diff --git a/NEMO/admin.py b/NEMO/admin.py index 591391a2..41f67627 100644 --- a/NEMO/admin.py +++ b/NEMO/admin.py @@ -101,6 +101,7 @@ TemporaryPhysicalAccess, TemporaryPhysicalAccessRequest, Tool, + ToolCredentials, ToolDocuments, ToolQualificationGroup, ToolUsageCounter, @@ -312,7 +313,7 @@ class ToolAdmin(admin.ModelAdmin): "_grant_physical_access_level_upon_qualification", "_grant_badge_reader_access_upon_qualification", "_interlock", - "_allow_delayed_logoff", + "_max_delayed_logoff", "_ask_to_leave_area_when_done_using", ) }, @@ -1448,7 +1449,7 @@ def alert_preview(self, obj: Closure): display_title = f'{obj.name}
' if obj.name else "" return iframe_content( f'
{display_title}{linebreaksbr(obj.closuretime_set.first().alert_contents())}
', - extra_style="padding-bottom: 20%", + extra_style="padding-bottom: 15%", ) except Exception: pass @@ -1571,6 +1572,7 @@ class LandingPageChoiceAdmin(admin.ModelAdmin): @register(Customization) class CustomizationAdmin(admin.ModelAdmin): list_display = ("name", "value") + search_fields = ["name"] @register(ScheduledOutageCategory) @@ -1836,6 +1838,29 @@ class OnboardingPhaseAdmin(admin.ModelAdmin): list_display = ("name", "display_order") +class ToolCredentialsAdminForm(forms.ModelForm): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["authorized_staff"].queryset = User.objects.filter(is_staff=True) + + +@register(ToolCredentials) +class ToolCredentialsAdmin(ModelAdminRedirectMixin, admin.ModelAdmin): + list_display = ["get_tool_category", "tool", "is_tool_visible", "username", "comments"] + list_filter = [("tool", admin.RelatedOnlyFieldListFilter), "tool__visible"] + autocomplete_fields = ["tool"] + filter_horizontal = ["authorized_staff"] + form = ToolCredentialsAdminForm + + @display(ordering="tool___category", description="Tool category") + def get_tool_category(self, obj: ToolCredentials) -> str: + return obj.tool._category + + @admin.display(ordering="tool__visible", boolean=True, description="Tool visible") + def is_tool_visible(self, obj: Configuration): + return obj.tool.visible + + @register(EmailLog) class EmailLogAdmin(admin.ModelAdmin): list_display = ["id", "category", "sender", "to", "subject", "when", "ok"] @@ -1916,9 +1941,9 @@ class PermissionAdmin(admin.ModelAdmin): form = PermissionAdminForm -def iframe_content(content, extra_style="padding-bottom: 75%") -> str: +def iframe_content(content, extra_style="padding-bottom: 65%") -> str: return mark_safe( - f'
' + f'
' ) diff --git a/NEMO/apps/area_access/templates/area_access/already_logged_in.html b/NEMO/apps/area_access/templates/area_access/already_logged_in.html index c4b95350..a428fe81 100644 --- a/NEMO/apps/area_access/templates/area_access/already_logged_in.html +++ b/NEMO/apps/area_access/templates/area_access/already_logged_in.html @@ -10,7 +10,7 @@

