From 4ef837d6c1ea9a5734f40e20a4a5e11bc8e225e8 Mon Sep 17 00:00:00 2001 From: mrampant Date: Tue, 18 Jun 2024 09:09:09 -0400 Subject: [PATCH 001/108] - starting 6.1.0.dev --- NEMO/migrations/0087_version_6_1_0.py | 19 +++++++++++++++++++ setup.py | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 NEMO/migrations/0087_version_6_1_0.py 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 00000000..d5452c0c --- /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/setup.py b/setup.py index 0d6ac86c..4c99a280 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name="NEMO", - version="6.0.2", + version="6.1.0.dev", python_requires=">=3.8, <4", packages=find_namespace_packages(exclude=["resources", "resources.*", "build", "build.*"]), include_package_data=True, From f57e759dfd7e0df642c121fc81603ad83a0dbba7 Mon Sep 17 00:00:00 2001 From: mrampant Date: Tue, 18 Jun 2024 12:32:42 -0400 Subject: [PATCH 002/108] - completely removed dependency on pytz. plugins will have to require it if they need it --- NEMO/templatetags/custom_tags_and_filters.py | 11 +++++-- NEMO/tests/test_formats.py | 6 ++-- NEMO/tests/test_settings.py | 2 +- NEMO/utilities.py | 30 +++++++++----------- setup.py | 1 - 5 files changed, 27 insertions(+), 23 deletions(-) diff --git a/NEMO/templatetags/custom_tags_and_filters.py b/NEMO/templatetags/custom_tags_and_filters.py index 73b4803b..0d9ee0b9 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 @@ -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_formats.py b/NEMO/tests/test_formats.py index de3f02fa..a4719239 100644 --- a/NEMO/tests/test_formats.py +++ b/NEMO/tests/test_formats.py @@ -1,6 +1,6 @@ import datetime +import zoneinfo -import pytz from django.conf import settings from django.test import TestCase from django.utils import timezone @@ -51,8 +51,8 @@ def test_format_daterange(self): end_date = datetime.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") + tz = zoneinfo.ZoneInfo("US/Pacific") + self.assertEqual(settings.TIME_ZONE, "US/Eastern") 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) self.assertNotEqual( diff --git a/NEMO/tests/test_settings.py b/NEMO/tests/test_settings.py index c40bc6dd..5a6bcbce 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/utilities.py b/NEMO/utilities.py index 09a23124..b57a3ac1 100644 --- a/NEMO/utilities.py +++ b/NEMO/utilities.py @@ -3,7 +3,7 @@ import os from calendar import monthrange from copy import deepcopy -from datetime import date, datetime, time +from datetime import date, datetime, time, timezone from email import encoders from email.mime.base import MIMEBase from enum import Enum @@ -12,7 +12,6 @@ from typing import Dict, List, Optional, Sequence, Set, Tuple, Union from urllib.parse import urljoin -import pytz from PIL import Image from dateutil import rrule from dateutil.parser import parse @@ -30,11 +29,10 @@ 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 utilities_logger = getLogger(__name__) @@ -241,7 +239,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 +250,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 +352,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 +368,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 +384,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: @@ -743,8 +741,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", diff --git a/setup.py b/setup.py index 4c99a280..dac5640e 100644 --- a/setup.py +++ b/setup.py @@ -40,7 +40,6 @@ "Pillow==10.3.0", "pymodbus==3.3.2", "python-dateutil==2.9.0", - "pytz==2024.1", "requests==2.32.3", ], extras_require={"dev-tools": ["pre-commit", "djlint", "black"]}, From e9840fbda3ab1482dfa560e6e117626e7ae1791b Mon Sep 17 00:00:00 2001 From: mrampant Date: Tue, 18 Jun 2024 12:45:49 -0400 Subject: [PATCH 003/108] - fixed tests for python 3.8 --- NEMO/tests/test_formats.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/NEMO/tests/test_formats.py b/NEMO/tests/test_formats.py index a4719239..53e5c342 100644 --- a/NEMO/tests/test_formats.py +++ b/NEMO/tests/test_formats.py @@ -1,5 +1,9 @@ import datetime -import zoneinfo + +try: + import zoneinfo +except ImportError: + from backports import zoneinfo from django.conf import settings from django.test import TestCase From e564433bdf79d9f1c1514f8727d14789480dc049 Mon Sep 17 00:00:00 2001 From: r-xyz <100710244+r-xyz@users.noreply.github.com> Date: Wed, 19 Jun 2024 00:41:09 +0200 Subject: [PATCH 004/108] Out of schedule email for areas. Extends functionality to accesses outside the allowed schedule for areas not requiring a reservation. Relevant area -related changes are mirrored from [NEMO-CE Merge Request](https://gitlab.com/nemo-community/nemo-ce/-/merge_requests/31). --- NEMO/views/timed_services.py | 100 +++++++++++++----- .../emails/out_of_time_reservation_email.html | 26 ++++- 2 files changed, 99 insertions(+), 27 deletions(-) diff --git a/NEMO/views/timed_services.py b/NEMO/views/timed_services.py index 0ebef423..9ecf3607 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,81 @@ def send_email_out_of_time_reservation_notification(request=None): if customer.is_staff or customer.is_service_personnel: continue - if area.requires_reservation: + 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]) + + # Find all users logged in tools + usage_records: List[UsageEvent] = ( + UsageEvent.objects.filter(end=None, user=F("operator")) + .prefetch_related("operator", "tool") + .only("operator", "tool") + ) + for usage_record in usage_records: + # staff and service personnel are exempt from out of time notification + operator = usage_record.operator + tool = usage_record.tool + if operator.is_staff or operator.is_service_personnel: + continue # 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 + trigger_time + if not tool.logout_grace_period + else trigger_time - timedelta(minutes=tool.logout_grace_period) ) - if ending_reservations.exists() and not starting_reservations.exists(): - out_of_time_user_area.append(ending_reservations[0]) + user_qualification = Qualification.objects.filter(user=operator, tool=tool).first() + # Check first if allowed schedule has just expired + if ( + user_qualification + and user_qualification.qualification_level + and user_qualification.qualification_level.is_allowed(threshold - timedelta(minutes=1)) + and not user_qualification.qualification_level.is_allowed(threshold) + ): + out_of_time_user_reservations.append(Reservation(user=operator, tool=tool, end=threshold)) + else: + if tool.reservation_required: + ending_reservations = Reservation.objects.filter( + cancelled=False, + missed=False, + shortened=False, + tool=tool, + user=operator, + 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 operator is good) + starting_reservations = Reservation.objects.filter( + cancelled=False, missed=False, shortened=False, tool=tool, user=operator, 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 +494,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/resources/emails/out_of_time_reservation_email.html b/resources/emails/out_of_time_reservation_email.html index 55e9291e..f287f1d1 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 %}

From 65c35f3505813efd7bdf6cf554eb589e2c09514c Mon Sep 17 00:00:00 2001 From: r-xyz <100710244+r-xyz@users.noreply.github.com> Date: Wed, 19 Jun 2024 15:26:41 +0200 Subject: [PATCH 005/108] Remove non-relevant code for out-of-schedule email. --- NEMO/views/timed_services.py | 45 ------------------------------------ 1 file changed, 45 deletions(-) diff --git a/NEMO/views/timed_services.py b/NEMO/views/timed_services.py index 9ecf3607..0dbcf3e1 100644 --- a/NEMO/views/timed_services.py +++ b/NEMO/views/timed_services.py @@ -439,51 +439,6 @@ def send_email_out_of_time_reservation_notification(request=None): if ending_reservations.exists() and not starting_reservations.exists(): out_of_time_user_reservations.append(ending_reservations[0]) - # Find all users logged in tools - usage_records: List[UsageEvent] = ( - UsageEvent.objects.filter(end=None, user=F("operator")) - .prefetch_related("operator", "tool") - .only("operator", "tool") - ) - for usage_record in usage_records: - # staff and service personnel are exempt from out of time notification - operator = usage_record.operator - tool = usage_record.tool - if operator.is_staff or operator.is_service_personnel: - continue - # Calculate the timestamp of how late a user can be logged in after a reservation ended. - threshold = ( - trigger_time - if not tool.logout_grace_period - else trigger_time - timedelta(minutes=tool.logout_grace_period) - ) - user_qualification = Qualification.objects.filter(user=operator, tool=tool).first() - # Check first if allowed schedule has just expired - if ( - user_qualification - and user_qualification.qualification_level - and user_qualification.qualification_level.is_allowed(threshold - timedelta(minutes=1)) - and not user_qualification.qualification_level.is_allowed(threshold) - ): - out_of_time_user_reservations.append(Reservation(user=operator, tool=tool, end=threshold)) - else: - if tool.reservation_required: - ending_reservations = Reservation.objects.filter( - cancelled=False, - missed=False, - shortened=False, - tool=tool, - user=operator, - 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 operator is good) - starting_reservations = Reservation.objects.filter( - cancelled=False, missed=False, shortened=False, tool=tool, user=operator, 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_reservations: send_out_of_time_reservation_notification(reservation, request) From 41b9d73a4ab1fe02712ad5d86a5e1dfa361c0fcc Mon Sep 17 00:00:00 2001 From: r-xyz <100710244+r-xyz@users.noreply.github.com> Date: Wed, 19 Jun 2024 16:09:42 +0200 Subject: [PATCH 006/108] Fix out-of-time email template. --- resources/emails/out_of_time_reservation_email.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/emails/out_of_time_reservation_email.html b/resources/emails/out_of_time_reservation_email.html index f287f1d1..bf208c00 100644 --- a/resources/emails/out_of_time_reservation_email.html +++ b/resources/emails/out_of_time_reservation_email.html @@ -32,14 +32,14 @@

{% else %} allowed schedule for the {% endif %} - {{ reservation.area }} in the {{ facility.name }} ended at {{ reservation.end }}. + {{ 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


{% 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. + 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 %} From 4d5749363620536ff31efc2f7a495947d3403a54 Mon Sep 17 00:00:00 2001 From: r-xyz <100710244+r-xyz@users.noreply.github.com> Date: Wed, 19 Jun 2024 17:48:24 +0200 Subject: [PATCH 007/108] Pickadate format based on py format (#242) * Pickadate format now based on js format. Closes #237 * Pickadate format conversion: remove additional unsupported formats. * Pickadate format conversion: fix submit format. * Pickadate format: rename variable. * Pickadate format: remove additional hardcoded value. * Pickadate format: fix unsupported removal order. * Pickadate format: fix typo. * Pickadate format: fix typo. * Pickadate format: remove duplicated formats. * Pickadate format: improved logic. Also renamed variables. * Update NEMO/apps/kiosk/templates/kiosk/tool_reservation.html * Update NEMO/templates/mobile/new_reservation.html * fixed wrong format for date --------- Co-authored-by: Mathieu Rampant --- .../templates/kiosk/tool_reservation.html | 12 ++--- NEMO/context_processors.py | 10 ++++- NEMO/templates/mobile/new_reservation.html | 12 ++--- NEMO/utilities.py | 44 ++++++++++++++++++- resources/settings.py | 9 ++++ 5 files changed, 73 insertions(+), 14 deletions(-) diff --git a/NEMO/apps/kiosk/templates/kiosk/tool_reservation.html b/NEMO/apps/kiosk/templates/kiosk/tool_reservation.html index ad055baa..1bc8d3f2 100644 --- a/NEMO/apps/kiosk/templates/kiosk/tool_reservation.html +++ b/NEMO/apps/kiosk/templates/kiosk/tool_reservation.html @@ -56,12 +56,12 @@

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 date_picker = $('#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_label}); + let end_time_picker = $('#end').pickatime({interval: 15, format: "{{ pickadate_time_format }}", formatSubmit: "H:i", hiddenName: true, formatLabel: format_label}); // set initial date if ('{{ date|default_if_none:'' }}') { - date_picker.pickadate('picker').set('select', '{{ date }}', {format: 'yyyy-mm-dd'}) + date_picker.pickadate('picker').set('select', '{{ date }}', {format: '{{ pickadate_date_format }}'}) } function refresh_times() { start_time_picker.pickatime('picker').render(); @@ -77,11 +77,11 @@

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

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'; + return '{{ pickadate_time_format }} !alre!ad!y re!serve!d'; } } } - return "h:i A"; + return '{{ pickadate_time_format }}'; } revert(120) diff --git a/NEMO/context_processors.py b/NEMO/context_processors.py index 18f3e5f3..9897cfc7 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/templates/mobile/new_reservation.html b/NEMO/templates/mobile/new_reservation.html index eea20145..ea2b370d 100644 --- a/NEMO/templates/mobile/new_reservation.html +++ b/NEMO/templates/mobile/new_reservation.html @@ -69,13 +69,13 @@

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

{% for times in item_reservation_times %} unavailable_times.push([{{ times.start|date:"U" }},{{ times.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 date_picker = $('#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_label}); + let end_time_picker = $('#end').pickatime({interval: 15, format: "{{ pickadate_time_format }}", formatSubmit: "H:i", hiddenName: true, formatLabel: format_label}); // set initial date if ('{{ date|default_if_none:'' }}') { - date_picker.pickadate('picker').set('select', '{{ date }}', {format: 'yyyy-mm-dd'}) + date_picker.pickadate('picker').set('select', '{{ date }}', {format: '{{ pickadate_date_format }}'}) } function refresh_times() { @@ -93,11 +93,11 @@

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

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'; + return '{{ pickadate_time_format }} !alre!ad!y re!serve!d'; } } } - return "h:i A"; + return "{{ pickadate_time_format }}"; } diff --git a/NEMO/utilities.py b/NEMO/utilities.py index b57a3ac1..f75f5035 100644 --- a/NEMO/utilities.py +++ b/NEMO/utilities.py @@ -64,6 +64,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: @@ -72,13 +90,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"] diff --git a/resources/settings.py b/resources/settings.py index 02af55e5..ac032dcc 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. From 45feacb5e3cb9e003a4ee49cbf68e6b6c5ea95be Mon Sep 17 00:00:00 2001 From: mrampant Date: Thu, 20 Jun 2024 18:37:21 -0400 Subject: [PATCH 008/108] - added hybrid calendar feed for reservation and usage --- NEMO/templates/calendar/calendar.html | 9 +++++++-- NEMO/views/calendar.py | 13 +++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/NEMO/templates/calendar/calendar.html b/NEMO/templates/calendar/calendar.html index b1f830bb..732fba37 100644 --- a/NEMO/templates/calendar/calendar.html +++ b/NEMO/templates/calendar/calendar.html @@ -34,6 +34,11 @@ href="javascript:void(0)" onclick="change_calendar_event_type(this)">{{ facility_name }} usage +
  • + Reservations and usage +
  • Date: Thu, 20 Jun 2024 18:57:29 -0400 Subject: [PATCH 009/108] - made the required unanswered questions email into a customizable template (when someone forces a user off a tool but there were required questions to answer) --- .../customizations_templates.html | 22 ++++++++ NEMO/views/customization.py | 1 + NEMO/views/tool_control.py | 53 ++++++++----------- ...l_required_unanswered_questions_email.html | 36 +++++++++++++ 4 files changed, 80 insertions(+), 32 deletions(-) create mode 100644 resources/emails/tool_required_unanswered_questions_email.html diff --git a/NEMO/templates/customizations/customizations_templates.html b/NEMO/templates/customizations/customizations_templates.html index f9a835e1..5f4d7c10 100644 --- a/NEMO/templates/customizations/customizations_templates.html +++ b/NEMO/templates/customizations/customizations_templates.html @@ -481,6 +481,28 @@

    +
    +

    + Tool required unanswered questions email +

    +

    + This email is sent to users when someone forces them off a tool and there were unanswered, required, post usage questions + The following context variables are provided when the email is rendered: +

    +
      +
    • + user - the user who was using the tool +
    • +
    • + tool - the tool the user was using +
    • +
    • + questions - the questions the user needs to answer +
    • +
    + {% include 'customizations/customizations_upload.html' with element=tool_required_unanswered_questions_email name='tool required unanswered questions email' key='templates' %} +
    +

    Unauthorized tool access email

    This email is sent when a user tries to access a tool:

    diff --git a/NEMO/views/customization.py b/NEMO/views/customization.py index 23284b47..1cdb0fe6 100644 --- a/NEMO/views/customization.py +++ b/NEMO/views/customization.py @@ -625,6 +625,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/tool_control.py b/NEMO/views/tool_control.py index 000fb16e..328b8c26 100644 --- a/NEMO/views/tool_control.py +++ b/NEMO/views/tool_control.py @@ -51,7 +51,6 @@ 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, @@ -731,38 +730,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): 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 00000000..f85b9577 --- /dev/null +++ b/resources/emails/tool_required_unanswered_questions_email.html @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + +
    +

    REQUIRED UNANSWERED QUESTIONS

    +
    +

    Dear {{ user.get_name }},

    +

    + You have been logged off by staff 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

    +
    + + + From e411b872ca46fa6579b6f2b302bfebac0497c49b Mon Sep 17 00:00:00 2001 From: mrampant Date: Fri, 21 Jun 2024 09:06:23 -0400 Subject: [PATCH 010/108] - now displaying UnitId/Bank/Multiplier in interlock names --- NEMO/models.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/NEMO/models.py b/NEMO/models.py index d3ca6b64..4352e22d 100644 --- a/NEMO/models.py +++ b/NEMO/models.py @@ -3211,6 +3211,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,6 +3219,10 @@ 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 "") From 39d9a6991e202690d71751ff40323c6dcb7d88f9 Mon Sep 17 00:00:00 2001 From: mrampant Date: Fri, 21 Jun 2024 09:23:00 -0400 Subject: [PATCH 011/108] - fixed template newline --- NEMO/templates/customizations/customizations_templates.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NEMO/templates/customizations/customizations_templates.html b/NEMO/templates/customizations/customizations_templates.html index 5f4d7c10..fb3e639a 100644 --- a/NEMO/templates/customizations/customizations_templates.html +++ b/NEMO/templates/customizations/customizations_templates.html @@ -487,8 +487,8 @@

    REQUIRED UNANSWERED QUES

    Dear {{ user.get_name }},

    -

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

    +

    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 }}
    From ae2912cfc843bb10361fbb6b7fc1dd34637edade Mon Sep 17 00:00:00 2001 From: mrampant Date: Thu, 27 Jun 2024 09:10:04 -0400 Subject: [PATCH 013/108] - in users page, now displaying projects using project selection template --- NEMO/models.py | 4 +++- NEMO/static/nemo.js | 3 ++- NEMO/templates/users/create_or_modify_user.html | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/NEMO/models.py b/NEMO/models.py index 4352e22d..f2aa1e09 100644 --- a/NEMO/models.py +++ b/NEMO/models.py @@ -2587,9 +2587,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}" diff --git a/NEMO/static/nemo.js b/NEMO/static/nemo.js index c608ca32..2c4f01d0 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/users/create_or_modify_user.html b/NEMO/templates/users/create_or_modify_user.html index c5b5189d..d3a24bde 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 }}); From 9addf36ecd05d01f2cd70fc0fe01cc9f6c595c51 Mon Sep 17 00:00:00 2001 From: mrampant Date: Mon, 1 Jul 2024 16:21:41 -0400 Subject: [PATCH 014/108] - updated Django version in classifiers --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index dac5640e..10d283ad 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ "License :: Public Domain", "Natural Language :: English", "Operating System :: OS Independent", - "Framework :: Django :: 3.2", + "Framework :: Django :: 4.2", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", From f1a4015723305441dae4ea7e572e4863eee43a9e Mon Sep 17 00:00:00 2001 From: mrampant Date: Wed, 3 Jul 2024 10:26:57 -0400 Subject: [PATCH 015/108] - fixed wrong API filters for physical access levels --- NEMO/views/api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NEMO/views/api.py b/NEMO/views/api.py index 91dac8f8..c2426f1a 100644 --- a/NEMO/views/api.py +++ b/NEMO/views/api.py @@ -629,8 +629,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, } From 52b55c3200f1767b1a332fc3546e72048bed8048 Mon Sep 17 00:00:00 2001 From: mrampant Date: Fri, 5 Jul 2024 11:31:39 -0400 Subject: [PATCH 016/108] - Attempting to fix a bug with adjustment request reviewers not loading properly --- NEMO/models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/NEMO/models.py b/NEMO/models.py index f2aa1e09..dda051e5 100644 --- a/NEMO/models.py +++ b/NEMO/models.py @@ -4243,8 +4243,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) From 5ffb7e0e8b177bd1526eb6dbb2782a1e9d785615 Mon Sep 17 00:00:00 2001 From: mrampant Date: Mon, 8 Jul 2024 20:24:42 -0400 Subject: [PATCH 017/108] - fixed a migration print statement --- NEMO/migrations/0081_oracle_django_4_rename.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/NEMO/migrations/0081_oracle_django_4_rename.py b/NEMO/migrations/0081_oracle_django_4_rename.py index 375e3534..38946fed 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) From ab0e2651f4431c6f634fd5cc50a068a8a928fde2 Mon Sep 17 00:00:00 2001 From: mrampant Date: Fri, 12 Jul 2024 12:52:23 -0400 Subject: [PATCH 018/108] - updated Django to 4.2.14 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 10d283ad..b8e9283f 100644 --- a/setup.py +++ b/setup.py @@ -29,7 +29,7 @@ ], install_requires=[ "cryptography==42.0.8", - "Django==4.2.11", + "Django==4.2.14", "django-auditlog==3.0.0", "django-filter==23.5", "django-mptt==0.14.0", From 7673521cf4cca0273075ff05b10cf2dda8fe9620 Mon Sep 17 00:00:00 2001 From: mrampant Date: Fri, 12 Jul 2024 12:53:17 -0400 Subject: [PATCH 019/108] - updated drf to 3.15.2 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b8e9283f..8af8ae92 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ "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", From e77937f6a52d28ff3459d562a582a1e18a5d2bf5 Mon Sep 17 00:00:00 2001 From: mrampant Date: Fri, 26 Jul 2024 15:24:50 -0400 Subject: [PATCH 020/108] - changed "facility usage" to "facility use" --- NEMO/apps/kiosk/templates/kiosk/choices.html | 2 +- NEMO/templates/calendar/calendar.html | 16 ++++++++-------- NEMO/templates/landing.html | 2 +- NEMO/templates/usage/usage_base.html | 2 +- NEMO/views/calendar.py | 4 ++-- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/NEMO/apps/kiosk/templates/kiosk/choices.html b/NEMO/apps/kiosk/templates/kiosk/choices.html index ddd54bfb..7a8ffd90 100644 --- a/NEMO/apps/kiosk/templates/kiosk/choices.html +++ b/NEMO/apps/kiosk/templates/kiosk/choices.html @@ -14,7 +14,7 @@ {% endfor %} {% endif %} {% if usage_events %} -

    Current {{ facility_name }} usage

    +

    Current {{ facility_name }} use

    {% for u in usage_events %}
    diff --git a/NEMO/templates/calendar/calendar.html b/NEMO/templates/calendar/calendar.html index 732fba37..0dc35e51 100644 --- a/NEMO/templates/calendar/calendar.html +++ b/NEMO/templates/calendar/calendar.html @@ -29,15 +29,15 @@

  • + title="Displays your {{ facility_name }} use. This includes{% if areas %} area access,{% endif %}{% if tools %} tool usage,{% endif %} and missed reservations (which are all billable items)."> {{ facility_name }} usage + onclick="change_calendar_event_type(this)">{{ facility_name }} use
  • -
  • +
  • Reservations and usage + onclick="change_calendar_event_type(this)">Reservations and use
  • + title="Displays {{ facility_name }} reservations and use for a specific user. This includes reservations, area access, tool usage, and missed reservations."> Specific user @@ -184,7 +184,7 @@ let item_event_source_list = []; let url = "{% url 'event_feed' %}"; - if (event_type === 'reservations' || event_type === 'configuration agenda' || event_type === '{{ facility_name|lower }} usage' || event_type === 'reservations and usage') + if (event_type === 'reservations' || event_type === 'configuration agenda' || event_type === '{{ facility_name|lower }} use' || event_type === 'reservations and use') { let item = get_selected_item(); const checked_items = get_checked_items(); @@ -369,7 +369,7 @@ let item = $(".selected"); {# Only allow reservations to be created when a item has been selected. #} let item_selected = (item != null); - {# Only allow selection to proceed when we're in reservation view (instead of usage view). #} + {# Only allow selection to proceed when we're in reservation view (instead of use view). #} let reservation_view = get_event_type() === 'reservations'; {# Users may not create reservations when viewing their personal schedule. #} let personal_schedule = item.hasClass('personal_schedule'); @@ -838,7 +838,7 @@ $(scheduled_outage_button).hide(); update_event_sources(); } - else if(event_type === "{{ facility_name|lower }} usage" || event_type === "reservations and usage") + else if(event_type === "{{ facility_name|lower }} use" || event_type === "reservations and use") { user_search.hide(); chosen_user_button.hide(); diff --git a/NEMO/templates/landing.html b/NEMO/templates/landing.html index 89290b82..8a1b6c03 100644 --- a/NEMO/templates/landing.html +++ b/NEMO/templates/landing.html @@ -105,7 +105,7 @@

    Alerts & outages

    {% endif %} {% if usage_events or user.in_area or self_log_in or user.is_staff and user.charging_staff_time %}
    -

    Current {{ facility_name }} usage

    +

    Current {{ facility_name }} use

    {% if user.is_staff and user.charging_staff_time %} diff --git a/NEMO/templates/usage/usage_base.html b/NEMO/templates/usage/usage_base.html index 443905d8..856854f4 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/views/calendar.py b/NEMO/views/calendar.py index f04d0823..1843633a 100644 --- a/NEMO/views/calendar.py +++ b/NEMO/views/calendar.py @@ -175,7 +175,7 @@ def event_feed(request): facility_name = ApplicationCustomization.get("facility_name") if event_type == "reservations": return reservation_event_feed(request, start, end) - if event_type == "reservations and 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 @@ -188,7 +188,7 @@ def event_feed(request): "[".encode(), "".encode(), 1 ) return reservation_feed - elif event_type == f"{facility_name.lower()} usage": + 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: From fabc6daf366bd18cd2f6640f09a7f96d54b95aa2 Mon Sep 17 00:00:00 2001 From: mrampant Date: Sat, 27 Jul 2024 18:18:56 -0400 Subject: [PATCH 021/108] - fixed ordering issue with configuration option for reservations --- .../0088_alter_configurationoption_options.py | 17 +++++++++++++++++ NEMO/models.py | 3 +++ 2 files changed, 20 insertions(+) create mode 100644 NEMO/migrations/0088_alter_configurationoption_options.py diff --git a/NEMO/migrations/0088_alter_configurationoption_options.py b/NEMO/migrations/0088_alter_configurationoption_options.py new file mode 100644 index 00000000..7f9658d8 --- /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/models.py b/NEMO/models.py index dda051e5..6e36ba89 100644 --- a/NEMO/models.py +++ b/NEMO/models.py @@ -2181,6 +2181,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): From a47a7959a265713155769b047ddd12df59165715 Mon Sep 17 00:00:00 2001 From: mrampant Date: Thu, 1 Aug 2024 16:12:13 -0400 Subject: [PATCH 022/108] - fixed adjustment requests link not working in email template - added creation time to adjustment request admin page --- NEMO/admin.py | 1 + NEMO/views/adjustment_requests.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/NEMO/admin.py b/NEMO/admin.py index 41f67627..48d52eb1 100644 --- a/NEMO/admin.py +++ b/NEMO/admin.py @@ -1723,6 +1723,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): diff --git a/NEMO/views/adjustment_requests.py b/NEMO/views/adjustment_requests.py index 97a05b0e..93a391e6 100644 --- a/NEMO/views/adjustment_requests.py +++ b/NEMO/views/adjustment_requests.py @@ -311,7 +311,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, } From c365410919df8d19869c9c502efb063ba592cf14 Mon Sep 17 00:00:00 2001 From: mrampant Date: Thu, 1 Aug 2024 16:13:15 -0400 Subject: [PATCH 023/108] - in tool control data usage history tab, changed date label for pre-run data to "Start date" and to "End date" for post run data --- NEMO/views/tool_control.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NEMO/views/tool_control.py b/NEMO/views/tool_control.py index 328b8c26..95b66ce4 100644 --- a/NEMO/views/tool_control.py +++ b/NEMO/views/tool_control.py @@ -242,13 +242,13 @@ def usage_data_history(request, tool_id): table_pre_run_data.add_header(("user", "User")) 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")) 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: From feeff7a90be2aab669b35aa11aa61d66f46addb4 Mon Sep 17 00:00:00 2001 From: mrampant Date: Tue, 6 Aug 2024 08:27:17 -0400 Subject: [PATCH 024/108] - fixed impersonate permission not working for anyone other than admins - restricted impersonate so regular users cannot impersonate admins --- NEMO/middleware.py | 2 +- NEMO/tests/test_users.py | 43 ++++++++++++++++++++++++++++++++++++ NEMO/views/authentication.py | 27 +++++++++++++++++++++- 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/NEMO/middleware.py b/NEMO/middleware.py index a571283b..9ddfb809 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/tests/test_users.py b/NEMO/tests/test_users.py index 9f009d03..85512ee6 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/views/authentication.py b/NEMO/views/authentication.py index 0d7e0d30..f7ba680d 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}) From c10220b0f95196a2dc80fa2e1c5cb5dddda469e3 Mon Sep 17 00:00:00 2001 From: mrampant Date: Tue, 27 Aug 2024 09:17:46 -0400 Subject: [PATCH 025/108] - removed pagination on readonly api endpoints to be consistent with other API behavior --- NEMO/views/api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/NEMO/views/api.py b/NEMO/views/api.py index c2426f1a..f170a679 100644 --- a/NEMO/views/api.py +++ b/NEMO/views/api.py @@ -569,6 +569,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, @@ -727,6 +728,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, From c8db407a284b03cbcc40e99f5fc2c10ec07e7ff6 Mon Sep 17 00:00:00 2001 From: mrampant Date: Tue, 27 Aug 2024 09:19:08 -0400 Subject: [PATCH 026/108] - removed links from footer for kiosk and area access pages --- NEMO/apps/area_access/templates/area_access/base.html | 2 +- NEMO/apps/kiosk/templates/kiosk/kiosk.html | 2 +- NEMO/templates/base/footer.html | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/NEMO/apps/area_access/templates/area_access/base.html b/NEMO/apps/area_access/templates/area_access/base.html index 61dce0a4..86604326 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 %} From 45e6babe86671020038ed61bd45d5af373288649 Mon Sep 17 00:00:00 2001 From: mrampant Date: Fri, 6 Sep 2024 19:18:03 -0400 Subject: [PATCH 033/108] - fixed a bug when NEMO would throw error 500 instead of return a bad request when an exception happens when looking up comments and tasks --- NEMO/views/tool_control.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/NEMO/views/tool_control.py b/NEMO/views/tool_control.py index 95b66ce4..326f0144 100644 --- a/NEMO/views/tool_control.py +++ b/NEMO/views/tool_control.py @@ -580,13 +580,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: From d78c43e309d2620e272fec2dba8e25003213c9d7 Mon Sep 17 00:00:00 2001 From: r-xyz <100710244+r-xyz@users.noreply.github.com> Date: Sun, 8 Sep 2024 19:20:52 +0200 Subject: [PATCH 034/108] Add reservation end date in kiosk. Fixes #253. --- .../templates/kiosk/tool_reservation.html | 63 ++++++++++++------- NEMO/apps/kiosk/views.py | 11 ++-- 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/NEMO/apps/kiosk/templates/kiosk/tool_reservation.html b/NEMO/apps/kiosk/templates/kiosk/tool_reservation.html index 1bc8d3f2..52629631 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: "{{ 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_label}); - let end_time_picker = $('#end').pickatime({interval: 15, format: "{{ pickadate_time_format }}", 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: '{{ pickadate_date_format }}'}) + 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 '{{ pickadate_time_format }} !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 '{{ pickadate_time_format }}'; - } + return '{{ pickadate_time_format }}'; + } + } revert(120) diff --git a/NEMO/apps/kiosk/views.py b/NEMO/apps/kiosk/views.py index 507b3458..1bc7d86a 100644 --- a/NEMO/apps/kiosk/views.py +++ b/NEMO/apps/kiosk/views.py @@ -255,11 +255,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() From 73a68245e28bca6d47a3a8f934097148300c967f Mon Sep 17 00:00:00 2001 From: mrampant Date: Sun, 8 Sep 2024 13:26:07 -0400 Subject: [PATCH 035/108] - fixed max datetime issue --- NEMO/views/status_dashboard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NEMO/views/status_dashboard.py b/NEMO/views/status_dashboard.py index fc0c5e1b..7676712f 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": From 50d26a4e16b10a0e4f9b3141a633999ccc0a1c1c Mon Sep 17 00:00:00 2001 From: mrampant Date: Mon, 9 Sep 2024 13:48:02 -0400 Subject: [PATCH 036/108] - fixed tests still using old name for facility use feed in calendar --- NEMO/tests/test_urls.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/NEMO/tests/test_urls.py b/NEMO/tests/test_urls.py index 5e697c5a..5205c1e1 100644 --- a/NEMO/tests/test_urls.py +++ b/NEMO/tests/test_urls.py @@ -221,7 +221,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 +234,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 +246,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 +258,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 +270,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", From bdf41bb75f0f192e693bf020dde9686f229b4279 Mon Sep 17 00:00:00 2001 From: mrampant Date: Tue, 10 Sep 2024 10:02:12 -0400 Subject: [PATCH 037/108] - added new dynamic form question of type "radio_report_problem" to automatically report tool issues from pre/post usage questions --- NEMO/apps/kiosk/views.py | 6 +-- NEMO/views/tool_control.py | 6 +-- NEMO/widgets/dynamic_form.py | 71 ++++++++++++++++++++++++++++++------ 3 files changed, 63 insertions(+), 20 deletions(-) diff --git a/NEMO/apps/kiosk/views.py b/NEMO/apps/kiosk/views.py index 1bc7d86a..1645f4ab 100644 --- a/NEMO/apps/kiosk/views.py +++ b/NEMO/apps/kiosk/views.py @@ -100,11 +100,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) @@ -160,11 +159,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 = { diff --git a/NEMO/views/tool_control.py b/NEMO/views/tool_control.py index 326f0144..a8775de7 100644 --- a/NEMO/views/tool_control.py +++ b/NEMO/views/tool_control.py @@ -446,10 +446,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 @@ -506,10 +505,9 @@ def disable_tool(request, tool_id): 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(): diff --git a/NEMO/widgets/dynamic_form.py b/NEMO/widgets/dynamic_form.py index c2a0671b..cc0ab95c 100644 --- a/NEMO/widgets/dynamic_form.py +++ b/NEMO/widgets/dynamic_form.py @@ -9,13 +9,15 @@ from logging import getLogger from typing import Any, Callable, Dict, List, Optional, Type +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.models import Consumable, Task, ToolUsageCounter, UsageEvent from NEMO.utilities import EmptyHttpRequest, quiet_int, slugify_underscore, strtobool from NEMO.views.consumables import make_withdrawal @@ -56,6 +58,7 @@ def __init__(self, properties: Dict, index: int = None): 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.group_add_button_name = self._init_property("group_add_button_name") or "Add" self.index = index if index and not isinstance(self, PostUsageGroupQuestion): @@ -436,6 +439,27 @@ 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): + properties["choices"] = ["true", "false"] + if not properties.get("labels"): + properties["labels"] = ["Yes", "No"] + super().__init__(properties, index) + + 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" @@ -455,6 +479,8 @@ def validate(self): 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: @@ -638,15 +664,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) @@ -656,14 +687,9 @@ 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): + def _update_tool_counters(self, usage_event: UsageEvent, run_data_json: Dict): # 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) + 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: @@ -680,6 +706,26 @@ def update_tool_counters(self, run_data: str, tool_id: int): counter.value += 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) + 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""" @@ -804,6 +850,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, From 42cb4da642260e6a1fc2326a8741edbabdf91a49 Mon Sep 17 00:00:00 2001 From: mrampant Date: Wed, 11 Sep 2024 10:53:36 -0400 Subject: [PATCH 038/108] - updated new pb reports to be sent to qualified users bcc --- NEMO/views/tasks.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/NEMO/views/tasks.py b/NEMO/views/tasks.py index 360ff5df..632e5e15 100644 --- a/NEMO/views/tasks.py +++ b/NEMO/views/tasks.py @@ -136,12 +136,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.update( [ email for email in qualified_user.get_emails( @@ -153,7 +152,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 +227,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 +264,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 +402,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) -> (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 +432,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 From 4a85a304f8c402f73e099ffa8ec5eeac3c1678db Mon Sep 17 00:00:00 2001 From: mrampant Date: Mon, 16 Sep 2024 09:31:55 -0400 Subject: [PATCH 039/108] - fixed script_name messing up landing page choices when script name is None --- NEMO/templates/landing.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NEMO/templates/landing.html b/NEMO/templates/landing.html index 79cde7b9..9ebd830f 100644 --- a/NEMO/templates/landing.html +++ b/NEMO/templates/landing.html @@ -150,7 +150,7 @@

    Current {{ facility_name }} use

    {% for choice in landing_page_choices %} {% if forloop.counter0|divisibleby:4 %}
    {% endif %}
    - From a0af3f4921c9e88a7fef4684982cc75f240e2832 Mon Sep 17 00:00:00 2001 From: mrampant Date: Fri, 20 Sep 2024 22:20:25 -0400 Subject: [PATCH 040/108] - added request message for PostUsageRadioReportProblemQuestion (on success) --- NEMO/widgets/dynamic_form.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NEMO/widgets/dynamic_form.py b/NEMO/widgets/dynamic_form.py index cc0ab95c..95616b05 100644 --- a/NEMO/widgets/dynamic_form.py +++ b/NEMO/widgets/dynamic_form.py @@ -9,6 +9,7 @@ from logging import getLogger from typing import Any, Callable, Dict, List, Optional, Type +from django.contrib import messages from django.core.exceptions import ValidationError from django.http import QueryDict from django.urls import NoReverseMatch, reverse @@ -725,6 +726,8 @@ def _report_problems(self, usage_event: UsageEvent, run_data_json: Dict, request 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: From 2613f5bcd144d5b0e4e8810ff25dd477d0135c20 Mon Sep 17 00:00:00 2001 From: mrampant Date: Fri, 20 Sep 2024 22:25:47 -0400 Subject: [PATCH 041/108] - finally fixed reservation policy issues when policy is off during weekdays and weekend: - reservation time during off hours is not taken into account anymore when checking reservation rules (if reservation time overlaps with off time) - reservations scheduled during off hours are also not taken into account when calculating total reservation time in the future and reservation count per day --- NEMO/models.py | 16 ++ NEMO/policy.py | 140 ++++++++---- .../test_calendar/test_area_reservation.py | 2 +- .../test_calendar/test_tool_reservation.py | 6 +- NEMO/tests/test_formats.py | 211 ++++++++++++++++-- NEMO/utilities.py | 83 ++++++- 6 files changed, 393 insertions(+), 65 deletions(-) diff --git a/NEMO/models.py b/NEMO/models.py index 93f3a9c8..ea2f2b2d 100644 --- a/NEMO/models.py +++ b/NEMO/models.py @@ -44,6 +44,7 @@ format_daterange, format_datetime, get_chemical_document_filename, + get_duration_with_off_schedule, get_full_url, get_hazard_logo_filename, get_model_instance, @@ -2718,6 +2719,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 diff --git a/NEMO/policy.py b/NEMO/policy.py index d94f879f..e78cba31 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,8 +605,8 @@ 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) + 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: should_enforce = False if item.policy_off_between_times and item.policy_off_start_time and item.policy_off_end_time: @@ -630,29 +632,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 +668,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 +705,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/tests/test_calendar/test_area_reservation.py b/NEMO/tests/test_calendar/test_area_reservation.py index dc52085e..4b9de2c9 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 a0dca4fd..9a05339d 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_formats.py b/NEMO/tests/test_formats.py index 53e5c342..e02a9828 100644 --- a/NEMO/tests/test_formats.py +++ b/NEMO/tests/test_formats.py @@ -1,4 +1,4 @@ -import datetime +from datetime import date, datetime, time, timedelta try: import zoneinfo @@ -11,15 +11,15 @@ 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)) @@ -27,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 = zoneinfo.ZoneInfo("US/Pacific") self.assertEqual(settings.TIME_ZONE, "US/Eastern") - 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) + 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", @@ -68,11 +68,180 @@ 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().astimezone().replace(year=2024, month=9, day=17, hour=6, minute=0, second=0, microsecond=0) + ) + 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().astimezone().replace(year=2024, month=9, day=17, hour=6, minute=0, second=0, microsecond=0) + ) + 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().astimezone().replace(year=2024, month=9, day=17, hour=18, minute=0, second=0, microsecond=0) + ) + 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/utilities.py b/NEMO/utilities.py index 7e322e42..61446170 100644 --- a/NEMO/utilities.py +++ b/NEMO/utilities.py @@ -3,7 +3,7 @@ import os from calendar import monthrange from copy import deepcopy -from datetime import date, datetime, time, timezone +from datetime import date, datetime, time, timedelta, timezone from email import encoders from email.mime.base import MIMEBase from enum import Enum @@ -839,3 +839,84 @@ def strtobool(val): 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 From cbb59be1b87ea45b651314cf097ec503ec29b286 Mon Sep 17 00:00:00 2001 From: mrampant Date: Thu, 26 Sep 2024 16:21:38 -0400 Subject: [PATCH 042/108] - now returning validation errors in broadcast email page when there are any --- NEMO/views/email.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NEMO/views/email.py b/NEMO/views/email.py index 354f1789..902e9079 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: From fb6f61ea70f5801a3c1744bb6f8d13229e3d5064 Mon Sep 17 00:00:00 2001 From: mrampant Date: Thu, 26 Sep 2024 17:01:03 -0400 Subject: [PATCH 043/108] - now sending email with required questions when someone else forces someone off (even if they are not staff or user office) --- NEMO/apps/kiosk/views.py | 4 ++-- NEMO/views/tool_control.py | 8 ++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/NEMO/apps/kiosk/views.py b/NEMO/apps/kiosk/views.py index 1645f4ab..6f680986 100644 --- a/NEMO/apps/kiosk/views.py +++ b/NEMO/apps/kiosk/views.py @@ -150,8 +150,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: diff --git a/NEMO/views/tool_control.py b/NEMO/views/tool_control.py index a8775de7..8f46c80f 100644 --- a/NEMO/views/tool_control.py +++ b/NEMO/views/tool_control.py @@ -493,12 +493,8 @@ 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: From 44fec17ea531cfca7c6af7290d9f513f6b13939d Mon Sep 17 00:00:00 2001 From: mrampant Date: Sat, 28 Sep 2024 01:43:12 -0400 Subject: [PATCH 044/108] - update to tool usage counters - added way to increase or decrease counters - added multiple checkboxes to decide who can reset the counter (staff/superusers/qualified users) - added flag to not send reset notification to facility members --- NEMO/admin.py | 12 ++- ...usagecounter_counter_direction_and_more.py | 55 +++++++++++ NEMO/models.py | 96 +++++++++++++++---- NEMO/templates/tool_control/tool_status.html | 4 +- NEMO/views/tool_control.py | 43 +++++---- NEMO/widgets/dynamic_form.py | 12 +-- resources/fixtures/splash_pad.json | 1 + 7 files changed, 176 insertions(+), 47 deletions(-) create mode 100644 NEMO/migrations/0090_toolusagecounter_counter_direction_and_more.py diff --git a/NEMO/admin.py b/NEMO/admin.py index 48d52eb1..9f39c983 100644 --- a/NEMO/admin.py +++ b/NEMO/admin.py @@ -1667,11 +1667,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"] 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 00000000..62800ce8 --- /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/models.py b/NEMO/models.py index ea2f2b2d..86edf0fb 100644 --- a/NEMO/models.py +++ b/NEMO/models.py @@ -4067,43 +4067,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=200, 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", + 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) @@ -4116,17 +4176,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: diff --git a/NEMO/templates/tool_control/tool_status.html b/NEMO/templates/tool_control/tool_status.html index 44bcdcca..6c5b9d5a 100644 --- a/NEMO/templates/tool_control/tool_status.html +++ b/NEMO/templates/tool_control/tool_status.html @@ -133,11 +133,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 }}
    diff --git a/NEMO/views/tool_control.py b/NEMO/views/tool_control.py index 8f46c80f..5bbec7e7 100644 --- a/NEMO/views/tool_control.py +++ b/NEMO/views/tool_control.py @@ -667,12 +667,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() @@ -680,28 +682,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") diff --git a/NEMO/widgets/dynamic_form.py b/NEMO/widgets/dynamic_form.py index 95616b05..4a24ec3b 100644 --- a/NEMO/widgets/dynamic_form.py +++ b/NEMO/widgets/dynamic_form.py @@ -689,22 +689,20 @@ def _charge_for_consumables(self, usage_event, run_data_json: Dict, request=None ) def _update_tool_counters(self, usage_event: UsageEvent, run_data_json: Dict): - # This function increments all counters associated with the given tool + # 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): @@ -795,7 +793,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"]: diff --git a/resources/fixtures/splash_pad.json b/resources/fixtures/splash_pad.json index 1ffb4f0c..00b97888 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, From fe1c141b6657516a83381c299b3cd06ebd7ae2be Mon Sep 17 00:00:00 2001 From: mrampant Date: Thu, 3 Oct 2024 23:09:25 -0400 Subject: [PATCH 045/108] - added a way to mark charges as waived - users can now create adjustment requests to ask for a charge to be waived - added all the new fields/flags to the billing API --- NEMO/actions.py | 8 + NEMO/admin.py | 19 ++- .../0091_adjustmentrequest_waive_charges.py | 146 ++++++++++++++++++ NEMO/mixins.py | 48 +++++- NEMO/models.py | 109 +++++++++++-- NEMO/serializers.py | 5 + .../customizations_requests.html | 49 ++++++ .../adjustment_request.html | 36 ++++- NEMO/views/adjustment_requests.py | 68 +++----- NEMO/views/api_billing.py | 35 +++++ NEMO/views/customization.py | 4 + 11 files changed, 458 insertions(+), 69 deletions(-) create mode 100644 NEMO/migrations/0091_adjustmentrequest_waive_charges.py diff --git a/NEMO/actions.py b/NEMO/actions.py index 16064edc..177b9723 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 9f39c983..d35e926e 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 @@ -486,7 +487,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 +506,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 +515,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) @@ -667,7 +671,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): @@ -745,7 +750,8 @@ class UsageEventAdmin(ObjPermissionAdminMixin, ModelAdminRedirectMixin, admin.Mo list_display = ("id", "tool", "user", "operator", "project", "start", "end", "duration", "remote_work") list_filter = ("remote_work", "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 +783,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) diff --git a/NEMO/migrations/0091_adjustmentrequest_waive_charges.py b/NEMO/migrations/0091_adjustmentrequest_waive_charges.py new file mode 100644 index 00000000..e73de7af --- /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/mixins.py b/NEMO/mixins.py index 6fb12442..06ab9fc8 100644 --- a/NEMO/mixins.py +++ b/NEMO/mixins.py @@ -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,7 +125,7 @@ def get_end(self) -> Optional[datetime.datetime]: return self.date def can_be_adjusted(self, user: User): - # determine if a charge can be adjusted + # determine if the given user can make an adjustment request for this charge from NEMO.views.customization import UserRequestsCustomization from NEMO.views.usage import get_managed_projects @@ -171,6 +184,39 @@ def can_be_adjusted(self, user: User): missed_resa_enabled = UserRequestsCustomization.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 UserRequestsCustomization + from NEMO.models import AreaAccessRecord, ConsumableWithdraw, Reservation, UsageEvent + + return ( + isinstance(self, AreaAccessRecord) + and UserRequestsCustomization.get_bool("adjustment_requests_waive_area_access_enabled") + or isinstance(self, UsageEvent) + and UserRequestsCustomization.get_bool("adjustment_requests_waive_tool_usage_enabled") + or isinstance(self, ConsumableWithdraw) + and UserRequestsCustomization.get_bool("adjustment_requests_waive_consumable_withdrawal_enabled") + or isinstance(self, Reservation) + and UserRequestsCustomization.get_bool("adjustment_requests_waive_missed_reservation_enabled") + ) + + def can_times_be_changed(item): + from NEMO.views.customization import UserRequestsCustomization + from NEMO.models import ConsumableWithdraw, Reservation + + 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 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 86edf0fb..c1be4920 100644 --- a/NEMO/models.py +++ b/NEMO/models.py @@ -9,7 +9,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 @@ -2206,6 +2206,16 @@ class Type(object): 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"] @@ -2225,6 +2235,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"] @@ -2508,6 +2528,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 = [ @@ -2688,6 +2718,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]: @@ -2793,6 +2828,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"] @@ -2845,6 +2885,16 @@ class UsageEvent(BaseModel, CalendarDisplayMixin, BillableItemMixin): remote_work = 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 duration(self): return calculate_duration(self.start, self.end, "In progress") @@ -2966,6 +3016,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"] @@ -3007,6 +3062,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) @@ -4254,6 +4310,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 @@ -4310,10 +4367,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""" @@ -4343,7 +4408,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: @@ -4360,11 +4430,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 @@ -4378,6 +4446,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.")}) @@ -4393,11 +4469,12 @@ def clean(self): 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 + not self.waive + and 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): + ) or (not self.waive and 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)")} ) @@ -4708,6 +4785,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/serializers.py b/NEMO/serializers.py index 34e284a3..e8fd0a6b 100644 --- a/NEMO/serializers.py +++ b/NEMO/serializers.py @@ -539,6 +539,11 @@ class BillableItemSerializer(serializers.Serializer): 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 = IntegerField(read_only=True, source="validated_by.id") + waived = BooleanField(read_only=True) + waived_by = IntegerField(read_only=True, source="waived_by.id") + waived_on = DateTimeField(read_only=True) def update(self, instance, validated_data): pass diff --git a/NEMO/templates/customizations/customizations_requests.html b/NEMO/templates/customizations/customizations_requests.html index b883619c..6965b736 100644 --- a/NEMO/templates/customizations/customizations_requests.html +++ b/NEMO/templates/customizations/customizations_requests.html @@ -169,6 +169,55 @@

    Adjustment requests settings


    +
    + +
    +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    - {% if change_times_allowed and form.item_id.value and form.item_type.value %} -
    + {% if item.can_times_be_changed and form.item_id.value and form.item_type.value %} +
    @@ -104,8 +104,8 @@

    {% endif %}

    {% endif %} - {% if change_quantity_allowed and form.item_id.value and form.item_type.value %} -
    + {% if item.can_quantity_be_changed and form.item_id.value and form.item_type.value %} +
    @@ -113,7 +113,7 @@

    class="form-control text-center" name="new_quantity" id="new_quantity" - value="{{ form.new_quantity.value }}"> + value="{{ form.new_quantity.value|default_if_none:'' }}">

    {% if form.new_quantity.errors %} @@ -125,6 +125,19 @@

    {% endif %}

    {% endif %} + {% if item.can_be_waived %} +
    + +
    + {% endif %}
    {% if form.description.errors %}- {{ form.description.errors|striptags }}{% endif %} @@ -197,6 +210,15 @@

    submit_and_disable(review_button) } } - + function toggle_waive_fields() + { + let waive_checkbox = $("#waive_charge")[0]; + if (waive_checkbox) + { + $("#new_quantity, #new_times").toggle(!waive_checkbox.checked); + } + } + + toggle_waive_fields(); {% endblock %} diff --git a/NEMO/views/adjustment_requests.py b/NEMO/views/adjustment_requests.py index 93a391e6..21569900 100644 --- a/NEMO/views/adjustment_requests.py +++ b/NEMO/views/adjustment_requests.py @@ -107,27 +107,35 @@ 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) - - # 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: + # 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(): 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: + if item_changed and adjustment_request.item and adjustment_request.item.can_quantity_be_changed(): adjustment_request.new_quantity = adjustment_request.item.quantity - dictionary = { - "change_times_allowed": change_times_allowed, - "change_quantity_allowed": change_quantity_allowed, - "eligible_items": adjustment_eligible_items(user, adjustment_request.item), - } + edit = bool(adjustment_request.id) + form = AdjustmentRequestForm( + request.POST or None, + instance=adjustment_request, + initial={"creator": adjustment_request.creator if edit else user}, + ) + + 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 + + # 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 +145,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) @@ -172,7 +174,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,20 +188,7 @@ 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 @@ -374,15 +363,6 @@ 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() diff --git a/NEMO/views/api_billing.py b/NEMO/views/api_billing.py index 25dfd566..5deb6e03 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/customization.py b/NEMO/views/customization.py index 1cdb0fe6..5e570dd3 100644 --- a/NEMO/views/customization.py +++ b/NEMO/views/customization.py @@ -378,6 +378,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", From 35c0d81349cc3b6323e13577ef2d096949ad7cba Mon Sep 17 00:00:00 2001 From: mrampant Date: Thu, 3 Oct 2024 23:47:55 -0400 Subject: [PATCH 046/108] - increased project name length to 1024 - streamlining all char field lengths using CHAR_FIELD_SMALL_LENGTH (100), CHAR_FIELD_MEDIUM_LENGTH (255) and CHAR_FIELD_LARGE_LENGTH (1024) constants --- ...tractoragreementdocuments_name_and_more.py | 37 ++ NEMO/apps/contracts/models.py | 10 +- ...label_alter_sensor_data_prefix_and_more.py | 53 +++ NEMO/apps/sensors/models.py | 31 +- ...type_name_alter_alert_category_and_more.py | 371 ++++++++++++++++++ NEMO/models.py | 196 +++++---- NEMO/serializers.py | 28 +- NEMO/views/constants.py | 8 +- 8 files changed, 619 insertions(+), 115 deletions(-) create mode 100644 NEMO/apps/contracts/migrations/0005_alter_contractoragreementdocuments_name_and_more.py create mode 100644 NEMO/apps/sensors/migrations/0002_alter_sensor_data_label_alter_sensor_data_prefix_and_more.py create mode 100644 NEMO/migrations/0092_alter_accounttype_name_alter_alert_category_and_more.py diff --git a/NEMO/apps/contracts/migrations/0005_alter_contractoragreementdocuments_name_and_more.py b/NEMO/apps/contracts/migrations/0005_alter_contractoragreementdocuments_name_and_more.py new file mode 100644 index 00000000..82b583fe --- /dev/null +++ b/NEMO/apps/contracts/migrations/0005_alter_contractoragreementdocuments_name_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.15 on 2024-10-04 03:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contracts", "0004_default_display_order"), + ] + + operations = [ + migrations.AlterField( + model_name="contractoragreementdocuments", + 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="contractoragreementdocuments", + name="url", + field=models.URLField(blank=True, null=True, verbose_name="URL"), + ), + migrations.AlterField( + model_name="procurementdocuments", + 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="procurementdocuments", + name="url", + field=models.URLField(blank=True, null=True, verbose_name="URL"), + ), + ] diff --git a/NEMO/apps/contracts/models.py b/NEMO/apps/contracts/models.py index 13a2f9ed..441b404f 100644 --- a/NEMO/apps/contracts/models.py +++ b/NEMO/apps/contracts/models.py @@ -7,20 +7,20 @@ from NEMO.apps.contracts.customization import ContractsCustomization from NEMO.models import BaseDocumentModel, BaseModel, User -from NEMO.views.constants import CHAR_FIELD_MAXIMUM_LENGTH, MEDIA_PROTECTED +from NEMO.views.constants import CHAR_FIELD_MEDIUM_LENGTH, MEDIA_PROTECTED class Procurement(BaseModel): - name = models.CharField(max_length=CHAR_FIELD_MAXIMUM_LENGTH, help_text=_("The name of the contract")) + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text=_("The name of the contract")) submitted_date = models.DateField(null=True, blank=True, help_text=_("The date this contract was submitted")) award_date = models.DateField(null=True, blank=True, help_text=_("The date this contract was awarded")) contract_number = models.CharField( - null=True, blank=True, max_length=CHAR_FIELD_MAXIMUM_LENGTH, help_text=_("The contract number") + null=True, blank=True, max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text=_("The contract number") ) requisition_number = models.CharField( null=True, blank=True, - max_length=CHAR_FIELD_MAXIMUM_LENGTH, + max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text=_("The requisition number for this contract"), ) cost = models.DecimalField( @@ -76,7 +76,7 @@ def clean(self): class ContractorAgreement(BaseModel): - name = models.CharField(max_length=CHAR_FIELD_MAXIMUM_LENGTH, help_text=_("The name of the contractor")) + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text=_("The name of the contractor")) contract = models.ForeignKey( Procurement, null=True, 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 00000000..80716674 --- /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/models.py b/NEMO/apps/sensors/models.py index aacd8f84..1d4dd2e7 100644 --- a/NEMO/apps/sensors/models.py +++ b/NEMO/apps/sensors/models.py @@ -19,13 +19,16 @@ from NEMO.models import BaseModel, InterlockCard from NEMO.typing import QuerySetType from NEMO.utilities import EmailCategory, format_datetime, get_email_from_settings, send_mail +from NEMO.views.constants import CHAR_FIELD_MEDIUM_LENGTH, CHAR_FIELD_SMALL_LENGTH models_logger = getLogger(__name__) 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,12 +39,12 @@ 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) enabled = models.BooleanField(blank=False, null=False, default=True) class Meta: @@ -53,7 +56,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 +98,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)]) 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 00000000..39392083 --- /dev/null +++ b/NEMO/migrations/0092_alter_accounttype_name_alter_alert_category_and_more.py @@ -0,0 +1,371 @@ +# 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.URLField( + 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", + ), + ), + 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/models.py b/NEMO/models.py index c1be4920..40209daa 100644 --- a/NEMO/models.py +++ b/NEMO/models.py @@ -56,7 +56,13 @@ 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.views.constants import ( + ADDITIONAL_INFORMATION_MAXIMUM_LENGTH, + CHAR_FIELD_LARGE_LENGTH, + CHAR_FIELD_MEDIUM_LENGTH, + CHAR_FIELD_SMALL_LENGTH, + MEDIA_PROTECTED, +) from NEMO.widgets.configuration_editor import ConfigurationEditor models_logger = getLogger(__name__) @@ -132,7 +138,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." ) @@ -147,15 +153,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, @@ -323,7 +332,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", @@ -390,7 +399,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( @@ -442,7 +451,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): @@ -456,7 +465,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( @@ -626,7 +635,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( @@ -727,13 +736,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) @@ -1121,7 +1132,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", @@ -1134,7 +1145,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, @@ -1152,7 +1165,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( @@ -1192,8 +1205,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, @@ -1232,7 +1247,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.", @@ -2010,7 +2025,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): @@ -2029,7 +2044,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( @@ -2038,7 +2053,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( @@ -2067,7 +2082,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." @@ -2129,7 +2147,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, @@ -2146,7 +2164,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( @@ -2161,7 +2179,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): @@ -2255,7 +2276,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( @@ -2270,7 +2291,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( @@ -2552,7 +2573,7 @@ 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() @@ -2569,7 +2590,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( @@ -2594,8 +2615,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, @@ -2710,7 +2731,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) @@ -2841,7 +2862,7 @@ def __str__(self): class ReservationQuestions(BaseModel): - name = models.CharField(max_length=100, help_text="The name of this ") + 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" ) @@ -2907,7 +2928,7 @@ def __str__(self): 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( @@ -2974,7 +2995,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"] @@ -3071,7 +3092,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, @@ -3228,15 +3251,15 @@ 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) enabled = models.BooleanField(blank=False, null=False, default=True) class Meta: @@ -3262,7 +3285,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") @@ -3304,8 +3327,10 @@ def __str__(self): 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" @@ -3467,7 +3492,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: @@ -3479,7 +3504,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" ) @@ -3511,7 +3536,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) @@ -3548,7 +3573,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) @@ -3559,7 +3584,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( @@ -3703,7 +3728,7 @@ 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, @@ -3732,7 +3757,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, @@ -3765,7 +3790,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." ) @@ -3797,7 +3822,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"] @@ -3808,8 +3833,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) @@ -3852,8 +3879,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", @@ -3861,7 +3888,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, @@ -3908,7 +3935,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") @@ -3918,9 +3945,10 @@ 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") - url = models.CharField( - max_length=200, + name = models.CharField( + max_length=CHAR_FIELD_SMALL_LENGTH, help_text="The textual name that will be displayed underneath the image" + ) + url = models.URLField( 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.", ) @@ -3949,7 +3977,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, @@ -3964,7 +3992,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: @@ -3975,7 +4003,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"] @@ -3989,14 +4017,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) @@ -4005,7 +4035,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.", ) @@ -4072,7 +4102,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" ) @@ -4098,7 +4128,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.)", @@ -4131,7 +4161,7 @@ class CounterDirection(object): (DECREMENT, _("Decrement")), ) - name = models.CharField(max_length=200, help_text=_("The name of this counter")) + name = models.CharField(max_length=CHAR_FIELD_MEDIUM_LENGTH, help_text=_("The name of this counter")) description = models.TextField( null=True, blank=True, @@ -4142,7 +4172,7 @@ class CounterDirection(object): 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, + 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( @@ -4496,9 +4526,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): @@ -4606,10 +4636,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: @@ -4682,7 +4712,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, @@ -4721,7 +4751,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, @@ -4756,9 +4786,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, @@ -4776,7 +4806,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) diff --git a/NEMO/serializers.py b/NEMO/serializers.py index e8fd0a6b..91c3d0bc 100644 --- a/NEMO/serializers.py +++ b/NEMO/serializers.py @@ -54,7 +54,7 @@ UsageEvent, User, ) -from NEMO.views.constants import CHAR_FIELD_MAXIMUM_LENGTH +from NEMO.views.constants import CHAR_FIELD_LARGE_LENGTH, CHAR_FIELD_MEDIUM_LENGTH class MultiEmailSerializerField(serializers.CharField): @@ -525,16 +525,16 @@ 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) @@ -557,37 +557,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/views/constants.py b/NEMO/views/constants.py index 5621ca9a..bf3fa675 100644 --- a/NEMO/views/constants.py +++ b/NEMO/views/constants.py @@ -4,8 +4,12 @@ # 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 +# 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" From 9b44eea1f868a719ac8b31e1511bebc7da599c1c Mon Sep 17 00:00:00 2001 From: mrampant Date: Fri, 4 Oct 2024 00:09:17 -0400 Subject: [PATCH 047/108] - separated adjustment request customization into its own section --- NEMO/mixins.py | 48 +-- .../customizations_adjustment_requests.html | 318 ++++++++++++++++++ .../customizations_requests.html | 305 +---------------- .../test_requests/test_adjustment_requests.py | 6 +- NEMO/tests/test_urls.py | 8 +- NEMO/views/adjustment_requests.py | 46 +-- NEMO/views/customization.py | 24 +- NEMO/views/usage.py | 4 +- NEMO/views/user_requests.py | 4 +- 9 files changed, 402 insertions(+), 361 deletions(-) create mode 100644 NEMO/templates/customizations/customizations_adjustment_requests.html diff --git a/NEMO/mixins.py b/NEMO/mixins.py index 06ab9fc8..5fc5178a 100644 --- a/NEMO/mixins.py +++ b/NEMO/mixins.py @@ -126,25 +126,25 @@ def get_end(self) -> Optional[datetime.datetime]: def can_be_adjusted(self, user: User): # determine if the given user can make an adjustment request for this charge - from NEMO.views.customization import UserRequestsCustomization + 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: @@ -155,15 +155,23 @@ def can_be_adjusted(self, user: User): and self.get_customer() == self.get_operator() ) 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 = ( @@ -181,29 +189,31 @@ 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 UserRequestsCustomization + from NEMO.views.customization import AdjustmentRequestsCustomization from NEMO.models import AreaAccessRecord, ConsumableWithdraw, Reservation, UsageEvent return ( isinstance(self, AreaAccessRecord) - and UserRequestsCustomization.get_bool("adjustment_requests_waive_area_access_enabled") + and AdjustmentRequestsCustomization.get_bool("adjustment_requests_waive_area_access_enabled") or isinstance(self, UsageEvent) - and UserRequestsCustomization.get_bool("adjustment_requests_waive_tool_usage_enabled") + and AdjustmentRequestsCustomization.get_bool("adjustment_requests_waive_tool_usage_enabled") or isinstance(self, ConsumableWithdraw) - and UserRequestsCustomization.get_bool("adjustment_requests_waive_consumable_withdrawal_enabled") + and AdjustmentRequestsCustomization.get_bool("adjustment_requests_waive_consumable_withdrawal_enabled") or isinstance(self, Reservation) - and UserRequestsCustomization.get_bool("adjustment_requests_waive_missed_reservation_enabled") + and AdjustmentRequestsCustomization.get_bool("adjustment_requests_waive_missed_reservation_enabled") ) def can_times_be_changed(item): - from NEMO.views.customization import UserRequestsCustomization + from NEMO.views.customization import AdjustmentRequestsCustomization from NEMO.models import ConsumableWithdraw, Reservation - can_change_reservation_times = UserRequestsCustomization.get_bool( + can_change_reservation_times = AdjustmentRequestsCustomization.get_bool( "adjustment_requests_missed_reservation_times" ) return ( diff --git a/NEMO/templates/customizations/customizations_adjustment_requests.html b/NEMO/templates/customizations/customizations_adjustment_requests.html new file mode 100644 index 00000000..84c0ee35 --- /dev/null +++ b/NEMO/templates/customizations/customizations_adjustment_requests.html @@ -0,0 +1,318 @@ +{% load custom_tags_and_filters %} +
    +
    + {% csrf_token %} +

    Adjustment requests settings

    +
    + +
    +
    + +
    +
    +
    +
    + At least one user with the facility manager role needs to exist for approval. +
    +
    +
    + +
    + +
    +
    + The title for the adjustment request tab of the user requests page. +
    +
    +
    + +
    + +
    +
    + The description/instructions to display on the adjustment requests page. +
    +
    +
    + +
    +
    + + requests +
    +
    +
    + The maximum number of requests to display in each section (approved, denied). Leave blank for all. +
    +
    +
    + +
    +
    + + +
    +
    +
    +
    +
    + +
    +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    + +
    +
    + + most recent charges +
    +
    +
    + The number of most recent charges to show in the dropdown menu when creating an adjustment. +
    +
    +
    + +
    +
    + last + + + + +
    +
    +
    + {% if errors.adjustment_requests_time_limit_interval %} + {{ errors.adjustment_requests_time_limit_interval.error }} + {% elif errors.adjustment_requests_time_limit_frequency %} + {{ errors.adjustment_requests_time_limit_frequency.error }} + {% else %} + The timeframe for users to be able to request adjustment on charges. + {% endif %} +
    +
    +
    + +
    +
    + +
    + +
    +
    +
    +
    This does not apply to general charges.
    +
    +
    +
    {% button type="save" value="Save settings" %}
    +
    + +
    diff --git a/NEMO/templates/customizations/customizations_requests.html b/NEMO/templates/customizations/customizations_requests.html index 6965b736..a1c6edbb 100644 --- a/NEMO/templates/customizations/customizations_requests.html +++ b/NEMO/templates/customizations/customizations_requests.html @@ -2,309 +2,6 @@
    {% csrf_token %} -

    Adjustment requests settings

    -
    - -
    -
    - -
    -
    -
    -
    - At least one user with the facility manager role needs to exist for approval. -
    -
    -
    - -
    - -
    -
    - The title for the adjustment request tab of the user requests page. -
    -
    -
    - -
    - -
    -
    - The description/instructions to display on the adjustment requests page. -
    -
    -
    - -
    -
    - - requests -
    -
    -
    - The maximum number of requests to display in each section (approved, denied). Leave blank for all. -
    -
    -
    - -
    -
    - - -
    -
    -
    -
    -
    - -
    -
    -
    - - -
    -
    -
    - - -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - - -
    -
    - -
    -
    -
    -
    -
    -
    - -
    -
    -
    - - -
    -
    -
    - - -
    -
    -
    - - -
    -
    - - -
    -
    -
    -
    -
    - -
    -
    - - most recent charges -
    -
    -
    - The number of most recent charges to show in the dropdown menu when creating an adjustment. -
    -
    -
    - -
    -
    - last - - - - -
    -
    -
    - {% if errors.adjustment_requests_time_limit_interval %} - {{ errors.adjustment_requests_time_limit_interval.error }} - {% elif errors.adjustment_requests_time_limit_frequency %} - {{ errors.adjustment_requests_time_limit_frequency.error }} - {% else %} - The timeframe for users to be able to request adjustment on charges. - {% endif %} -
    -
    -
    - -
    -
    - -
    - -
    -
    -
    -
    This does not apply to general charges.
    -
    -

    Access requests settings

    @@ -406,7 +103,7 @@

    Access requests settings

    -
    +
    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) diff --git a/NEMO/tests/test_urls.py b/NEMO/tests/test_urls.py index 5205c1e1..8b1d59eb 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__) @@ -174,7 +178,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("/") diff --git a/NEMO/views/adjustment_requests.py b/NEMO/views/adjustment_requests.py index 21569900..a547c9ea 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 @@ -163,7 +163,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 @@ -194,7 +194,7 @@ def create_adjustment_request(request, request_id=None, item_type_id=None, item_ @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) @@ -222,7 +222,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) @@ -242,7 +242,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) @@ -263,9 +263,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) @@ -364,34 +364,38 @@ def email_interested_parties(reply: RequestMessage, reply_url): 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"): items.extend( UsageEvent.objects.filter(user=user, operator=user, end__isnull=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( @@ -405,7 +409,7 @@ 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"): + if user.is_staff and AdjustmentRequestsCustomization.get_bool("adjustment_requests_staff_staff_charges_enabled"): # Add all remote charges for staff to request for adjustment items.extend( UsageEvent.objects.filter(remote_work=True, operator=user, end__isnull=False) diff --git a/NEMO/views/customization.py b/NEMO/views/customization.py index 5e570dd3..daa2e3da 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", @@ -390,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 @@ -434,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: diff --git a/NEMO/views/usage.py b/NEMO/views/usage.py index 587e3fb4..5f5f20db 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 892a7588..62249b10 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, From b43cdc41ffafb49fef4bcea1d1f84162e0686431 Mon Sep 17 00:00:00 2001 From: mrampant Date: Fri, 4 Oct 2024 00:13:39 -0400 Subject: [PATCH 048/108] - missed renaming adjustment customizations in a few templates --- .../requests/adjustment_requests/adjustment_request.html | 4 ++-- .../adjustment_requests/adjustment_requests_table.html | 4 ++-- NEMO/templates/usage/adjustment_request_button.html | 2 +- NEMO/templates/usage/usage.html | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/NEMO/templates/requests/adjustment_requests/adjustment_request.html b/NEMO/templates/requests/adjustment_requests/adjustment_request.html index db8bf79e..8cbb41d6 100644 --- a/NEMO/templates/requests/adjustment_requests/adjustment_request.html +++ b/NEMO/templates/requests/adjustment_requests/adjustment_request.html @@ -68,7 +68,7 @@

    - Only the {{ "requests"|customization:"adjustment_requests_charges_display_number" }} most recent charges are displayed. To find earlier charges, go to your Usage page and create an adjustment request from there. + Only the {{ "adjustment_requests"|customization:"adjustment_requests_charges_display_number" }} most recent charges are displayed. To find earlier charges, go to your Usage page and create an adjustment request from there.
    @@ -164,7 +164,7 @@

    {% if form.instance.get_status_display == "Pending" %} {% if instance_id and user in form.instance.reviewers %} - {% if form.instance.adjustable_charge and "requests"|customization:"adjustment_requests_apply_button" == "enabled" %} + {% if form.instance.adjustable_charge and "adjustment_requests"|customization:"adjustment_requests_apply_button" == "enabled" %} {% button type="save" submit=False name="approve_apply_request" title="Approve and adjust the original charge" icon="glyphicon-ok-circle" value="Approve and adjust" onclick="confirm_review_dialog(this, 'approve and adjust the actual charge of');" %} {% button type="save" submit=False name="approve_request" title="Approve without adjusting" icon="glyphicon-ok-circle" value="Approve only" onclick="confirm_review_dialog(this);" %} {% else %} diff --git a/NEMO/templates/requests/adjustment_requests/adjustment_requests_table.html b/NEMO/templates/requests/adjustment_requests/adjustment_requests_table.html index 09750416..e07cf60b 100644 --- a/NEMO/templates/requests/adjustment_requests/adjustment_requests_table.html +++ b/NEMO/templates/requests/adjustment_requests/adjustment_requests_table.html @@ -40,7 +40,7 @@ {% if request_status == 'approved' or request_status == 'denied' %} {{ a_request.reviewer.get_name }} {% if not a_request.applied and request_status == 'approved' and a_request.adjustable_charge %} - {% if "requests"|customization:"adjustment_requests_edit_charge_button" == "enabled" %} + {% if "adjustment_requests"|customization:"adjustment_requests_edit_charge_button" == "enabled" %} {% url "user_requests" "adjustment" as redirect_adjustment_url %} {% admin_edit_url a_request.item redirect_adjustment_url as edit_url %} {% if edit_url %} @@ -49,7 +49,7 @@ {% endif %} {% endif %} {% if user.is_user_office or user in a_request.reviewers %} - {% if user in a_request.reviewers and "requests"|customization:"adjustment_requests_apply_button" == "enabled" %} + {% if user in a_request.reviewers and "adjustment_requests"|customization:"adjustment_requests_apply_button" == "enabled" %}
    {% button id="apply_"|concat:a_request.id icon="glyphicon-edit" type="save" size="small" value="Adjust charge" onclick="apply_adjustment("|concat:a_request.id|concat:")" style="margin-bottom: 5px" %} {% endif %} diff --git a/NEMO/templates/usage/adjustment_request_button.html b/NEMO/templates/usage/adjustment_request_button.html index 93490af0..8f105522 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 c34fe622..66b36a54 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 %} From 85f591fd104719ea50337c993db72d5979643d9c Mon Sep 17 00:00:00 2001 From: mrampant Date: Fri, 4 Oct 2024 13:58:16 -0400 Subject: [PATCH 049/108] - removed restriction on changes for adjustment requests, meaning adjustment requests can be linked to charges without changing dates or quantity - now letting users request adjustment for charges on their behalf if it's not a remote. staff can also request an adjustment for that same charge --- NEMO/forms.py | 25 +++++++++++++++++++ NEMO/mixins.py | 7 +++++- NEMO/models.py | 11 -------- .../adjustment_requests_table.html | 4 +-- NEMO/views/adjustment_requests.py | 22 ++++++++++------ 5 files changed, 47 insertions(+), 22 deletions(-) diff --git a/NEMO/forms.py b/NEMO/forms.py index 8082b21e..b2d26067 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/mixins.py b/NEMO/mixins.py index 5fc5178a..6b5a7d03 100644 --- a/NEMO/mixins.py +++ b/NEMO/mixins.py @@ -152,7 +152,12 @@ 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 = AdjustmentRequestsCustomization.get_bool("adjustment_requests_staff_staff_charges_enabled") diff --git a/NEMO/models.py b/NEMO/models.py index 40209daa..9064ab15 100644 --- a/NEMO/models.py +++ b/NEMO/models.py @@ -4382,7 +4382,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: @@ -4498,16 +4497,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 ( - not self.waive - and 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 (not self.waive and 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"] diff --git a/NEMO/templates/requests/adjustment_requests/adjustment_requests_table.html b/NEMO/templates/requests/adjustment_requests/adjustment_requests_table.html index e07cf60b..8897a6ab 100644 --- a/NEMO/templates/requests/adjustment_requests/adjustment_requests_table.html +++ b/NEMO/templates/requests/adjustment_requests/adjustment_requests_table.html @@ -39,7 +39,7 @@ {% if request_status == 'approved' or request_status == 'denied' %} {{ a_request.reviewer.get_name }} - {% if not a_request.applied and request_status == 'approved' and a_request.adjustable_charge %} + {% if not a_request.applied and request_status == 'approved' %} {% if "adjustment_requests"|customization:"adjustment_requests_edit_charge_button" == "enabled" %} {% url "user_requests" "adjustment" as redirect_adjustment_url %} {% admin_edit_url a_request.item redirect_adjustment_url as edit_url %} @@ -49,7 +49,7 @@ {% endif %} {% endif %} {% if user.is_user_office or user in a_request.reviewers %} - {% if user in a_request.reviewers and "adjustment_requests"|customization:"adjustment_requests_apply_button" == "enabled" %} + {% if user in a_request.reviewers and "adjustment_requests"|customization:"adjustment_requests_apply_button" == "enabled" and a_request.adjustable_charge %}
    {% button id="apply_"|concat:a_request.id icon="glyphicon-edit" type="save" size="small" value="Adjust charge" onclick="apply_adjustment("|concat:a_request.id|concat:")" style="margin-bottom: 5px" %} {% endif %} diff --git a/NEMO/views/adjustment_requests.py b/NEMO/views/adjustment_requests.py index a547c9ea..5f9f1dbe 100644 --- a/NEMO/views/adjustment_requests.py +++ b/NEMO/views/adjustment_requests.py @@ -107,19 +107,20 @@ def create_adjustment_request(request, request_id=None, item_type_id=None, item_ except ContentType.DoesNotExist: pass + 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(): - adjustment_request.new_start = adjustment_request.item.start - adjustment_request.new_end = adjustment_request.item.end + 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(): - adjustment_request.new_quantity = adjustment_request.item.quantity + initial_data["new_quantity"] = adjustment_request.item.quantity - edit = bool(adjustment_request.id) form = AdjustmentRequestForm( request.POST or None, instance=adjustment_request, - initial={"creator": adjustment_request.creator if edit else user}, + initial=initial_data, ) item_type = form.data.get("item_type") if form.is_bound else None @@ -373,8 +374,10 @@ def adjustment_eligible_items(user: User, current_item=None) -> List[BillableIte Reservation.objects.filter(user=user, missed=True).filter(**end_filter).order_by("-end")[:item_number] ) 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] ) @@ -410,9 +413,10 @@ def adjustment_eligible_items(user: User, current_item=None) -> List[BillableIte consumable_withdrawals = consumable_withdrawals.filter(type_filter) items.extend(consumable_withdrawals[:item_number]) if user.is_staff and AdjustmentRequestsCustomization.get_bool("adjustment_requests_staff_staff_charges_enabled"): - # Add all remote charges for staff to request for adjustment + # 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] ) @@ -453,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")) @@ -469,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, From d4a8c04a4d8f04b92558ef17a3eccb87611e8b0a Mon Sep 17 00:00:00 2001 From: mrampant Date: Sat, 5 Oct 2024 09:07:39 -0400 Subject: [PATCH 050/108] -moved constants.py from views package to main package --- NEMO/apps/contracts/models.py | 2 +- NEMO/apps/sensors/models.py | 2 +- NEMO/constants.py | 18 ++++++++++++++++++ NEMO/mixins.py | 2 +- NEMO/models.py | 14 +++++++------- NEMO/serializers.py | 2 +- NEMO/templatetags/custom_tags_and_filters.py | 2 +- NEMO/urls.py | 2 +- NEMO/views/calendar.py | 2 +- NEMO/views/constants.py | 19 +------------------ NEMO/views/feedback.py | 2 +- 11 files changed, 34 insertions(+), 33 deletions(-) create mode 100644 NEMO/constants.py diff --git a/NEMO/apps/contracts/models.py b/NEMO/apps/contracts/models.py index 441b404f..7c31958c 100644 --- a/NEMO/apps/contracts/models.py +++ b/NEMO/apps/contracts/models.py @@ -6,8 +6,8 @@ from django.utils.translation import gettext_lazy as _ from NEMO.apps.contracts.customization import ContractsCustomization +from NEMO.constants import CHAR_FIELD_MEDIUM_LENGTH, MEDIA_PROTECTED from NEMO.models import BaseDocumentModel, BaseModel, User -from NEMO.views.constants import CHAR_FIELD_MEDIUM_LENGTH, MEDIA_PROTECTED class Procurement(BaseModel): diff --git a/NEMO/apps/sensors/models.py b/NEMO/apps/sensors/models.py index 1d4dd2e7..bf692f49 100644 --- a/NEMO/apps/sensors/models.py +++ b/NEMO/apps/sensors/models.py @@ -13,13 +13,13 @@ from django.utils.safestring import mark_safe 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 from NEMO.models import BaseModel, InterlockCard from NEMO.typing import QuerySetType from NEMO.utilities import EmailCategory, format_datetime, get_email_from_settings, send_mail -from NEMO.views.constants import CHAR_FIELD_MEDIUM_LENGTH, CHAR_FIELD_SMALL_LENGTH models_logger = getLogger(__name__) diff --git a/NEMO/constants.py b/NEMO/constants.py new file mode 100644 index 00000000..bf3fa675 --- /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/mixins.py b/NEMO/mixins.py index 6b5a7d03..ae485bc7 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 diff --git a/NEMO/models.py b/NEMO/models.py index 9064ab15..9533f9b1 100644 --- a/NEMO/models.py +++ b/NEMO/models.py @@ -32,6 +32,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 ( @@ -56,13 +63,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_LARGE_LENGTH, - CHAR_FIELD_MEDIUM_LENGTH, - CHAR_FIELD_SMALL_LENGTH, - MEDIA_PROTECTED, -) from NEMO.widgets.configuration_editor import ConfigurationEditor models_logger = getLogger(__name__) diff --git a/NEMO/serializers.py b/NEMO/serializers.py index 91c3d0bc..ba496986 100644 --- a/NEMO/serializers.py +++ b/NEMO/serializers.py @@ -18,6 +18,7 @@ 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, @@ -54,7 +55,6 @@ UsageEvent, User, ) -from NEMO.views.constants import CHAR_FIELD_LARGE_LENGTH, CHAR_FIELD_MEDIUM_LENGTH class MultiEmailSerializerField(serializers.CharField): diff --git a/NEMO/templatetags/custom_tags_and_filters.py b/NEMO/templatetags/custom_tags_and_filters.py index 0d9ee0b9..1c83afdc 100644 --- a/NEMO/templatetags/custom_tags_and_filters.py +++ b/NEMO/templatetags/custom_tags_and_filters.py @@ -15,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() diff --git a/NEMO/urls.py b/NEMO/urls.py index e2f08ab4..6c479e41 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__) diff --git a/NEMO/views/calendar.py b/NEMO/views/calendar.py index 1843633a..8c1ffbaf 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,7 +51,6 @@ render_email_template, send_mail, ) -from NEMO.views.constants import ADDITIONAL_INFORMATION_MAXIMUM_LENGTH from NEMO.views.customization import ( ApplicationCustomization, CalendarCustomization, diff --git a/NEMO/views/constants.py b/NEMO/views/constants.py index bf3fa675..57c9b5b5 100644 --- a/NEMO/views/constants.py +++ b/NEMO/views/constants.py @@ -1,18 +1 @@ -# 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" +# Kept for backward compatibility. remove in future version of NEMO diff --git a/NEMO/views/feedback.py b/NEMO/views/feedback.py index a4edb4ac..d1f4403f 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 From e38f9e72fa3c05ba1b50eaea1aa5fdbd4d63df51 Mon Sep 17 00:00:00 2001 From: mrampant Date: Sat, 5 Oct 2024 09:07:57 -0400 Subject: [PATCH 051/108] -moved constants.py from views package to main package --- NEMO/views/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/NEMO/views/constants.py b/NEMO/views/constants.py index 57c9b5b5..e5a08b29 100644 --- a/NEMO/views/constants.py +++ b/NEMO/views/constants.py @@ -1 +1,2 @@ # Kept for backward compatibility. remove in future version of NEMO +from NEMO.constants import * From ab94c901a4036ffc13d481a7cf2125aa40aca366 Mon Sep 17 00:00:00 2001 From: mrampant Date: Sat, 5 Oct 2024 10:37:55 -0400 Subject: [PATCH 052/108] - updated api to include waive info for charges --- NEMO/serializers.py | 12 ++++++++++++ NEMO/views/api.py | 22 ++++++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/NEMO/serializers.py b/NEMO/serializers.py index ba496986..7aca1a5a 100644 --- a/NEMO/serializers.py +++ b/NEMO/serializers.py @@ -278,6 +278,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", } @@ -290,6 +292,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", } @@ -302,6 +306,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", } @@ -347,6 +353,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", } @@ -358,6 +366,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", } @@ -396,6 +406,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", } diff --git a/NEMO/views/api.py b/NEMO/views/api.py index f170a679..3ace7eef 100644 --- a/NEMO/views/api.py +++ b/NEMO/views/api.py @@ -383,11 +383,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, } @@ -409,6 +412,9 @@ class UsageEventViewSet(ModelViewSet): "tool": key_filters, "validated": boolean_filters, "validated_by": key_filters, + "waived": boolean_filters, + "waived_on": datetime_filters, + "waived_by": key_filters, } @@ -430,6 +436,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 +501,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, } @@ -518,6 +530,9 @@ class TrainingSessionViewSet(ModelViewSet): "qualified": boolean_filters, "validated": boolean_filters, "validated_by": key_filters, + "waived": boolean_filters, + "waived_on": datetime_filters, + "waived_by": key_filters, } @@ -562,6 +577,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, } From e4929c910f8f86fb7c6dcc740c6352685d1fb88a Mon Sep 17 00:00:00 2001 From: mrampant Date: Sat, 5 Oct 2024 11:09:28 -0400 Subject: [PATCH 053/108] - updated billing api to return username for validated_by and waived_by --- NEMO/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NEMO/serializers.py b/NEMO/serializers.py index 7aca1a5a..f4da9237 100644 --- a/NEMO/serializers.py +++ b/NEMO/serializers.py @@ -552,9 +552,9 @@ class BillableItemSerializer(serializers.Serializer): end = DateTimeField(read_only=True) quantity = DecimalField(read_only=True, decimal_places=2, max_digits=8) validated = BooleanField(read_only=True) - validated_by = IntegerField(read_only=True, source="validated_by.id") + validated_by = IntegerField(read_only=True, source="validated_by.username") waived = BooleanField(read_only=True) - waived_by = IntegerField(read_only=True, source="waived_by.id") + waived_by = IntegerField(read_only=True, source="waived_by.username") waived_on = DateTimeField(read_only=True) def update(self, instance, validated_data): From 195af922a6830508888179b12cbd177b9f3b9b30 Mon Sep 17 00:00:00 2001 From: mrampant Date: Sat, 5 Oct 2024 11:21:25 -0400 Subject: [PATCH 054/108] - making sure validated_by and waived_by are always shown in api data --- NEMO/serializers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NEMO/serializers.py b/NEMO/serializers.py index f4da9237..9385be4d 100644 --- a/NEMO/serializers.py +++ b/NEMO/serializers.py @@ -552,9 +552,9 @@ class BillableItemSerializer(serializers.Serializer): end = DateTimeField(read_only=True) quantity = DecimalField(read_only=True, decimal_places=2, max_digits=8) validated = BooleanField(read_only=True) - validated_by = IntegerField(read_only=True, source="validated_by.username") + validated_by = CharField(read_only=True, source="validated_by.username", allow_null=True) waived = BooleanField(read_only=True) - waived_by = IntegerField(read_only=True, source="waived_by.username") + 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): From a745e279f20d77d707ffba98ee395ff9660ee4ef Mon Sep 17 00:00:00 2001 From: mrampant Date: Sat, 5 Oct 2024 11:36:15 -0400 Subject: [PATCH 055/108] - updated customizations_adjustment_requests.html to remove unnecessary blank line --- .../customizations/customizations_adjustment_requests.html | 1 - 1 file changed, 1 deletion(-) diff --git a/NEMO/templates/customizations/customizations_adjustment_requests.html b/NEMO/templates/customizations/customizations_adjustment_requests.html index 84c0ee35..f0935b01 100644 --- a/NEMO/templates/customizations/customizations_adjustment_requests.html +++ b/NEMO/templates/customizations/customizations_adjustment_requests.html @@ -168,7 +168,6 @@

    Adjustment requests settings

    -
    From e5e0cd38f07bf66132866e7cafd3aa4ff1614a18 Mon Sep 17 00:00:00 2001 From: mrampant Date: Sat, 5 Oct 2024 18:27:10 -0400 Subject: [PATCH 056/108] - updated adjustment_request_notification_email to show the fact that the user requested to waive a charge --- ...adjustment_request_notification_email.html | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/resources/emails/adjustment_request_notification_email.html b/resources/emails/adjustment_request_notification_email.html index 0c60569b..20335b51 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 %}
    From 267bd9a45551c4e476f26312562af73801b0a36d Mon Sep 17 00:00:00 2001 From: mrampant Date: Mon, 7 Oct 2024 12:33:45 -0400 Subject: [PATCH 057/108] - added a way to specify initial data in dynamic forms. this could be used in the future to allow editing pre/post usage questions and reservation questions --- NEMO/tests/test_dynamic_form.py | 70 +++++++++++++++++++++++- NEMO/widgets/dynamic_form.py | 95 ++++++++++++++++++++++----------- 2 files changed, 133 insertions(+), 32 deletions(-) diff --git a/NEMO/tests/test_dynamic_form.py b/NEMO/tests/test_dynamic_form.py index be6f5b6b..fd579633 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/widgets/dynamic_form.py b/NEMO/widgets/dynamic_form.py index 4a24ec3b..d09deb00 100644 --- a/NEMO/widgets/dynamic_form.py +++ b/NEMO/widgets/dynamic_form.py @@ -7,7 +7,7 @@ from copy import copy 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 @@ -33,9 +33,10 @@ class PostUsageQuestion: 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") @@ -68,6 +69,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: @@ -133,11 +139,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 @@ -155,7 +165,7 @@ def render_element(self, virtual_inputs: bool, group_question_url: str, group_it 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 += "

    " @@ -177,7 +187,7 @@ def render_element(self, virtual_inputs: bool, group_question_url: str, group_it 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 += "
    " @@ -223,7 +233,7 @@ def render_element(self, virtual_inputs: bool, group_question_url: str, group_it result += f'' for index, choice in enumerate(self.choices): label = self.labels[index] if self.labels else choice - is_default_choice = "selected" if self.default_value and self.default_value == choice else "" + is_default_choice = "selected" if self.get_default_value() and self.get_default_value() == choice else "" result += f'' result += "" if self.help: @@ -252,7 +262,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}' @@ -287,7 +297,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): @@ -443,11 +453,11 @@ def extract_for_formula(self, request, index=None) -> Any: class PostUsageRadioReportProblemQuestion(PostUsageRadioQuestion): question_type = "Question of type radio report problem" - def __init__(self, properties: Dict, index: int = None): + 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) + super().__init__(properties, index, initial_data) def validate(self): super().validate() @@ -464,17 +474,21 @@ def validate(self): 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") @@ -484,12 +498,25 @@ def validate(self): raise Exception(f"{sub_question.question_type} cannot be used inside a group question") sub_question.validate() + 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) -> str: title = self.title_html or self.title 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'' @@ -509,7 +536,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 += 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): @@ -784,6 +781,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): + visible_questions = [q for q in questions if not isinstance(q, PostUsageFormulaQuestion)] + use_grid = max([q.form_row for q in visible_questions if q.form_row], default=0) + result = "" + for row in sort_question_for_grid(visible_questions): + if row: + extra_class = "" + if use_grid: + max_cells = len(row) + 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: + result += "
    " + return result + + def validate_consumable_for_question(question: PostUsageQuestion): if question.has_consumable: if not isinstance(question, (PostUsageNumberFieldQuestion, PostUsageFormulaQuestion)): @@ -873,6 +889,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+))?$") From 682da4493a3e7d0c601df0543f8fee3ef243d55b Mon Sep 17 00:00:00 2001 From: mrampant Date: Tue, 8 Oct 2024 16:27:56 -0400 Subject: [PATCH 060/108] - fixed issue with max-width in dynamic form and improved the preview in detailed admin --- .../dynamic_form_preview.css | 138 ++++++++++++++++++ NEMO/widgets/dynamic_form.py | 3 +- 2 files changed, 140 insertions(+), 1 deletion(-) 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 da3a5bc5..00cf2738 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,142 @@ div.dynamic_form_preview:empty .dynamic_form_preview input:invalid, .dynamic_form_preview select:invalid, .dynamic_form_preview textarea:invalid { border-color: #dc3545!important; +} + +.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/widgets/dynamic_form.py b/NEMO/widgets/dynamic_form.py index 1021b807..446b7f1c 100644 --- a/NEMO/widgets/dynamic_form.py +++ b/NEMO/widgets/dynamic_form.py @@ -272,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: From c4cd1bfd3bef0c100981dec1bc2b667e39b38498 Mon Sep 17 00:00:00 2001 From: mrampant Date: Wed, 9 Oct 2024 17:01:21 -0400 Subject: [PATCH 061/108] - using reservation title if set when sending ICS reservations emails --- NEMO/views/calendar.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/NEMO/views/calendar.py b/NEMO/views/calendar.py index 8c1ffbaf..97553c6c 100644 --- a/NEMO/views/calendar.py +++ b/NEMO/views/calendar.py @@ -1066,7 +1066,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 ) @@ -1108,7 +1108,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] @@ -1135,7 +1135,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 ) From 9b4e3bce3f46f640db577b1cdecb9697951fdd0a Mon Sep 17 00:00:00 2001 From: mrampant Date: Wed, 9 Oct 2024 17:23:45 -0400 Subject: [PATCH 062/108] - added option to show username in tool freed time notification email --- .../customizations/customizations_tool.html | 14 ++++++++++++++ NEMO/views/calendar.py | 5 ++++- NEMO/views/customization.py | 1 + 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/NEMO/templates/customizations/customizations_tool.html b/NEMO/templates/customizations/customizations_tool.html index 911e63ee..744a8120 100644 --- a/NEMO/templates/customizations/customizations_tool.html +++ b/NEMO/templates/customizations/customizations_tool.html @@ -61,6 +61,20 @@

    Tool settings

    +
    + +
    +
    + +
    +
    +

    Tool configuration

    diff --git a/NEMO/views/calendar.py b/NEMO/views/calendar.py index 97553c6c..390a6590 100644 --- a/NEMO/views/calendar.py +++ b/NEMO/views/calendar.py @@ -55,6 +55,7 @@ ApplicationCustomization, CalendarCustomization, EmailsCustomization, + ToolCustomization, get_media_file_contents, ) from NEMO.widgets.dynamic_form import DynamicForm, render_group_questions @@ -1190,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/customization.py b/NEMO/views/customization.py index daa2e3da..400a1b2b 100644 --- a/NEMO/views/customization.py +++ b/NEMO/views/customization.py @@ -515,6 +515,7 @@ class ToolCustomization(CustomizationBase): "tool_reservation_policy_superusers_bypass": "", "tool_wait_list_spot_expiration": "15", "tool_wait_list_reservation_buffer": "15", + "tool_freed_time_notification_include_username": "", } def validate(self, name, value): From b2d34aa393bcae62c645e60c1aa1653153c4b071 Mon Sep 17 00:00:00 2001 From: mrampant Date: Thu, 10 Oct 2024 23:03:58 -0400 Subject: [PATCH 063/108] - added pre-post run data in facility use details in calendar --- NEMO/models.py | 6 + .../event_details/usage_details.html | 121 +++++++++++++++--- 2 files changed, 109 insertions(+), 18 deletions(-) diff --git a/NEMO/models.py b/NEMO/models.py index 9533f9b1..c140165d 100644 --- a/NEMO/models.py +++ b/NEMO/models.py @@ -2920,6 +2920,12 @@ def clean(self): def duration(self): return calculate_duration(self.start, self.end, "In progress") + 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"] diff --git a/NEMO/templates/event_details/usage_details.html b/NEMO/templates/event_details/usage_details.html index 259a5939..2e98b189 100644 --- a/NEMO/templates/event_details/usage_details.html +++ b/NEMO/templates/event_details/usage_details.html @@ -1,24 +1,109 @@ {% extends popup_view|yesno:"base/popup.html,base.html" %} +{% load custom_tags_and_filters %} {% block title %}{{ event.tool }} usage details{% endblock %} {% block content %} {% if not popup_view %}

    {{ event.tool }} usage details

    {% endif %} - User: {{ event.user }} -
    - Operator: {{ event.operator }} -
    - Project: {{ event.project.name }} -
    - Tool: {{ event.tool }} -
    - Start: {{ event.start }} -
    - {% if event.end %} - End: {{ event.end }} -
    - {% else %} - This tool is currently in use. -
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% if event.pre_run_data_json.items %} + + + + + {% endif %} + {% if event.post_run_data_json.items %} + + + + + {% endif %} +
    User:{{ event.user }}
    Operator:{{ event.operator }}
    Project:{{ event.project.name }}
    Tool:{{ event.tool }}
    Start:{{ event.start }}
    End: + {% if event.end %} + {{ event.end }} + {% else %} + This tool is currently in use. + {% endif %} +
    Identifier:{{ event.id }}
    Pre run data: + + {% for question_name, data in event.pre_run_data_json.items %} + {% if data.type == 'group' %} + + + + + {% else %} + + + + + {% endif %} + {% endfor %} +
    + {{ data.title }} + {% if data.title|slice:"-1:" != ":" %}:{% endif %} + + + {% res_question_tbody data.user_input %} +
    +
    + {{ data.title }} + {% if data.title|slice:"-1:" != ":" %}:{% endif %} + {{ data.user_input|linebreaksbr }}
    +
    Post run data: + + {% for question_name, data in event.post_run_data_json.items %} + {% if data.type == 'group' %} + + + + + {% else %} + + + + + {% endif %} + {% endfor %} +
    + {{ data.title }} + {% if data.title|slice:"-1:" != ":" %}:{% endif %} + + + {% res_question_tbody data.user_input %} +
    +
    + {{ data.title }} + {% if data.title|slice:"-1:" != ":" %}:{% endif %} + {{ data.user_input|linebreaksbr }}
    +
    + {% if popup_view and event.pre_run_data_json.items or popup_view and event.post_run_data_json.items %} + {% endif %} - Identifier: {{ event.id }} -
    {% endblock %} From fdbfdfe6d12e7c33aeae1fd57b4afbd0378b38de Mon Sep 17 00:00:00 2001 From: mrampant Date: Thu, 10 Oct 2024 23:20:41 -0400 Subject: [PATCH 064/108] - added pre-post run data in facility use details in calendar --- .../event_details/usage_details.html | 136 +++++++++--------- 1 file changed, 71 insertions(+), 65 deletions(-) diff --git a/NEMO/templates/event_details/usage_details.html b/NEMO/templates/event_details/usage_details.html index 2e98b189..86f1c7c8 100644 --- a/NEMO/templates/event_details/usage_details.html +++ b/NEMO/templates/event_details/usage_details.html @@ -38,72 +38,78 @@ Identifier: {{ event.id }} - {% if event.pre_run_data_json.items %} - - Pre run data: - - - {% for question_name, data in event.pre_run_data_json.items %} - {% if data.type == 'group' %} - - - - - {% else %} - - - - - {% endif %} - {% endfor %} -
    - {{ data.title }} - {% if data.title|slice:"-1:" != ":" %}:{% endif %} - - - {% res_question_tbody data.user_input %} -
    -
    - {{ data.title }} - {% if data.title|slice:"-1:" != ":" %}:{% endif %} - {{ data.user_input|linebreaksbr }}
    - - - {% endif %} - {% if event.post_run_data_json.items %} - - Post run data: - - - {% for question_name, data in event.post_run_data_json.items %} - {% if data.type == 'group' %} - - - - - {% else %} - - - - - {% endif %} - {% endfor %} -
    - {{ data.title }} - {% if data.title|slice:"-1:" != ":" %}:{% endif %} - - - {% res_question_tbody data.user_input %} -
    -
    - {{ data.title }} - {% if data.title|slice:"-1:" != ":" %}:{% endif %} - {{ data.user_input|linebreaksbr }}
    - - + {% if "tool"|customization:"tool_control_hide_data_history_users" != "enabled" or user.is_staff or user == event.user %} + {% if event.pre_run_data_json.items %} + + Pre run data: + + + {% for question_name, data in event.pre_run_data_json.items %} + {% if data.type == 'group' %} + + + + + {% else %} + + + + + {% endif %} + {% endfor %} +
    + {{ data.title }} + {% if data.title|slice:"-1:" != ":" %}:{% endif %} + + + {% res_question_tbody data.user_input %} +
    +
    + {{ data.title }} + {% if data.title|slice:"-1:" != ":" %}:{% endif %} + {{ data.user_input|linebreaksbr }}
    + + + {% endif %} + {% if event.post_run_data_json.items %} + + Post run data: + + + {% for question_name, data in event.post_run_data_json.items %} + {% if data.type == 'group' %} + + + + + {% else %} + + + + + {% endif %} + {% endfor %} +
    + {{ data.title }} + {% if data.title|slice:"-1:" != ":" %}:{% endif %} + + + {% res_question_tbody data.user_input %} +
    +
    + {{ data.title }} + {% if data.title|slice:"-1:" != ":" %}:{% endif %} + {{ data.user_input|linebreaksbr }}
    + + + {% endif %} {% endif %} - {% if popup_view and event.pre_run_data_json.items or popup_view and event.post_run_data_json.items %} - + {% if popup_view %} + {% if "tool"|customization:"tool_control_hide_data_history_users" != "enabled" or user.is_staff or user == event.user %} + {% if event.pre_run_data_json.items or event.post_run_data_json.items %} + + {% endif %} + {% endif %} {% endif %} {% endblock %} From 0449875230034673cd9c274dcae1f673e5ff5dad Mon Sep 17 00:00:00 2001 From: mrampant Date: Fri, 11 Oct 2024 07:44:41 -0400 Subject: [PATCH 065/108] - added operator information in usage data history --- NEMO/views/tool_control.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/NEMO/views/tool_control.py b/NEMO/views/tool_control.py index 5bbec7e7..98ad8a66 100644 --- a/NEMO/views/tool_control.py +++ b/NEMO/views/tool_control.py @@ -240,12 +240,14 @@ 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", "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", "End date")) @@ -778,6 +780,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: @@ -796,6 +799,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 @@ -805,6 +809,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 From 230e32cb330abbb60e8b3aa152ad7fae420023b5 Mon Sep 17 00:00:00 2001 From: mrampant Date: Fri, 11 Oct 2024 08:19:03 -0400 Subject: [PATCH 066/108] - added customization setting to show tool documents in a separate tab in tool control --- .../customizations/customizations_tool.html | 20 +++++++++++-- NEMO/templates/tool_control/tool_status.html | 29 +++++++++++++++++-- NEMO/views/customization.py | 1 + 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/NEMO/templates/customizations/customizations_tool.html b/NEMO/templates/customizations/customizations_tool.html index 744a8120..22dba687 100644 --- a/NEMO/templates/customizations/customizations_tool.html +++ b/NEMO/templates/customizations/customizations_tool.html @@ -215,7 +215,7 @@

    Tool control

    - +

    +
    +
    +
    +
    +
    +
    +
    diff --git a/NEMO/templates/tool_control/tool_status.html b/NEMO/templates/tool_control/tool_status.html index 6c5b9d5a..bd51dd1e 100644 --- a/NEMO/templates/tool_control/tool_status.html +++ b/NEMO/templates/tool_control/tool_status.html @@ -1,6 +1,6 @@ {% load custom_tags_and_filters %}
    - {% with hide_data_history="tool"|customization:"tool_control_hide_data_history_users" %} + {% with hide_data_history="tool"|customization:"tool_control_hide_data_history_users" documents_separate_tab="tool"|customization:"tool_control_documents_in_separate_tab" %} {% if device == 'mobile' %}

    {{ tool.name_or_child_in_use_name }}

    {% else %}

    @@ -55,6 +60,11 @@

    Post a comment

  • + {% if documents_separate_tab == "enabled" and tool.tooldocuments_set.all and user_can_see_documents %} +
  • + Documents +
  • + {% endif %} {% endif %} {% endwith %} @@ -551,7 +561,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 +578,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 +965,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 %}
    diff --git a/NEMO/apps/kiosk/templates/kiosk/choices.html b/NEMO/apps/kiosk/templates/kiosk/choices.html index 7a8ffd90..a24289d1 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 %}
    @@ -31,14 +32,16 @@

    Current {{ facility_name }} use

    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/templates/customizations/customizations_tool.html b/NEMO/templates/customizations/customizations_tool.html index 22dba687..04e0a2ed 100644 --- a/NEMO/templates/customizations/customizations_tool.html +++ b/NEMO/templates/customizations/customizations_tool.html @@ -75,6 +75,22 @@

    Tool settings

    + {% if "NEMO.apps.kiosk"|app_installed %} +
    + +
    +
    + +
    +
    +
    + {% endif %}

    Tool configuration

    diff --git a/NEMO/views/customization.py b/NEMO/views/customization.py index a60d63d7..78a1b1df 100644 --- a/NEMO/views/customization.py +++ b/NEMO/views/customization.py @@ -517,6 +517,7 @@ class ToolCustomization(CustomizationBase): "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): From 3399a3afa0243302a8a71ca0d1030dd8c3a5aec8 Mon Sep 17 00:00:00 2001 From: mrampant Date: Fri, 11 Oct 2024 17:08:33 -0400 Subject: [PATCH 068/108] - fixed extend vs update issue with task recipients --- NEMO/views/tasks.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/NEMO/views/tasks.py b/NEMO/views/tasks.py index 632e5e15..89668afd 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 @@ -140,7 +140,7 @@ def send_new_task_emails(request, task: Task, user, task_images: List[TaskImages if ToolCustomization.get_bool("tool_problem_send_to_all_qualified_users"): for qualified_user in task.tool.user_set.all(): if qualified_user.is_active: - bcc.update( + bcc.extend( [ email for email in qualified_user.get_emails( @@ -402,7 +402,7 @@ def save_task_images(request, task: Task) -> List[TaskImages]: return task_images -def get_task_email_recipients(task: Task, new=False) -> (List[str], 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() From 92d7e60c185796059e9115b71e36eac3c6ed1db7 Mon Sep 17 00:00:00 2001 From: mrampant Date: Sun, 13 Oct 2024 23:32:50 -0400 Subject: [PATCH 069/108] - fixed issue with dynamic form grid not dealing with formulas correctly --- NEMO/widgets/dynamic_form.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/NEMO/widgets/dynamic_form.py b/NEMO/widgets/dynamic_form.py index 446b7f1c..41206a4d 100644 --- a/NEMO/widgets/dynamic_form.py +++ b/NEMO/widgets/dynamic_form.py @@ -783,20 +783,20 @@ def render_group_questions(request, questions, group_question_url, group_item_id def render_grid_questions(questions, group_question_url: str, group_item_id: int, virtual_inputs: bool): - visible_questions = [q for q in questions if not isinstance(q, PostUsageFormulaQuestion)] - use_grid = max([q.form_row for q in visible_questions if q.form_row], default=0) + # 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(visible_questions): + for row in sort_question_for_grid(questions): if row: extra_class = "" - if use_grid: - max_cells = len(row) + 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: + if use_grid and max_cells: result += "
    " return result From 8e991fb482f820ddb7d306304a898943ebdfd6de Mon Sep 17 00:00:00 2001 From: mrampant Date: Sun, 13 Oct 2024 23:43:37 -0400 Subject: [PATCH 070/108] - fixed adjustment requests tests --- NEMO/tests/test_requests/test_adjustment_requests.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/NEMO/tests/test_requests/test_adjustment_requests.py b/NEMO/tests/test_requests/test_adjustment_requests.py index 3dc6eeb7..5c5ac70b 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 @@ -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() From 2301dfc931feec63ef442cad7855e4fc2f145a89 Mon Sep 17 00:00:00 2001 From: mrampant Date: Sun, 13 Oct 2024 23:44:07 -0400 Subject: [PATCH 071/108] - added new datalist field to be used in admin for CharField and ChoiceField --- NEMO/fields.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/NEMO/fields.py b/NEMO/fields.py index 8e71e93d..93078718 100644 --- a/NEMO/fields.py +++ b/NEMO/fields.py @@ -31,6 +31,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 From 95a513120c8ad2e53ef69d48241d201bfa575ac4 Mon Sep 17 00:00:00 2001 From: mrampant Date: Sun, 13 Oct 2024 23:44:28 -0400 Subject: [PATCH 072/108] - added some type hints in utilities.py --- NEMO/utilities.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/NEMO/utilities.py b/NEMO/utilities.py index 61446170..b498ff62 100644 --- a/NEMO/utilities.py +++ b/NEMO/utilities.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import csv import importlib import os @@ -9,7 +11,7 @@ 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 Dict, List, Optional, Sequence, Set, TYPE_CHECKING, Tuple, Union from urllib.parse import urljoin from PIL import Image @@ -34,6 +36,9 @@ from django.utils.html import format_html from django.utils.text import slugify +if TYPE_CHECKING: + from NEMO.models import User + utilities_logger = getLogger(__name__) # List of python to js formats @@ -765,8 +770,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: From 1abb65a2706cb52a934be1863730a40782ed0fcc Mon Sep 17 00:00:00 2001 From: mrampant Date: Sun, 13 Oct 2024 23:59:51 -0400 Subject: [PATCH 073/108] - fixed last data point for sensor which was throwing an error when no data was found instead of returning None --- NEMO/apps/sensors/models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/NEMO/apps/sensors/models.py b/NEMO/apps/sensors/models.py index bf692f49..49d7e41b 100644 --- a/NEMO/apps/sensors/models.py +++ b/NEMO/apps/sensors/models.py @@ -144,7 +144,10 @@ 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") + try: + return SensorData.objects.filter(sensor=self).latest("created_date") + except: + return None def clean(self): from NEMO.apps.sensors import sensors From 923030b33058c3e6e470150f182a8d7c34ca77dc Mon Sep 17 00:00:00 2001 From: mrampant Date: Wed, 23 Oct 2024 08:57:40 -0400 Subject: [PATCH 074/108] - added constraints to prevent duplicate tool in use and duplicate user in area --- NEMO/apps/area_access/views.py | 6 +++++- NEMO/apps/kiosk/views.py | 8 ++++++++ NEMO/models.py | 14 ++++++++++++++ NEMO/tests/test_area_access.py | 9 +++++++++ NEMO/tests/test_tool.py | 9 +++++++++ NEMO/views/area_access.py | 18 +++++++++++++++++- NEMO/views/remote_work.py | 5 +++++ NEMO/views/tool_control.py | 11 +++++++++++ 8 files changed, 78 insertions(+), 2 deletions(-) diff --git a/NEMO/apps/area_access/views.py b/NEMO/apps/area_access/views.py index 5f4941b7..e3367613 100644 --- a/NEMO/apps/area_access/views.py +++ b/NEMO/apps/area_access/views.py @@ -1,6 +1,7 @@ from time import sleep from django.contrib.auth.decorators import login_required, permission_required +from django.core.exceptions import ValidationError from django.http import JsonResponse from django.shortcuts import get_object_or_404, render from django.template.defaultfilters import linebreaksbr @@ -243,7 +244,10 @@ def login_to_area(request, door_id): delay_lock_door(door.id) - log_in_user_to_area(area, user, project) + try: + log_in_user_to_area(area, user, project) + except ValidationError as e: + return render(request, "area_access/physical_access_denied.html", {"message": str(e)}) dictionary = { "door": door, diff --git a/NEMO/apps/kiosk/views.py b/NEMO/apps/kiosk/views.py index 6f680986..0b755a5e 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 diff --git a/NEMO/models.py b/NEMO/models.py index c140165d..78c6b427 100644 --- a/NEMO/models.py +++ b/NEMO/models.py @@ -2568,6 +2568,14 @@ 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 + ) + 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) @@ -2932,6 +2940,12 @@ class Meta: 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).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=CHAR_FIELD_SMALL_LENGTH) diff --git a/NEMO/tests/test_area_access.py b/NEMO/tests/test_area_access.py index 88ad3f56..a2e3db7b 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_tool.py b/NEMO/tests/test_tool.py index 17234520..34153b42 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/views/area_access.py b/NEMO/views/area_access.py index 4995479a..a4e2fe23 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/remote_work.py b/NEMO/views/remote_work.py index ae4d7608..91286b70 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/tool_control.py b/NEMO/views/tool_control.py index 98ad8a66..7600d0dc 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 @@ -406,6 +407,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): @@ -436,6 +443,10 @@ 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 From 6b52eeae50be8582f1bf5388be490437cc9bbfa8 Mon Sep 17 00:00:00 2001 From: mrampant Date: Wed, 23 Oct 2024 12:08:45 -0400 Subject: [PATCH 075/108] - added a customization to show who has a reservation next in tool control --- .../customizations/customizations_tool.html | 15 +++++++++++++++ NEMO/templates/tool_control/tool_status.html | 10 ++++++++++ NEMO/views/customization.py | 1 + NEMO/views/tool_control.py | 12 ++++++++++++ 4 files changed, 38 insertions(+) diff --git a/NEMO/templates/customizations/customizations_tool.html b/NEMO/templates/customizations/customizations_tool.html index 04e0a2ed..0e8a9370 100644 --- a/NEMO/templates/customizations/customizations_tool.html +++ b/NEMO/templates/customizations/customizations_tool.html @@ -215,6 +215,21 @@

    Tool control

    +
    + +
    +
    + +
    +
    +
    diff --git a/NEMO/templates/tool_control/tool_status.html b/NEMO/templates/tool_control/tool_status.html index bd51dd1e..1e735b80 100644 --- a/NEMO/templates/tool_control/tool_status.html +++ b/NEMO/templates/tool_control/tool_status.html @@ -128,6 +128,16 @@

    {% 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 %}
    diff --git a/NEMO/views/customization.py b/NEMO/views/customization.py index 78a1b1df..fa6d8bd3 100644 --- a/NEMO/views/customization.py +++ b/NEMO/views/customization.py @@ -505,6 +505,7 @@ class ToolCustomization(CustomizationBase): "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": "", diff --git a/NEMO/views/tool_control.py b/NEMO/views/tool_control.py index 7600d0dc..9793f110 100644 --- a/NEMO/views/tool_control.py +++ b/NEMO/views/tool_control.py @@ -166,6 +166,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) From 854cab3f64d91c3ae5680a67bf9c3fce62971ad4 Mon Sep 17 00:00:00 2001 From: mrampant Date: Fri, 25 Oct 2024 08:46:35 -0400 Subject: [PATCH 076/108] - added new GUNICORN environment variables to customize its configuration when using the docker container. this should allow more flexibility for workers and threads - the options are: GUNICORN_WORKER_CLASS, GUNICORN_WORKER_COUNT, GUNICORN_THREAD_COUNT, GUNICORN_KEEPALIVE_SECONDS, GUNICORN_CAPTURE_OUTPUT (defaults are set to previous values) --- Dockerfile | 8 ++++++++ gunicorn_configuration.py | 14 ++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 31930def..f329e626 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,14 @@ 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 "" + COPY gunicorn_configuration.py /etc/ EXPOSE 8000/tcp diff --git a/gunicorn_configuration.py b/gunicorn_configuration.py index 0c7c6a90..5a419768 100644 --- a/gunicorn_configuration.py +++ b/gunicorn_configuration.py @@ -1,14 +1,16 @@ # 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 +from NEMO.utilities import strtobool + bind = "0.0.0.0:8000" -worker_class = "gthread" +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 = bool(strtobool(os.getenv("GUNICORN_CAPTURE_OUTPUT", "True") or "True")) From be58b41f333c2fc2a9baa8adddc57c0e268ab65a Mon Sep 17 00:00:00 2001 From: mrampant Date: Fri, 25 Oct 2024 09:36:34 -0400 Subject: [PATCH 077/108] - fixed strbool import in gunicorn_configuration --- gunicorn_configuration.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/gunicorn_configuration.py b/gunicorn_configuration.py index 5a419768..ccd66f0b 100644 --- a/gunicorn_configuration.py +++ b/gunicorn_configuration.py @@ -4,8 +4,6 @@ import os from multiprocessing import cpu_count -from NEMO.utilities import strtobool - bind = "0.0.0.0:8000" worker_class = os.getenv("GUNICORN_WORKER_CLASS", "gthread") or "gthread" # The following value was decided based on the Gunicorn documentation and configuration example: @@ -13,4 +11,4 @@ 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 = bool(strtobool(os.getenv("GUNICORN_CAPTURE_OUTPUT", "True") or "True")) +capture_output = (os.getenv("GUNICORN_CAPTURE_OUTPUT", "True") or "True") == "True" From cb953ebabbce00e55a960faa161a07d97bca1122 Mon Sep 17 00:00:00 2001 From: mrampant Date: Mon, 28 Oct 2024 11:31:10 -0400 Subject: [PATCH 078/108] - added date for email logs in detailed admin --- NEMO/admin.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/NEMO/admin.py b/NEMO/admin.py index d35e926e..0f211c14 100644 --- a/NEMO/admin.py +++ b/NEMO/admin.py @@ -1884,7 +1884,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): From b5e9ea028559bb5f81ff5559548779cb99ade490 Mon Sep 17 00:00:00 2001 From: mrampant Date: Mon, 28 Oct 2024 11:31:40 -0400 Subject: [PATCH 079/108] - fixed type hint for slugify method in utilities.py --- NEMO/utilities.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/NEMO/utilities.py b/NEMO/utilities.py index b498ff62..0ff1b87e 100644 --- a/NEMO/utilities.py +++ b/NEMO/utilities.py @@ -11,7 +11,7 @@ from enum import Enum from io import BytesIO, StringIO from logging import getLogger -from typing import Dict, List, Optional, Sequence, Set, TYPE_CHECKING, Tuple, Union +from typing import Any, Dict, List, Optional, Sequence, Set, TYPE_CHECKING, Tuple, Union from urllib.parse import urljoin from PIL import Image @@ -825,7 +825,7 @@ 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("-", "_") From ac5bfbf5f0ce04a2b98b917804bff24b4e3dd3a6 Mon Sep 17 00:00:00 2001 From: mrampant Date: Mon, 28 Oct 2024 13:35:36 -0400 Subject: [PATCH 080/108] - added PIs to project view in detailed administration --- NEMO/admin.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/NEMO/admin.py b/NEMO/admin.py index 0f211c14..c37871df 100644 --- a/NEMO/admin.py +++ b/NEMO/admin.py @@ -594,10 +594,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"] @@ -606,6 +611,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. From 3a17a5142231862d92c62e07ddaf1c48956a07f8 Mon Sep 17 00:00:00 2001 From: mrampant Date: Thu, 31 Oct 2024 13:29:28 -0400 Subject: [PATCH 081/108] - added new type of AdminEmailHandler for django allowing to limit the number of error emails received --- NEMO/log.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 NEMO/log.py diff --git a/NEMO/log.py b/NEMO/log.py new file mode 100644 index 00000000..f5c1e75d --- /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) From 9d31b03d1a1eadc042c8538990799a6661b648ef Mon Sep 17 00:00:00 2001 From: mrampant Date: Sat, 2 Nov 2024 21:46:27 -0400 Subject: [PATCH 082/108] - fixed validate_unique for usage events and area access records throwing errors when saving in detailed admin --- NEMO/models.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/NEMO/models.py b/NEMO/models.py index 78c6b427..9a93b380 100644 --- a/NEMO/models.py +++ b/NEMO/models.py @@ -2570,8 +2570,10 @@ def __str__(self): 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 + 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")) @@ -2942,7 +2944,11 @@ def __str__(self): 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).exists() + 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")) From 069a54e40eb9ac63094b08cce18891f5629b236b Mon Sep 17 00:00:00 2001 From: mrampant Date: Sat, 2 Nov 2024 22:37:30 -0400 Subject: [PATCH 083/108] - added new options to use a tool for training in tool control. only available to staff and tool superusers. - when disengaging a tool that is used for training, it will take the trainer to the training recording page --- NEMO/admin.py | 15 +- ...session_usage_event_usageevent_training.py | 26 +++ NEMO/models.py | 13 ++ .../customizations_training.html | 41 ++++ NEMO/templates/tool_control/tool_control.html | 6 +- NEMO/templates/tool_control/tool_status.html | 33 +++ NEMO/templates/training/training.html | 140 +------------ NEMO/templates/training/training_entry.html | 188 +++++++++--------- .../training/training_recording.html | 176 ++++++++++++++++ NEMO/utilities.py | 10 +- NEMO/views/api.py | 3 + NEMO/views/customization.py | 8 +- NEMO/views/tool_control.py | 7 + NEMO/views/training.py | 46 +++-- 14 files changed, 457 insertions(+), 255 deletions(-) create mode 100644 NEMO/migrations/0093_trainingsession_usage_event_usageevent_training.py create mode 100644 NEMO/templates/training/training_recording.html diff --git a/NEMO/admin.py b/NEMO/admin.py index c37871df..a3439dfb 100644 --- a/NEMO/admin.py +++ b/NEMO/admin.py @@ -476,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", @@ -757,7 +768,7 @@ 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", "waived_by"] actions = [waive_selected_charges] 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 00000000..50be7c88 --- /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/models.py b/NEMO/models.py index 9a93b380..5915b9a0 100644 --- a/NEMO/models.py +++ b/NEMO/models.py @@ -2223,6 +2223,12 @@ 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 @@ -2914,6 +2920,7 @@ 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) @@ -2927,9 +2934,15 @@ def clean(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 diff --git a/NEMO/templates/customizations/customizations_training.html b/NEMO/templates/customizations/customizations_training.html index 9c40de58..39edc53f 100644 --- a/NEMO/templates/customizations/customizations_training.html +++ b/NEMO/templates/customizations/customizations_training.html @@ -3,6 +3,39 @@

    Training

    {% csrf_token %} +
    + +
    +
    + + +
    +
    +
    @@ -67,13 +100,21 @@

    Training

    $(this).typeahead('val', '').focus(); add_to_list("#include-hidden-list", "remove_tool", search_selection.id, search_selection.name, "Remove " + search_selection.name, "training_included_hidden_tools_list"); } + function remove_tool(tool_id) { remove_from_list("#include-hidden-list", "#training_included_hidden_tools_list_" + tool_id, "No hidden tools are included."); } + + function show_hide_training_options() + { + $('#training_options').toggle($('#show_training_option').prop('checked')); + } + $('#include_hidden_tool').autocomplete('tools', add_tool, {{ tools|json_search_base }}); {% for tool in included_hidden_tools %} add_tool(undefined, {name:'{{ tool.name }}', id:{{ tool.id }}}); {% endfor %} auto_size_textarea(document.getElementById("training_request_default_message_placeholder")); + show_hide_training_options(); diff --git a/NEMO/templates/tool_control/tool_control.html b/NEMO/templates/tool_control/tool_control.html index 45f2c977..d37b8afc 100644 --- a/NEMO/templates/tool_control/tool_control.html +++ b/NEMO/templates/tool_control/tool_control.html @@ -99,8 +99,9 @@ } } - function use_tool_for_self() + function use_tool_for_self(training) { + $("#training").val(training || ""); $("#project_choice").html("Fetching project list...").show().load("{% url 'get_projects_for_self' %}", undefined, load_projects_completion_callback); } @@ -230,9 +231,10 @@ {# Only staff can perform the functions in this if-block. #} - function use_tool_for_other(remote_work) + function use_tool_for_other(remote_work, training) { $("#remote_work").val(remote_work || ""); + $("#training").val(training || ""); $("#start").hide(); $("#pre_usage_questions").hide(); $("#project_choice").html("Fetching user list...").show().load("{% url 'use_tool_for_other' %}", undefined, load_projects_completion_callback); diff --git a/NEMO/templates/tool_control/tool_status.html b/NEMO/templates/tool_control/tool_status.html index 1e735b80..73161ed7 100644 --- a/NEMO/templates/tool_control/tool_status.html +++ b/NEMO/templates/tool_control/tool_status.html @@ -432,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 %} diff --git a/NEMO/templates/training/training.html b/NEMO/templates/training/training.html index 7b783066..bdcfe012 100644 --- a/NEMO/templates/training/training.html +++ b/NEMO/templates/training/training.html @@ -8,143 +8,5 @@ {% block title %}Training{% endblock %} {% block content %}

    Training

    -

    Use this form to charge users for 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="" %} - - + {% include 'training/training_recording.html' %} {% endblock %} diff --git a/NEMO/templates/training/training_entry.html b/NEMO/templates/training/training_entry.html index 0c5b1369..2804a3dd 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 00000000..c62c8277 --- /dev/null +++ b/NEMO/templates/training/training_recording.html @@ -0,0 +1,176 @@ +{% load custom_tags_and_filters %} + +

    Use this form to charge users for 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/utilities.py b/NEMO/utilities.py index 0ff1b87e..5883a86c 100644 --- a/NEMO/utilities.py +++ b/NEMO/utilities.py @@ -27,7 +27,7 @@ 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 render, resolve_url from django.template import Template from django.template.context import make_context from django.urls import NoReverseMatch, reverse @@ -925,3 +925,11 @@ def get_local_date_times_for_item_policy_times( 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/api.py b/NEMO/views/api.py index 3ace7eef..9f9eb215 100644 --- a/NEMO/views/api.py +++ b/NEMO/views/api.py @@ -410,6 +410,7 @@ 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, @@ -524,6 +525,8 @@ 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, diff --git a/NEMO/views/customization.py b/NEMO/views/customization.py index fa6d8bd3..5af83b37 100644 --- a/NEMO/views/customization.py +++ b/NEMO/views/customization.py @@ -577,7 +577,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() diff --git a/NEMO/views/tool_control.py b/NEMO/views/tool_control.py index 9793f110..f214a69e 100644 --- a/NEMO/views/tool_control.py +++ b/NEMO/views/tool_control.py @@ -47,6 +47,7 @@ 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 @@ -392,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 @@ -463,6 +465,8 @@ def enable_tool(request, tool_id, user_id, project_id, staff_charge): # 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 @@ -543,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() diff --git a/NEMO/views/training.py b/NEMO/views/training.py index 7dd723a3..727bc6cd 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) From 53cbc0854ff70155173204bceceae99578ef106f Mon Sep 17 00:00:00 2001 From: mrampant Date: Mon, 4 Nov 2024 10:09:27 -0500 Subject: [PATCH 084/108] - fixed tests --- NEMO/tests/test_formats.py | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/NEMO/tests/test_formats.py b/NEMO/tests/test_formats.py index e02a9828..16144ea7 100644 --- a/NEMO/tests/test_formats.py +++ b/NEMO/tests/test_formats.py @@ -77,8 +77,15 @@ def test_format_daterange(self): 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().astimezone().replace(year=2024, month=9, day=17, hour=6, minute=0, second=0, microsecond=0) + 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 @@ -109,8 +116,15 @@ def test_duration_with_off_schedule_weekends(self): 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().astimezone().replace(year=2024, month=9, day=17, hour=6, minute=0, second=0, microsecond=0) + 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 @@ -182,8 +196,15 @@ def test_duration_with_off_schedule_weekdays_normal(self): 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().astimezone().replace(year=2024, month=9, day=17, hour=18, minute=0, second=0, microsecond=0) + 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 From e42bd53158372fdb785a250d654d41b1fe52fdef Mon Sep 17 00:00:00 2001 From: mrampant Date: Thu, 7 Nov 2024 21:44:51 -0500 Subject: [PATCH 085/108] - fixed landing page URL field --- ...092_alter_accounttype_name_alter_alert_category_and_more.py | 3 ++- NEMO/models.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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 index 39392083..b254a6d5 100644 --- 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 @@ -115,9 +115,10 @@ class Migration(migrations.Migration): migrations.AlterField( model_name="landingpagechoice", name="url", - field=models.URLField( + 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( diff --git a/NEMO/models.py b/NEMO/models.py index 78c6b427..c5ace30b 100644 --- a/NEMO/models.py +++ b/NEMO/models.py @@ -3968,7 +3968,8 @@ class LandingPageChoice(BaseModel): name = models.CharField( max_length=CHAR_FIELD_SMALL_LENGTH, help_text="The textual name that will be displayed underneath the image" ) - url = models.URLField( + url = models.CharField( + 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.", ) From 74a895756bca19ce1f9856395afa516e0e0f767d Mon Sep 17 00:00:00 2001 From: mrampant Date: Thu, 7 Nov 2024 21:47:38 -0500 Subject: [PATCH 086/108] - added apps.py for all internal plugins --- NEMO/apps/area_access/apps.py | 11 +++++++++++ NEMO/apps/contracts/apps.py | 1 + NEMO/apps/kiosk/apps.py | 11 +++++++++++ NEMO/apps/sensors/apps.py | 12 ++++++++++++ 4 files changed, 35 insertions(+) create mode 100644 NEMO/apps/area_access/apps.py create mode 100644 NEMO/apps/kiosk/apps.py create mode 100644 NEMO/apps/sensors/apps.py diff --git a/NEMO/apps/area_access/apps.py b/NEMO/apps/area_access/apps.py new file mode 100644 index 00000000..7e2ff905 --- /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/contracts/apps.py b/NEMO/apps/contracts/apps.py index 3e706bb9..4ea84e4d 100644 --- a/NEMO/apps/contracts/apps.py +++ b/NEMO/apps/contracts/apps.py @@ -3,6 +3,7 @@ class ContractsConfig(AppConfig): name = "NEMO.apps.contracts" + label = "contracts" def ready(self): """ diff --git a/NEMO/apps/kiosk/apps.py b/NEMO/apps/kiosk/apps.py new file mode 100644 index 00000000..faf24db2 --- /dev/null +++ b/NEMO/apps/kiosk/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + + +class KioskConfig(AppConfig): + name = "NEMO.apps.kiosk" + + def ready(self): + """ + This code will be run when Django starts. + """ + pass diff --git a/NEMO/apps/sensors/apps.py b/NEMO/apps/sensors/apps.py new file mode 100644 index 00000000..78483c59 --- /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 From 7afaa31757f12e1de3e0a7366f919950b8085be4 Mon Sep 17 00:00:00 2001 From: mrampant Date: Thu, 7 Nov 2024 23:06:15 -0500 Subject: [PATCH 087/108] - added new environment variable NEMO_EXTRA_PIP_PACKAGES to easily install plugins and other python packages --- Dockerfile | 3 +++ start_NEMO_in_Docker.sh | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/Dockerfile b/Dockerfile index f329e626..e7183674 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,9 @@ 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/start_NEMO_in_Docker.sh b/start_NEMO_in_Docker.sh index 285b5902..e644d487 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 From 700060bf8212b75db93a767ba2e70cd5f55f8bdf Mon Sep 17 00:00:00 2001 From: mrampant Date: Thu, 7 Nov 2024 23:23:36 -0500 Subject: [PATCH 088/108] - added better handling when loading plugin URLs - added "critical" flag to plugin apps to stop NEMO when there is an issue with loading a plugin (otherwise it's logged and ignored) --- NEMO/urls.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/NEMO/urls.py b/NEMO/urls.py index 6c479e41..8ded3aeb 100644 --- a/NEMO/urls.py +++ b/NEMO/urls.py @@ -123,16 +123,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 += [ From 360185dce42066fd21a6a1034d2644e67488cffa Mon Sep 17 00:00:00 2001 From: mrampant Date: Sat, 9 Nov 2024 15:34:23 -0500 Subject: [PATCH 089/108] - added index on sensor data created date to speed up "latest data point" queries on sensors --- .../0003_alter_sensordata_created_date.py | 18 ++++++++++++++++++ NEMO/apps/sensors/models.py | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 NEMO/apps/sensors/migrations/0003_alter_sensordata_created_date.py diff --git a/NEMO/apps/sensors/migrations/0003_alter_sensordata_created_date.py b/NEMO/apps/sensors/migrations/0003_alter_sensordata_created_date.py new file mode 100644 index 00000000..b461e6bc --- /dev/null +++ b/NEMO/apps/sensors/migrations/0003_alter_sensordata_created_date.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.16 on 2024-11-09 20:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("sensors", "0002_alter_sensor_data_label_alter_sensor_data_prefix_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="sensordata", + name="created_date", + field=models.DateTimeField(auto_now_add=True, db_index=True), + ), + ] diff --git a/NEMO/apps/sensors/models.py b/NEMO/apps/sensors/models.py index 49d7e41b..98e5c099 100644 --- a/NEMO/apps/sensors/models.py +++ b/NEMO/apps/sensors/models.py @@ -181,7 +181,7 @@ def __str__(self): 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): From fd04cd5a3faa50cb936efb6fcfcd80eaea7fde28 Mon Sep 17 00:00:00 2001 From: mrampant Date: Sun, 10 Nov 2024 16:36:49 -0500 Subject: [PATCH 090/108] - fixed @synchronized annotation which prevents users from enabling/disabling the same tool concurrently - added preload_app = True to gunicorn --- NEMO/decorators.py | 29 +++++++++++++++-------------- gunicorn_configuration.py | 1 + 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/NEMO/decorators.py b/NEMO/decorators.py index fe23c6dd..79dd912b 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,24 @@ 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__}{'_' if attribute_value else ''}" + ) + + 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/gunicorn_configuration.py b/gunicorn_configuration.py index ccd66f0b..46c28192 100644 --- a/gunicorn_configuration.py +++ b/gunicorn_configuration.py @@ -5,6 +5,7 @@ from multiprocessing import cpu_count bind = "0.0.0.0:8000" +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 From b8a56b6a40e08df1b2e5f040a916fdf88d2c22d1 Mon Sep 17 00:00:00 2001 From: mrampant Date: Sun, 10 Nov 2024 17:17:33 -0500 Subject: [PATCH 091/108] - fixed tests --- NEMO/tests/test_urls.py | 1 + 1 file changed, 1 insertion(+) diff --git a/NEMO/tests/test_urls.py b/NEMO/tests/test_urls.py index 8b1d59eb..a8957767 100644 --- a/NEMO/tests/test_urls.py +++ b/NEMO/tests/test_urls.py @@ -141,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 = [ From e15dafc89d65645c48ae211d4c3bf70926d776b4 Mon Sep 17 00:00:00 2001 From: mrampant Date: Sun, 10 Nov 2024 17:18:32 -0500 Subject: [PATCH 092/108] - simplified lock_name in synchronized decorator --- NEMO/decorators.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/NEMO/decorators.py b/NEMO/decorators.py index 79dd912b..76126072 100644 --- a/NEMO/decorators.py +++ b/NEMO/decorators.py @@ -60,11 +60,8 @@ def decorator(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 = ( - slugify_underscore( - f"{function.__module__.replace('.', '_')}_{function.__qualname__}{'_' if attribute_value else ''}" - ) - + attribute_value + lock_name = slugify_underscore( + f"{function.__module__.replace('.', '_')}_{function.__qualname__}_{attribute_value}" ) lock = locks.setdefault(lock_name, Lock()) with lock: From 5ec79119343a3ad6f5f97934f2e175e37c4bf1d6 Mon Sep 17 00:00:00 2001 From: mrampant Date: Mon, 11 Nov 2024 13:59:07 -0500 Subject: [PATCH 093/108] - added customization option to automatically shut down a tool when a safety hazard is reported --- NEMO/templates/customizations/customizations_tool.html | 7 +++++++ NEMO/views/customization.py | 1 + NEMO/views/tasks.py | 6 +++++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/NEMO/templates/customizations/customizations_tool.html b/NEMO/templates/customizations/customizations_tool.html index 0e8a9370..b6f551d9 100644 --- a/NEMO/templates/customizations/customizations_tool.html +++ b/NEMO/templates/customizations/customizations_tool.html @@ -136,6 +136,13 @@

    Tool problems

    +