However, a scheduled outage is in effect in the {{ scheduled_outage_in_progr {% elif reservation_requirement_failed %}

However, you cannot enter since you don't have a current reservation for this area.

{% endif %} -

Please visit the {{ facility_name }} staff if you believe this is an error.

+

Please contact {{ facility_name }} staff if you believe this is an error.

{% if not reservation_requirement_failed and not max_capacity_reached and not scheduled_outage_in_progress %}

What would you like to do?

diff --git a/NEMO/apps/area_access/templates/area_access/badge_not_found.html b/NEMO/apps/area_access/templates/area_access/badge_not_found.html index 881ff6ce..cb25b4e4 100644 --- a/NEMO/apps/area_access/templates/area_access/badge_not_found.html +++ b/NEMO/apps/area_access/templates/area_access/badge_not_found.html @@ -1,6 +1,6 @@

Your badge wasn't recognized.

If you got a new one recently then we'll need to update your account.

-

Please visit the {{ facility_name }} staff to resolve the problem.

+

Please contact {{ facility_name }} staff to resolve the problem.

diff --git a/NEMO/apps/area_access/templates/area_access/inactive.html b/NEMO/apps/area_access/templates/area_access/inactive.html index ade36981..fb9577db 100644 --- a/NEMO/apps/area_access/templates/area_access/inactive.html +++ b/NEMO/apps/area_access/templates/area_access/inactive.html @@ -1,3 +1,3 @@

Your account was deactivated.

-

Please visit the {{ facility_name }} staff to resolve the problem.

+

Please contact the {{ facility_name }} staff to resolve the problem.

diff --git a/NEMO/apps/area_access/templates/area_access/no_active_projects.html b/NEMO/apps/area_access/templates/area_access/no_active_projects.html index 5777d073..a38dc216 100644 --- a/NEMO/apps/area_access/templates/area_access/no_active_projects.html +++ b/NEMO/apps/area_access/templates/area_access/no_active_projects.html @@ -1,4 +1,4 @@

You are not a member of any active projects.

You won't be able to use any interlocked {{ facility_name }} tools.

-

Please visit the {{ facility_name }} user office for more information.

+

Please contact {{ facility_name }} staff for more information.

diff --git a/NEMO/apps/area_access/templates/area_access/not_logged_in.html b/NEMO/apps/area_access/templates/area_access/not_logged_in.html index 9af49416..2916f9a6 100644 --- a/NEMO/apps/area_access/templates/area_access/not_logged_in.html +++ b/NEMO/apps/area_access/templates/area_access/not_logged_in.html @@ -1,5 +1,5 @@

According to our records, you're not logged in to any access controlled areas.

-

Please visit the {{ facility_name }} user office if you believe this is an error.

+

Please contact {{ facility_name }} staff if you believe this is an error.

diff --git a/NEMO/apps/kiosk/templates/kiosk/tool_information.html b/NEMO/apps/kiosk/templates/kiosk/tool_information.html index 1c081f28..21ba6658 100644 --- a/NEMO/apps/kiosk/templates/kiosk/tool_information.html +++ b/NEMO/apps/kiosk/templates/kiosk/tool_information.html @@ -149,6 +149,30 @@

{% endfor %} {% endif %} +{% if user.is_staff and tool_credentials %} +
+ + + +
+
Credentials
+
+
    + {% for cred in tool_credentials %} +
  • + {{ cred.username|default_if_none:"" }} + {% if cred.password %} {{ cred.password }}{% endif %} + {% if cred.comments %}({{ cred.comments }}){% endif %} +
  • + {% endfor %} +
+
+
+
+{% endif %} {% if tool.problems %}
{# Display all problems and shutdowns... #} @@ -248,7 +272,7 @@

Configuration

Post usage questions

{{ post_usage_questions }} {% endif %} - {% if tool.allow_delayed_logoff and not tool.delayed_logoff_in_progress %} + {% if tool.max_delayed_logoff is not None and not tool.delayed_logoff_in_progress %}

Delayed logoff

Use the following field to prevent others from using the tool for @@ -259,7 +283,7 @@

Delayed logoff

style="display:inline; width:auto" min="5" - max="120" + max="{{ tool.max_delayed_logoff }}" inputmode="numeric" pattern="[0-9]*" placeholder="0"> diff --git a/NEMO/apps/kiosk/templates/kiosk/tool_project_selection_snippet.html b/NEMO/apps/kiosk/templates/kiosk/tool_project_selection_snippet.html index 75c181f2..26149338 100644 --- a/NEMO/apps/kiosk/templates/kiosk/tool_project_selection_snippet.html +++ b/NEMO/apps/kiosk/templates/kiosk/tool_project_selection_snippet.html @@ -8,7 +8,7 @@

{% else %} use {% endif %} - any tools because you are not a member of an active project. Please visit the {{ facility_name }} staff to begin a project. + any tools because you are not a member of an active project. Please contact {{ facility_name }} staff to begin a project.

{% elif active_projects|length == 1 %}

diff --git a/NEMO/apps/kiosk/views.py b/NEMO/apps/kiosk/views.py index 19b082e7..507b3458 100644 --- a/NEMO/apps/kiosk/views.py +++ b/NEMO/apps/kiosk/views.py @@ -1,26 +1,29 @@ from datetime import datetime, timedelta from http import HTTPStatus +from django.conf import settings from django.contrib.auth.decorators import login_required, permission_required -from django.shortcuts import render, redirect +from django.db.models import Q +from django.shortcuts import redirect, render from django.utils import timezone from django.utils.dateparse import parse_date, parse_time +from django.utils.html import format_html from django.views.decorators.http import require_GET, require_POST from NEMO.decorators import synchronized from NEMO.exceptions import RequiredUnansweredQuestionsException -from NEMO.forms import CommentForm, nice_errors, TaskForm +from NEMO.forms import CommentForm, TaskForm, nice_errors from NEMO.models import ( BadgeReader, Project, Reservation, ReservationItemType, + TaskCategory, + TaskStatus, Tool, ToolWaitList, UsageEvent, User, - TaskCategory, - TaskStatus, ) from NEMO.policy import policy_class as policy from NEMO.utilities import localize, quiet_int @@ -33,7 +36,6 @@ shorten_reservation, ) from NEMO.views.customization import ApplicationCustomization, ToolCustomization -from NEMO.views.status_dashboard import create_tool_summary from NEMO.views.tasks import save_task from NEMO.views.tool_control import ( email_managers_required_questions_disable_tool, @@ -407,7 +409,6 @@ def choices(request): "customer": customer, "usage_events": list(usage_events), "upcoming_reservations": tool_reservations, - "tool_summary": create_tool_summary(), "categories": categories, "unqualified_categories": unqualified_categories, } @@ -433,7 +434,6 @@ def category_choices(request, category, user_id): "unqualified_tools": [ tool for tool in tools if not customer.is_staff and tool not in customer.qualifications.all() ], - "tool_summary": create_tool_summary(), } return render(request, "kiosk/category_choices.html", dictionary) @@ -458,9 +458,20 @@ def tool_information(request, tool_id, user_id, back): if user_wait_list_entry else 0 ) + tool_credentials = [] + if ToolCustomization.get_bool("tool_control_show_tool_credentials") and ( + customer.is_staff or customer.is_facility_manager + ): + if customer.is_facility_manager: + tool_credentials = tool.toolcredentials_set.all() + else: + tool_credentials = tool.toolcredentials_set.filter( + Q(authorized_staff__isnull=True) | Q(authorized_staff__in=[customer]) + ) dictionary = { "customer": customer, "tool": tool, + "tool_credentials": tool_credentials, "rendered_configuration_html": tool.configuration_widget(customer), "pre_usage_questions": DynamicForm(tool.pre_usage_questions).render( "tool_usage_group_question", tool.id, virtual_inputs=True @@ -597,15 +608,20 @@ def report_problem(request): return render(request, "kiosk/tool_report_problem.html", dictionary) + if not settings.ALLOW_CONDITIONAL_URLS and form.cleaned_data["force_shutdown"]: + site_title = ApplicationCustomization.get("site_title") + dictionary["message"] = format_html( + '
  • {}
'.format( + f"Tool control is only available on campus. When creating a task, you can't force a tool shutdown while using {site_title} off campus.", + ) + ) + dictionary["form"] = form + return render(request, "kiosk/tool_report_problem.html", dictionary) + task = form.save() task.estimated_resolution_time = estimated_resolution_time - save_error = save_task(request, task, customer) - - if save_error: - dictionary["message"] = save_error - dictionary["form"] = form - return render(request, "kiosk/tool_report_problem.html", dictionary) + save_task(request, task, customer) return redirect("kiosk_tool_information", tool_id=tool.id, user_id=customer.id, back=back) diff --git a/NEMO/context_processors.py b/NEMO/context_processors.py index b12c3e36..18f3e5f3 100644 --- a/NEMO/context_processors.py +++ b/NEMO/context_processors.py @@ -1,5 +1,6 @@ -from NEMO.models import Area, Customization, Notification, PhysicalAccessLevel, Tool, User +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.views.customization import CustomizationBase from NEMO.views.notifications import get_notification_counts @@ -63,11 +64,11 @@ def base_context(request): facility_managers_exist = User.objects.filter(is_active=True, is_facility_manager=True).exists() except: facility_managers_exist = False - customization_values = {customization.name: customization.value for customization in Customization.objects.all()} + customization_values = CustomizationBase.get_all() return { - "facility_name": customization_values.get("facility_name", "Facility"), - "recurring_charges_name": customization_values.get("recurring_charges_name", "Recurring charges"), - "site_title": customization_values.get("site_title", ""), + "facility_name": customization_values.get("facility_name"), + "recurring_charges_name": customization_values.get("recurring_charges_name"), + "site_title": customization_values.get("site_title"), "device": getattr(request, "device", "desktop"), "tools_exist": tools_exist, "areas_exist": areas_exist, @@ -84,10 +85,12 @@ def base_context(request): "date_input_js_format": date_input_js_format, "datetime_input_js_format": datetime_input_js_format, "no_header": request.session.get("no_header", False), - "safety_menu_item": customization_values.get("safety_main_menu", "enabled") == "enabled", - "calendar_page_title": customization_values.get("calendar_page_title", "Calendar"), - "tool_control_page_title": customization_values.get("tool_control_page_title", "Tool control"), - "status_dashboard_page_title": customization_values.get("status_dashboard_page_title", "Status dashboard"), - "requests_page_title": customization_values.get("requests_page_title", "Requests"), - "safety_page_title": customization_values.get("safety_page_title", "Safety"), + "safety_menu_item": customization_values.get("safety_main_menu") == "enabled", + "calendar_page_title": customization_values.get("calendar_page_title"), + "tool_control_page_title": customization_values.get("tool_control_page_title"), + "status_dashboard_page_title": customization_values.get("status_dashboard_page_title"), + "requests_page_title": customization_values.get("requests_page_title"), + "safety_page_title": customization_values.get("safety_page_title"), + "calendar_first_day_of_week": customization_values.get("calendar_first_day_of_week"), + "allow_profile_view": customization_values.get("user_allow_profile_view", "") == "enabled", } diff --git a/NEMO/decorators.py b/NEMO/decorators.py index d131146b..fe23c6dd 100644 --- a/NEMO/decorators.py +++ b/NEMO/decorators.py @@ -133,6 +133,9 @@ def decorator(view_func=None, redirect_field_name=REDIRECT_FIELD_NAME, login_url # For example, to replace NEMO.views.policy.check_policy_to_save_reservation(arg1, arg2) # @replace_function("NEMO.views.policy.check_policy_to_save_reservation") # def new_function(old_function, arg1, arg2) +# +# Note: this won't be executed when running management commands. To fix that, +# in the apps.py "ready" function, import the file where the annotated function is def replace_function(old_function_name, raise_exception=True): try: pkg, fun_name = old_function_name.rsplit(".", 1) diff --git a/NEMO/migrations/0080_version_6_0_0.py b/NEMO/migrations/0080_version_6_0_0.py new file mode 100644 index 00000000..a0c0bc71 --- /dev/null +++ b/NEMO/migrations/0080_version_6_0_0.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.25 on 2024-04-17 16:18 + +from django.db import migrations + +from NEMO.migrations_utils import create_news_for_version + + +class Migration(migrations.Migration): + + dependencies = [ + ("NEMO", "0079_user_active_access_expiration_verbose_names"), + ] + + def new_version_news(apps, schema_editor): + create_news_for_version(apps, "6.0.0", "") + + operations = [ + migrations.RunPython(new_version_news), + ] diff --git a/NEMO/migrations/0081_oracle_django_4_rename.py b/NEMO/migrations/0081_oracle_django_4_rename.py new file mode 100644 index 00000000..375e3534 --- /dev/null +++ b/NEMO/migrations/0081_oracle_django_4_rename.py @@ -0,0 +1,170 @@ +# Generated by Django 3.2.25 on 2024-05-29 15:35 +import hashlib + +from django.db import connection, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("NEMO", "0080_version_6_0_0"), + ] + + def check_nemo_6_0_0_oracle_long_names(apps, schema_editor): + rename_table_sql = "ALTER TABLE %s RENAME TO %s;" + rename_column_sql = "ALTER TABLE %s RENAME COLUMN %s to %s;" + max_name_length = connection.ops.max_name_length() + + table_renames = [] + column_renames = [] + + if not getattr(connection, "vendor", "") == "oracle": + print("This is only needed for ORACLE databases") + else: + with connection.cursor() as cursor: + table_list = connection.introspection.get_table_list(cursor) + + for _model in apps.get_models(include_auto_created=True): + # Table names. + db_table = _model._meta.db_table + new_quoted_name = connection.ops.quote_name(db_table) + old_quoted_name = new_quoted_name + if len(db_table) > max_name_length: + if new_quoted_name.lower() not in table_list: + old_quoted_name = old_quote_name(_model._meta.db_table, max_name_length) + table_renames.append(rename_table_sql % (old_quoted_name, new_quoted_name)) + + # Column names: + column_list = connection.introspection.get_table_description(cursor, strip_quotes(old_quoted_name)) + for field in _model._meta.local_fields: + if len(field.column) > max_name_length: + field_quoted_name = connection.ops.quote_name(field.column) + if field_quoted_name.lower() not in column_list: + column_renames.append( + rename_column_sql + % ( + new_quoted_name, + old_quote_name(field.column, max_name_length), + field_quoted_name, + ) + ) + + sql_queries = table_renames + column_renames + if not sql_queries: + print("No changes needed") + else: + with connection.cursor() as cursor: + for sql in table_renames + column_renames: + print("executing: " + sql) + cursor.execute(sql) + print("done") + + def check_nemo_6_0_0_oracle_long_names_reverse(apps, schema_editor): + rename_table_sql = "ALTER TABLE %s RENAME TO %s;" + rename_column_sql = "ALTER TABLE %s RENAME COLUMN %s to %s;" + max_name_length = connection.ops.max_name_length() + + table_renames = [] + column_renames = [] + + if not getattr(connection, "vendor", "") == "oracle": + print("This is only needed for ORACLE databases") + else: + with connection.cursor() as cursor: + table_list = connection.introspection.get_table_list(cursor) + + for _model in apps.get_models(include_auto_created=True): + # Table names. + db_table = _model._meta.db_table + new_quoted_name = connection.ops.quote_name(db_table) + if len(db_table) > max_name_length: + old_quoted_name = old_quote_name(_model._meta.db_table, max_name_length) + if old_quoted_name.lower() not in table_list: + table_renames.append(rename_table_sql % (new_quoted_name, old_quoted_name)) + + # Column names: + column_list = connection.introspection.get_table_description(cursor, strip_quotes(new_quoted_name)) + for field in _model._meta.local_fields: + if len(field.column) > max_name_length: + old_quoted_name = old_quote_name(field.column, max_name_length) + if old_quoted_name.lower() not in column_list: + field_quoted_name = connection.ops.quote_name(field.column) + column_renames.append( + rename_column_sql + % ( + new_quoted_name, + field_quoted_name, + old_quote_name(field.column, max_name_length), + ) + ) + + sql_queries = table_renames + column_renames + if not sql_queries: + print("No changes needed") + else: + with connection.cursor() as cursor: + for sql in table_renames + column_renames: + print("executing: " + sql) + cursor.execute(sql) + print("done") + + operations = [migrations.RunPython(check_nemo_6_0_0_oracle_long_names, check_nemo_6_0_0_oracle_long_names_reverse)] + + +def old_quote_name(name, max_name_length): + if not name.startswith('"') and not name.endswith('"'): + name = '"%s"' % truncate_name(name.upper(), max_name_length) + name = name.replace("%", "%%") + return name.upper() + + +def split_identifier(identifier): + """ + Split an SQL identifier into a two element tuple of (namespace, name). + + The identifier could be a table, column, or sequence name might be prefixed + by a namespace. + """ + try: + namespace, name = identifier.split('"."') + except ValueError: + namespace, name = "", identifier + return namespace.strip('"'), name.strip('"') + + +def truncate_name(identifier, length=None, hash_len=4): + """ + Shorten an SQL identifier to a repeatable mangled version with the given + length. + + If a quote stripped name contains a namespace, e.g. USERNAME"."TABLE, + truncate the table portion only. + """ + namespace, name = split_identifier(identifier) + + if length is None or len(name) <= length: + return identifier + + digest = names_digest(name, length=hash_len) + return "%s%s%s" % ('%s"."' % namespace if namespace else "", name[: length - hash_len], digest) + + +def names_digest(*args, length): + """ + Generate a 32-bit digest of a set of arguments that can be used to shorten + identifying names. + """ + h = hashlib.md5() + for arg in args: + h.update(arg.encode()) + return h.hexdigest()[:length] + + +def strip_quotes(table_name): + """ + Strip quotes off of quoted table names to make them safe for use in index + names, sequence names, etc. For example '"USER"."TABLE"' (an Oracle naming + scheme) becomes 'USER"."TABLE'. + """ + has_quotes = table_name.startswith('"') and table_name.endswith('"') + return table_name[1:-1] if has_quotes else table_name diff --git a/NEMO/migrations/0082_alter_userpreferences_tool_task_notifications.py b/NEMO/migrations/0082_alter_userpreferences_tool_task_notifications.py new file mode 100644 index 00000000..dfd3df53 --- /dev/null +++ b/NEMO/migrations/0082_alter_userpreferences_tool_task_notifications.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.11 on 2024-04-18 03:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("NEMO", "0081_oracle_django_4_rename"), + ] + + operations = [ + migrations.AlterField( + model_name="userpreferences", + name="tool_task_notifications", + field=models.ManyToManyField( + blank=True, + help_text="Tools to see maintenance records and receive task notifications for. If empty all notifications will be received.", + related_name="+", + to="NEMO.tool", + ), + ), + ] diff --git a/NEMO/migrations/0083_toolcredentials.py b/NEMO/migrations/0083_toolcredentials.py new file mode 100644 index 00000000..3338f04a --- /dev/null +++ b/NEMO/migrations/0083_toolcredentials.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.11 on 2024-05-21 17:28 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("NEMO", "0082_alter_userpreferences_tool_task_notifications"), + ] + + operations = [ + migrations.CreateModel( + name="ToolCredentials", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("username", models.CharField(blank=True, max_length=255, null=True)), + ("password", models.CharField(blank=True, max_length=255, null=True)), + ("comments", models.CharField(blank=True, max_length=255, null=True)), + ( + "authorized_staff", + models.ManyToManyField( + blank=True, + help_text="Selected staff will be the only ones allowed to see these credentials. Leave blank for all staff.", + to=settings.AUTH_USER_MODEL, + ), + ), + ("tool", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="NEMO.tool")), + ], + options={ + "verbose_name": "Tool credentials", + "verbose_name_plural": "Tool credentials", + "ordering": ["-tool__visible", "tool___category", "tool__name"], + }, + ), + ] diff --git a/NEMO/migrations/0084_remove_tool__allow_delayed_logoff_and_more.py b/NEMO/migrations/0084_remove_tool__allow_delayed_logoff_and_more.py new file mode 100644 index 00000000..cfd14bea --- /dev/null +++ b/NEMO/migrations/0084_remove_tool__allow_delayed_logoff_and_more.py @@ -0,0 +1,40 @@ +# Generated by Django 4.2.11 on 2024-05-24 18:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("NEMO", "0083_toolcredentials"), + ] + + def migrate_tool_delayed_logoff(apps, schema_editor): + Tool = apps.get_model("NEMO", "Tool") + for tool in Tool.objects.filter(_allow_delayed_logoff=True): + tool._max_delayed_logoff = 120 + tool.save(update_fields=["_max_delayed_logoff"]) + + def reverse_tool_delayed_logoff(apps, schema_editor): + Tool = apps.get_model("NEMO", "Tool") + for tool in Tool.objects.filter(_max_delayed_logoff__isnull=False): + tool._allow_delayed_logoff = True + tool.save(update_fields=["_allow_delayed_logoff"]) + + operations = [ + migrations.AddField( + model_name="tool", + name="_max_delayed_logoff", + field=models.PositiveIntegerField( + blank=True, + db_column="max_delayed_logoff", + help_text='[Optional] Maximum delay in minutes that users may enter upon logging off before another user may use the tool. Some tools require "spin-down" or cleaning time after use. Leave blank to disable.', + null=True, + ), + ), + migrations.RunPython(migrate_tool_delayed_logoff, reverse_tool_delayed_logoff), + migrations.RemoveField( + model_name="tool", + name="_allow_delayed_logoff", + ), + ] diff --git a/NEMO/migrations/0085_contactinformation_title_and_more.py b/NEMO/migrations/0085_contactinformation_title_and_more.py new file mode 100644 index 00000000..dd224c73 --- /dev/null +++ b/NEMO/migrations/0085_contactinformation_title_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.11 on 2024-06-04 02:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("NEMO", "0084_remove_tool__allow_delayed_logoff_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="contactinformation", + name="title", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name="contactinformation", + name="name", + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name="contactinformation", + name="office_location", + field=models.CharField(blank=True, max_length=255), + ), + ] diff --git a/NEMO/migrations/0086_adjustmentrequest_new_quantity.py b/NEMO/migrations/0086_adjustmentrequest_new_quantity.py new file mode 100644 index 00000000..f6c29427 --- /dev/null +++ b/NEMO/migrations/0086_adjustmentrequest_new_quantity.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2024-06-04 19:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("NEMO", "0085_contactinformation_title_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="adjustmentrequest", + name="new_quantity", + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/NEMO/mixins.py b/NEMO/mixins.py index 8316f0c1..6fb12442 100644 --- a/NEMO/mixins.py +++ b/NEMO/mixins.py @@ -86,7 +86,7 @@ def get_item(self) -> str: elif self.get_real_type() == BillableItemMixin.TRAINING: return f"{self.get_type_display()} training" elif self.get_real_type() == BillableItemMixin.CONSUMABLE: - quantity = f" (x {self.quantity}" if self.quantity > 1 else "" + quantity = f" (x {self.quantity})" if self.quantity > 1 else "" return f"{self.consumable}{quantity}" elif self.get_real_type() == BillableItemMixin.MISSED_RESERVATION: return f"{self.tool or self.area} missed reservation" @@ -111,6 +111,66 @@ def get_end(self) -> Optional[datetime.datetime]: elif self.get_real_type() in [BillableItemMixin.TRAINING, BillableItemMixin.CONSUMABLE]: return self.date + def can_be_adjusted(self, user: User): + # determine if a charge can be adjusted + from NEMO.views.customization import UserRequestsCustomization + from NEMO.views.usage import get_managed_projects + + pi_projects = get_managed_projects(user) + + time_limit = UserRequestsCustomization.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") + 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") + if self.remote_work: + return remote_enabled and time_limit_condition and operator_is_staff + else: + return ( + usage_enabled + and time_limit_condition + and user_project_condition + 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") + 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") + type_condition = True + if not self_check: + consumable_self_checked = ( + self.consumable.allow_self_checkout + and self.get_operator() == self.get_customer() + and not self.usage_event + ) + type_condition = type_condition and not consumable_self_checked + if not staff_check: + consumable_staff_check = ( + not self.usage_event and self.get_operator().is_staff and self.get_operator() != self.get_customer() + ) + type_condition = type_condition and not consumable_staff_check + if not usage_event: + 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") + return missed_resa_enabled and time_limit_condition and user_project_condition + 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 ba796304..d3ca6b64 100644 --- a/NEMO/models.py +++ b/NEMO/models.py @@ -46,6 +46,7 @@ get_chemical_document_filename, get_full_url, get_hazard_logo_filename, + get_model_instance, get_task_image_filename, get_tool_image_filename, new_model_copy, @@ -832,20 +833,27 @@ def natural_key(self): return (self.get_username(),) def clean(self): + from NEMO.views.customization import UserCustomization + + user_type_required = UserCustomization.get_bool("user_type_required") + if user_type_required and UserType.objects.exists() and not self.type_id: + raise ValidationError({"type": _("This field is required.")}) username_pattern = getattr(settings, "USERNAME_REGEX", None) if self.username: if username_pattern and not match(username_pattern, self.username): - raise ValidationError({"username": "Invalid username format"}) + raise ValidationError({"username": _("Invalid username format")}) username_taken = User.objects.filter(username__iexact=self.username) if self.pk: username_taken = username_taken.exclude(pk=self.pk) if username_taken.exists(): - raise ValidationError({"username": "This username has already been taken"}) + raise ValidationError({"username": _("This username has already been taken")}) if self.is_staff and self.is_service_personnel: raise ValidationError( { - "is_staff": "A user cannot be both staff and service personnel. Please choose one or the other.", - "is_service_personnel": "A user cannot be both staff and service personnel. Please choose one or the other.", + "is_staff": _("A user cannot be both staff and service personnel. Please choose one or the other."), + "is_service_personnel": _( + "A user cannot be both staff and service personnel. Please choose one or the other." + ), } ) @@ -1271,10 +1279,11 @@ class OperationMode(object): blank=True, help_text='The amount of time (in minutes) that a tool reservation may go unused before it is automatically marked as "missed" and hidden from the calendar. Usage can be from any user, regardless of who the reservation was originally created for. The cancellation process is triggered by a timed job on the web server.', ) - _allow_delayed_logoff = models.BooleanField( - db_column="allow_delayed_logoff", - default=False, - help_text='Upon logging off users may enter a delay before another user may use the tool. Some tools require "spin-down" or cleaning time after use.', + _max_delayed_logoff = models.PositiveIntegerField( + null=True, + blank=True, + db_column="max_delayed_logoff", + help_text='[Optional] Maximum delay in minutes that users may enter upon logging off before another user may use the tool. Some tools require "spin-down" or cleaning time after use. Leave blank to disable.', ) _pre_usage_questions = models.TextField( db_column="pre_usage_questions", @@ -1579,13 +1588,13 @@ def missed_reservation_threshold(self, value): self._missed_reservation_threshold = value @property - def allow_delayed_logoff(self): - return self.parent_tool.allow_delayed_logoff if self.is_child_tool() else self._allow_delayed_logoff + def max_delayed_logoff(self): + return self.parent_tool.max_delayed_logoff if self.is_child_tool() else self._max_delayed_logoff - @allow_delayed_logoff.setter - def allow_delayed_logoff(self, value): - self.raise_setter_error_if_child_tool("allow_delayed_logoff") - self._allow_delayed_logoff = value + @max_delayed_logoff.setter + def max_delayed_logoff(self, value): + self.raise_setter_error_if_child_tool("max_delayed_logoff") + self._max_delayed_logoff = value @property def pre_usage_questions(self): @@ -2537,6 +2546,9 @@ class Account(SerializationByNameModel): class Meta: ordering = ["name"] + def sorted_active_projects(self): + return self.sorted_projects().filter(active=True) + def sorted_projects(self): return self.project_set.all().order_by("-active", "name") @@ -3753,7 +3765,8 @@ class Meta(BaseCategory.Meta): class ContactInformation(BaseModel): - name = models.CharField(max_length=200) + name = models.CharField(max_length=CHAR_FIELD_MAXIMUM_LENGTH) + title = models.CharField(max_length=CHAR_FIELD_MAXIMUM_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", @@ -3761,7 +3774,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=200, blank=True) + office_location = models.CharField(max_length=CHAR_FIELD_MAXIMUM_LENGTH, blank=True) mobile_phone = models.CharField(max_length=40, blank=True) mobile_phone_is_sms_capable = models.BooleanField( default=True, @@ -4147,6 +4160,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) 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 @@ -4185,6 +4199,11 @@ def get_new_end(self) -> Optional[datetime]: else None ) + 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: previous_duration = self.item.end.replace(microsecond=0, second=0) - self.item.start.replace( @@ -4197,7 +4216,13 @@ def get_time_difference(self) -> str: else f"- {(previous_duration - new_duration)}" ) - def editable_charge(self) -> bool: + def get_difference(self): + return self.get_time_difference() or self.get_quantity_difference() + + def adjustable_charge(self): + return 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""" return self.item and (self.get_new_end() or self.get_new_start()) @@ -4223,17 +4248,29 @@ def reviewers(self) -> QuerySetType[User]: return facility_managers def apply_adjustment(self, user): - if self.status == RequestStatus.APPROVED and self.editable_charge(): - new_start = self.get_new_start() - new_end = self.get_new_end() - if new_start: - self.item.start = new_start - if new_end: - self.item.end = new_end - self.item.save() - self.applied = True - self.applied_by = user - self.save() + if self.status == RequestStatus.APPROVED: + if self.has_changed_time(): + new_start = self.get_new_start() + new_end = self.get_new_end() + if new_start: + self.item.start = new_start + if new_end: + self.item.end = new_end + self.item.save() + self.applied = True + self.applied_by = user + self.save() + elif self.get_quantity_difference(): + self.item.quantity = self.new_quantity + self.item.save() + 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() def delete(self, using=None, keep_parents=False): adjustment_id = self.id @@ -4250,7 +4287,8 @@ def delete(self, using=None, keep_parents=False): def clean(self): if not self.description: raise ValidationError({"description": _("This field is required.")}) - if self.item: + item = get_model_instance(self.item_type, self.item_id) + if item: already_adjusted = AdjustmentRequest.objects.filter( deleted=False, item_type_id=self.item_type_id, item_id=self.item_id ) @@ -4262,12 +4300,12 @@ def clean(self): raise ValidationError({"new_end": _("The end must be later than the start")}) if ( self.new_start - and format_datetime(self.new_start) == format_datetime(self.item.start) + and format_datetime(self.new_start) == format_datetime(item.start) and self.new_end - and format_datetime(self.new_end) == format_datetime(self.item.end) - ): + and format_datetime(self.new_end) == format_datetime(item.end) + ) or (self.new_quantity is not None and self.new_quantity == item.quantity): raise ValidationError( - {NON_FIELD_ERRORS: _("One of the dates must be different from the original charge")} + {NON_FIELD_ERRORS: _("You must change at least one attribute (dates or quantity)")} ) class Meta: @@ -4545,6 +4583,23 @@ class Meta(BaseDocumentModel.Meta): verbose_name_plural = "User knowledge base item documents" +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) + authorized_staff = models.ManyToManyField( + User, + blank=True, + help_text="Selected staff will be the only ones allowed to see these credentials. Leave blank for all staff.", + ) + + class Meta: + ordering = ["-tool__visible", "tool___category", "tool__name"] + verbose_name = "Tool credentials" + verbose_name_plural = "Tool credentials" + + class EmailLog(BaseModel): category = models.IntegerField(choices=EmailCategory.Choices, default=EmailCategory.GENERAL) when = models.DateTimeField(null=False, auto_now_add=True) diff --git a/NEMO/policy.py b/NEMO/policy.py index e4afb506..d94f879f 100644 --- a/NEMO/policy.py +++ b/NEMO/policy.py @@ -102,7 +102,7 @@ def check_to_enable_tool( abuse_email_address = EmailsCustomization.get("abuse_email_address") message = get_media_file_contents("unauthorized_tool_access_email.html") if abuse_email_address and message: - dictionary = {"operator": operator, "tool": tool, "type": "access"} + dictionary = {"operator": operator, "tool": tool, "type": "area-access"} rendered_message = render_email_template(message, dictionary) send_mail( subject="Area access requirement", @@ -126,7 +126,7 @@ def check_to_enable_tool( dictionary = { "operator": operator, "tool": tool, - "type": "reservation", + "type": "area-reservation", } rendered_message = render_email_template(message, dictionary) send_mail( @@ -196,16 +196,19 @@ def check_to_disable_tool(self, tool, operator, downtime) -> HttpResponse: return HttpResponseBadRequest( "You may not disable a tool while another user is using it unless you are a staff member." ) - if downtime < timedelta(): - return HttpResponseBadRequest("Downtime cannot be negative.") - if downtime > timedelta(minutes=120): - return HttpResponseBadRequest("Post-usage tool downtime may not exceed 120 minutes.") - if tool.delayed_logoff_in_progress() and downtime > timedelta(): - return HttpResponseBadRequest( - "The tool is already in a delayed-logoff state. You may not issue additional delayed logoffs until the existing one expires." - ) - if not tool.allow_delayed_logoff and downtime > timedelta(): - return HttpResponseBadRequest("Delayed logoff is not allowed for this tool.") + if downtime: + if downtime < timedelta(): + return HttpResponseBadRequest("Downtime cannot be negative.") + if downtime > timedelta(minutes=tool.max_delayed_logoff): + return HttpResponseBadRequest( + f"Post-usage tool downtime may not exceed {tool.max_delayed_logoff} minutes." + ) + if tool.delayed_logoff_in_progress() and downtime > timedelta(): + return HttpResponseBadRequest( + "The tool is already in a delayed-logoff state. You may not issue additional delayed logoffs until the existing one expires." + ) + if tool.max_delayed_logoff is None and downtime > timedelta(): + return HttpResponseBadRequest("Delayed logoff is not allowed for this tool.") return HttpResponse() def check_to_save_reservation( diff --git a/NEMO/serializers.py b/NEMO/serializers.py index 4f8ddc92..8dffb809 100644 --- a/NEMO/serializers.py +++ b/NEMO/serializers.py @@ -20,10 +20,12 @@ from NEMO.models import ( Account, AccountType, + AdjustmentRequest, Alert, AlertCategory, Area, AreaAccessRecord, + BuddyRequest, Configuration, ConfigurationOption, Consumable, @@ -32,16 +34,20 @@ Interlock, InterlockCard, InterlockCardCategory, + PhysicalAccessLevel, Project, ProjectDiscipline, Qualification, + RecurringConsumableCharge, Reservation, Resource, ScheduledOutage, StaffCharge, Task, TaskHistory, + TemporaryPhysicalAccessRequest, Tool, + ToolCredentials, TrainingSession, UsageEvent, User, @@ -398,6 +404,70 @@ class Meta: } +class RecurringConsumableChargeSerializer(FlexFieldsSerializerMixin, ModelSerializer): + class Meta: + model = RecurringConsumableCharge + fields = "__all__" + expandable_fields = { + "customer": "NEMO.serializers.UserSerializer", + "last_updated_by": "NEMO.serializers.UserSerializer", + "consumable": "NEMO.serializers.ConsumableSerializer", + "project": "NEMO.serializers.ProjectSerializer", + } + + +class PhysicalAccessLevelSerializer(FlexFieldsSerializerMixin, ModelSerializer): + class Meta: + model = PhysicalAccessLevel + fields = "__all__" + expandable_fields = {"area": "NEMO.serializers.AreaSerializer"} + + +class BuddyRequestSerializer(FlexFieldsSerializerMixin, ModelSerializer): + class Meta: + model = BuddyRequest + fields = "__all__" + expandable_fields = { + "area": "NEMO.serializers.AreaSerializer", + "user": "NEMO.serializers.UserSerializer", + } + + +class TemporaryPhysicalAccessRequestSerializer(FlexFieldsSerializerMixin, ModelSerializer): + class Meta: + model = TemporaryPhysicalAccessRequest + fields = "__all__" + expandable_fields = { + "creator": "NEMO.serializers.UserSerializer", + "last_updated_by": "NEMO.serializers.UserSerializer", + "physical_access_level": "NEMO.serializers.PhysicalAccessLevelSerializer", + "other_users": ("NEMO.serializers.UserSerializer", {"many": True}), + } + + +class AdjustmentRequestSerializer(FlexFieldsSerializerMixin, ModelSerializer): + class Meta: + model = AdjustmentRequest + fields = "__all__" + expandable_fields = { + "creator": "NEMO.serializers.UserSerializer", + "last_updated_by": "NEMO.serializers.UserSerializer", + "reviewer": "NEMO.serializers.UserSerializer", + "item_type": "NEMO.serializers.ContentTypeSerializer", + "applied_by": "NEMO.serializers.UserSerializer", + } + + +class ToolCredentialsSerializer(FlexFieldsSerializerMixin, ModelSerializer): + class Meta: + model = ToolCredentials + fields = "__all__" + expandable_fields = { + "tool": "NEMO.serializers.ToolSerializer", + "authorized_staff": ("NEMO.serializers.UserSerializer", {"many": True}), + } + + class PermissionSerializer(FlexFieldsSerializerMixin, ModelSerializer): users = PrimaryKeyRelatedField( source="user_set", many=True, queryset=User.objects.all(), allow_null=True, required=False diff --git a/NEMO/static/nemo.css b/NEMO/static/nemo.css index 72246877..1667c609 100644 --- a/NEMO/static/nemo.css +++ b/NEMO/static/nemo.css @@ -77,7 +77,7 @@ body.impersonating .application-sidebar, body.impersonating .application-content padding-right: 4px; } -#sidebar #tool_tree input[type=checkbox], #sidebar #area_tree input[type=checkbox] +#sidebar #tool_tree input[type=checkbox], #sidebar #area_tree input[type=checkbox], #sidebar #extra-links input[type=checkbox] { margin-right: 5px; margin-top: 0; diff --git a/NEMO/static/nemo.js b/NEMO/static/nemo.js index 29b29f7f..c608ca32 100644 --- a/NEMO/static/nemo.js +++ b/NEMO/static/nemo.js @@ -52,7 +52,7 @@ function switch_tab(element) function set_item_link_callback(callback) { - $("a[data-item-type='tool'], a[data-item-type='area']").each(function() + $("a[data-item-type='tool'], a[data-item-type='area'], a[data-item-type='personal-schedule']").each(function() { $(this).click({"callback": callback}, callback); }); @@ -60,7 +60,7 @@ function set_item_link_callback(callback) function set_item_checkbox_callback(callback) { - $("input[type='checkbox'][data-item-type='tool'], input[type='checkbox'][data-item-type='area']").each(function() + $("input[type='checkbox'][data-item-type='tool'], input[type='checkbox'][data-item-type='area'], input[type='checkbox'][data-item-type='personal-schedule']").each(function() { $(this).click({"callback": callback}, callback); }); @@ -167,7 +167,7 @@ function hide_empty_tool_categories() let categoryHasItem = false; $(category).find("li>a").each((toolIdx, tool) => { - let toolStyle = $(tool).attr("style"); + let toolStyle = $(tool).parent("li").attr("style"); if (toolStyle === undefined || toolStyle !== "display: none;") { @@ -865,3 +865,33 @@ function collapse_navbar(max_width, max_height) $("body").addClass("force-navbar-collapse"); } } + +function table_search(table_id, always_show_rows) +{ + always_show_rows = always_show_rows || []; + return function () + { + let rows = $("#"+table_id).find("tr").hide(); + if (this.value.length) + { + let data = this.value.split(" "); + $.each(data, function (i, v) + { + $.each(rows, function(i, row) + { + let $row = $(row); + if (always_show_rows.includes(i)) {$row.show();return true;} + // Only look in td within the row that don't have display:none, so we don't only look at visible cells + if ($row.find("td").filter(function() { return $(this).css('display') !== 'none'; }).filter(":icontains('" + v + "')").length !== 0) + { + $row.show(); + } + }); + }); + } + else + { + rows.show(); + } + } +} \ No newline at end of file diff --git a/NEMO/templates/accounts_and_projects/account_and_projects.html b/NEMO/templates/accounts_and_projects/account_and_projects.html index e9f31f44..7a4d6fd6 100644 --- a/NEMO/templates/accounts_and_projects/account_and_projects.html +++ b/NEMO/templates/accounts_and_projects/account_and_projects.html @@ -41,7 +41,7 @@

{% endif %} - {% for project in account.project_set.all %} + {% for project in account_projects %} {% if not selected_project or project == selected_project %}
{# Typeahead #} + {% endblock %} {# NEMO #} diff --git a/NEMO/templates/base/impersonate_header.html b/NEMO/templates/base/impersonate_header.html index 438e473c..4e75659e 100644 --- a/NEMO/templates/base/impersonate_header.html +++ b/NEMO/templates/base/impersonate_header.html @@ -1,5 +1,6 @@ diff --git a/NEMO/templates/base/navbar_base.html b/NEMO/templates/base/navbar_base.html index f148ab3f..0502fa7d 100644 --- a/NEMO/templates/base/navbar_base.html +++ b/NEMO/templates/base/navbar_base.html @@ -73,19 +73,17 @@ {% endif %} {% if tools_exist %} {% navigation_url 'configuration_agenda' 'Configuration agenda' user.is_staff user.is_superuser %} + {% navigation_url 'maintenance' 'Maintenance' user.is_staff user.is_superuser %} + {% navigation_url 'qualifications' 'Qualifications' user.is_staff user.is_superuser %} + {% navigation_url 'training' 'Training' user.is_staff user.is_superuser %} + {% navigation_url 'tool_credentials' 'Tool credentials' user.is_staff user.is_superuser %} {% endif %} {% if user.is_facility_manager or user.is_superuser or user.is_staff and "contracts"|customization:"contracts_view_staff" == "enabled" or user.is_user_office and "contracts"|customization:"contracts_view_user_office" == "enabled" or user.is_accounting_officer and "contracts"|customization:"contracts_view_accounting_officer" == "enabled" %} {% navigation_url 'service_contracts' 'Contracts & procurements' %} {% endif %} {% navigation_url 'customization' 'Customization' user.is_superuser %} {% navigation_url 'email_broadcast' 'Email' %} - {% if tools_exist %} - {% navigation_url 'maintenance' 'Maintenance' user.is_staff user.is_superuser %} - {% endif %} {% navigation_url 'project_billing' 'Project billing' user.is_accounting_officer user.is_user_office user.is_superuser %} - {% if tools_exist %} - {% navigation_url 'qualifications' 'Qualifications' user.is_staff user.is_superuser %} - {% endif %} {% navigation_url 'recurring_charges' recurring_charges_name user.is_user_office user.is_facility_manager user.is_superuser %} {% navigation_url 'staff_charges' 'Remote work' user.is_staff user.is_superuser %} {% navigation_url 'resources' 'Resources' user.is_staff user.is_superuser %} @@ -98,9 +96,6 @@ {% endif %} {% navigation_url 'consumables' 'Supplies' user.is_staff user.is_user_office user.is_superuser %} - {% if tools_exist %} - {% navigation_url 'training' 'Training' user.is_staff user.is_superuser %} - {% endif %} {% navigation_url 'users' 'Users' %} {% if "projects_and_accounts"|customization:"project_allow_transferring_charges" == "enabled" %} {% navigation_url 'transfer_charges' 'Transfer charges' user.is_accounting_officer user.is_facility_manager user.is_superuser %} @@ -130,6 +125,11 @@ {% endif %} + {% if allow_profile_view %} +
  • + My profile +
  • + {% endif %}
  • Preferences
  • @@ -157,9 +157,17 @@ {% endif %} -
  • - -
  • + {% block welcome_user %} +
  • + +
  • + {% endblock %}
    +
    + +
    +
    + + +
    +
    +
    + Whether the user type is required when adding new users. +
    +
    @@ -65,6 +87,24 @@

    General

    Check this box to allow uploading documents to users.
    +
    + +
    +
    + +
    +
    +
    + Check this box to enable users to view their own profile by clicking on their name in the navigation bar. +
    +

    Access expiration - reminders

    diff --git a/NEMO/templates/email/compose_email.html b/NEMO/templates/email/compose_email.html index 0ba949c1..527d240a 100644 --- a/NEMO/templates/email/compose_email.html +++ b/NEMO/templates/email/compose_email.html @@ -32,13 +32,13 @@

    Compose an email

    diff --git a/NEMO/templates/requests/adjustment_requests/adjustment_request.html b/NEMO/templates/requests/adjustment_requests/adjustment_request.html index 0208fe47..7be3f262 100644 --- a/NEMO/templates/requests/adjustment_requests/adjustment_request.html +++ b/NEMO/templates/requests/adjustment_requests/adjustment_request.html @@ -104,6 +104,27 @@

    {% endif %}

    {% endif %} + {% if change_quantity_allowed and form.item_id.value and form.item_type.value %} +
    +
    +
    + + +
    +
    + {% if form.new_quantity.errors %} +
    +
    + {% if form.new_quantity.errors %}{{ form.new_quantity.errors|striptags }}{% endif %} +
    +
    + {% endif %} +
    + {% endif %}
    {% if form.description.errors %}- {{ form.description.errors|striptags }}{% endif %} @@ -117,7 +138,9 @@

    {% if form.instance.get_status_display == "Pending" and instance_id and user in form.instance.reviewers %}
    - {% if form.description.errors %}- {{ form.manager_note.errors|striptags }}{% endif %} + {% if form.description.errors %} + - {{ form.manager_note.errors|striptags }} + {% endif %}