Repository: usnistgov/NEMO (language: python)

Issue:
Custom Time Format not working in Calendar events
Hello,
I wanted to file in a minor bug.

When specifying a different time format (e.g. 24h), both under `settings.py` and under Calendar Customizations, the events are still shown in 12h format in the calendar (1:15-2:00 vs the left axis in example below).
Maybe we need to add [`timeFormat`](https://fullcalendar.io/docs/v3/timeFormat) somewhere in [NEMO/templates/calendar/calendar.html](https://github.com/usnistgov/NEMO/blob/master/NEMO/templates/calendar/calendar.html)?
Thanks in advance

![image](https://github.com/usnistgov/NEMO/assets/100710244/f9d3f635-4bba-4fd7-971a-2f8b0d8764ba) 
![image](https://github.com/usnistgov/NEMO/assets/100710244/5ed3a4bd-2b1e-4de7-886e-4ae4b990f0ea)


Wait list & hybrid mode feature
## Wait List Feature

A tool can operate in three different modes
* Regular
* Wait List
* Hybrid

### Regular Mode
In regular mode, a tool operates with same behavior as previously implemented, reservations are allowed and tool can be used on first come first serve basis.

### Wait List Mode

- In wait list mode, the tool does not accept reservations, users can enter the wait list of a tool
  - if the tool is currently in use or there are other people in the wait list. 

- A tool is considered free to use, when there is no current user using the tool

- A user in the wait list is notified when their turn has come to use a tool 
     - The tool is free to use 
     - The user is at the top of the wait list
 
- A user's spot is held for a customizable set period of time since their turn has last become available, after that period, their spot expires and the next user's turn becomes available.

- In wait list mode, the user's time starts from on of these times (the latest one)
   - Last tool usage has ended
   - Previous user in the wait list has exited the wait list (either voluntarily or because their spot expired)

- A customization setting is available to configure the time to expiration for a spot in the wait list

- An option to skip the line is always available to allow for edge cases when users need to use the tool out of turn, when that happens, the current user at the top of the wait list will retain their spot until the tool becomes available again, and they will be notified again.

### Hybrid Mode

In hybrid mode, the tool operates the same as in wait list mode, with a few differences

- The tool will allow reservation

- A tool is considered free to use
     - There is no current reservation ongoing
     - There is no upcoming reservation within a set number of minutes (reservation buffer zone) this allows the tool to be free for the users that have an upcoming reservation when their time comes.
     - The user is at the top of the wait list

- A customization setting is available to configure the reservation buffer zone

- In hybrid mode, the user's time starts from on of these times (the latest one)
   - Last tool usage has ended
   - Previous user in the wait list has exited the wait list (either voluntarily or because their spot expired)
   - Last tool reservation has ended
       - If the reservation is missed, then the reservation end date is considered to be the reservation_start + missed_threshold


### Notes

The wait list check and update is performed in a timed_service that needs to be run periodically (e.g. every minute), if the service is not running, users will not be notified when their turns are available and the wait list will not expire spots.


Discussion / hints:

Fix patch:
diff --git a/NEMO/actions.py b/NEMO/actions.py
index da66ab1ca..31d5b7ac2 100644
--- a/NEMO/actions.py
+++ b/NEMO/actions.py
@@ -134,6 +134,11 @@ def adjustment_requests_export_csv(modeladmin, request, queryset):
     return adjustments_csv_export(queryset.all())
 
 
+@admin.action(description="Mark selected adjustment requests as applied")
+def adjustment_requests_mark_as_applied(modeladmin, request, queryset):
+    return queryset.update(applied=True, applied_by=request.user)
+
+
 @admin.action(description="Export selected access requests in CSV")
 def access_requests_export_csv(modeladmin, request, queryset):
     return access_csv_export(queryset.all())
diff --git a/NEMO/admin.py b/NEMO/admin.py
index 9097304bb..a7f14dcc9 100644
--- a/NEMO/admin.py
+++ b/NEMO/admin.py
@@ -1,7 +1,8 @@
 import datetime
+import json
 
 from django import forms
-from django.contrib import admin
+from django.contrib import admin, messages
 from django.contrib.admin import register
 from django.contrib.admin.decorators import display
 from django.contrib.admin.widgets import FilteredSelectMultiple
@@ -18,6 +19,7 @@
 from NEMO.actions import (
     access_requests_export_csv,
     adjustment_requests_export_csv,
+    adjustment_requests_mark_as_applied,
     create_next_interlock,
     disable_selected_cards,
     duplicate_configuration,
@@ -102,6 +104,7 @@
     ToolDocuments,
     ToolQualificationGroup,
     ToolUsageCounter,
+    ToolWaitList,
     TrainingSession,
     UsageEvent,
     User,
@@ -183,6 +186,22 @@ def __init__(self, *args, **kwargs):
             self.fields["required_resources"].initial = self.instance.required_resource_set.all()
             self.fields["nonrequired_resources"].initial = self.instance.nonrequired_resource_set.all()
 
+    def clean__pre_usage_questions(self):
+        questions = self.cleaned_data["_pre_usage_questions"]
+        try:
+            return json.dumps(json.loads(questions), indent=4)
+        except:
+            pass
+        return questions
+
+    def clean__post_usage_questions(self):
+        questions = self.cleaned_data["_post_usage_questions"]
+        try:
+            return json.dumps(json.loads(questions), indent=4)
+        except:
+            pass
+        return questions
+
     def clean(self):
         cleaned_data = super().clean()
         image = cleaned_data.get("_image")
@@ -240,6 +259,7 @@ class ToolAdmin(admin.ModelAdmin):
                     "name",
                     "parent_tool",
                     "_category",
+                    "_operation_mode",
                     "qualified_users",
                     "_qualifications_never_expire",
                     "_pre_usage_questions",
@@ -322,6 +342,13 @@ def save_model(self, request, obj, form, change):
         """
         Explicitly record any project membership changes on non-child tools.
         """
+        if not obj.allow_wait_list() and obj.current_wait_list():
+            obj.current_wait_list().update(deleted=True)
+            messages.warning(
+                request,
+                f"The wait list for {obj} has been deleted because the current operation mode does not allow it.",
+            )
+
         if obj.parent_tool:
             super(ToolAdmin, self).save_model(request, obj, form, change)
         else:
@@ -334,6 +361,12 @@ def save_model(self, request, obj, form, change):
                 obj.nonrequired_resource_set.set(form.cleaned_data["nonrequired_resources"])
 
 
+@register(ToolWaitList)
+class ToolWaitList(admin.ModelAdmin):
+    list_display = ["tool", "user", "date_entered", "date_exited", "expired", "deleted"]
+    list_filter = ["deleted", "expired", "tool"]
+
+
 @register(ToolQualificationGroup)
 class ToolQualificationGroup(admin.ModelAdmin):
     list_display = ["name", "get_tools"]
@@ -484,16 +517,22 @@ class ConfigurationAdmin(admin.ModelAdmin):
     list_display = (
         "id",
         "tool",
+        "is_tool_visible",
         "name",
         "enabled",
         "qualified_users_are_maintainers",
         "display_order",
         "exclude_from_configuration_agenda",
     )
+    list_filter = ["enabled", ("tool", admin.RelatedOnlyFieldListFilter), "tool__visible"]
     filter_horizontal = ("maintainers",)
     actions = [duplicate_configuration]
     autocomplete_fields = ["tool"]
 
+    @admin.display(ordering="tool__visible", boolean=True, description="Tool visible")
+    def is_tool_visible(self, obj: Configuration):
+        return obj.tool.visible
+
 
 @register(ConfigurationHistory)
 class ConfigurationHistoryAdmin(admin.ModelAdmin):
@@ -637,6 +676,14 @@ class Media:
         js = ("admin/dynamic_form_preview/dynamic_form_preview.js",)
         css = {"": ("admin/dynamic_form_preview/dynamic_form_preview.css",)}
 
+    def clean_questions(self):
+        questions = self.cleaned_data["questions"]
+        try:
+            return json.dumps(json.loads(questions), indent=4)
+        except:
+            pass
+        return questions
+
     def clean(self):
         cleaned_data = super().clean()
         reservation_questions = cleaned_data.get("questions")
@@ -1648,16 +1695,18 @@ class AdjustmentRequestAdmin(admin.ModelAdmin):
         "get_time_difference",
         "get_status_display",
         "reply_count",
+        "applied",
         "deleted",
     )
     list_filter = (
         "status",
         "deleted",
+        "applied",
         ("creator", admin.RelatedOnlyFieldListFilter),
         ("reviewer", admin.RelatedOnlyFieldListFilter),
     )
     date_hierarchy = "last_updated"
-    actions = [adjustment_requests_export_csv]
+    actions = [adjustment_requests_export_csv, adjustment_requests_mark_as_applied]
 
     @admin.display(description="Diff")
     def get_time_difference(self, adjustment_request: AdjustmentRequest):
diff --git a/NEMO/apps/area_access/templates/area_access/alerts.html b/NEMO/apps/area_access/templates/area_access/alerts.html
index ad61c4192..80e5812c1 100644
--- a/NEMO/apps/area_access/templates/area_access/alerts.html
+++ b/NEMO/apps/area_access/templates/area_access/alerts.html
@@ -14,7 +14,7 @@ <h2>Alerts and outages</h2>
                                 <span style="font-weight:bold">{{ a.title }}</span>
                                 <br>
                             {% endif %}
-                            {{ a.contents|linebreaks }}
+                            {{ a.contents|safe|linebreaksbr }}
                         </td>
                     </tr>
                 </table>
diff --git a/NEMO/apps/area_access/views.py b/NEMO/apps/area_access/views.py
index f9ae601ee..5f4941b7e 100644
--- a/NEMO/apps/area_access/views.py
+++ b/NEMO/apps/area_access/views.py
@@ -8,7 +8,7 @@
 from django.utils.formats import localize
 from django.views.decorators.http import require_GET, require_POST
 
-from NEMO.decorators import postpone
+from NEMO.decorators import disable_session_expiry_refresh, postpone
 from NEMO.exceptions import (
     InactiveUserError,
     MaximumCapacityReachedError,
@@ -352,6 +352,9 @@ def get_badge_reader(request) -> BadgeReader:
     return badge_reader
 
 
+@login_required
+@disable_session_expiry_refresh
+@require_GET
 def get_alerts(request):
     dictionary = {
         "alerts": Alert.objects.filter(user=None, debut_time__lte=timezone.now(), expired=False, deleted=False),
diff --git a/NEMO/apps/contracts/migrations/0003_documents_display_order.py b/NEMO/apps/contracts/migrations/0003_documents_display_order.py
new file mode 100644
index 000000000..0320ef10e
--- /dev/null
+++ b/NEMO/apps/contracts/migrations/0003_documents_display_order.py
@@ -0,0 +1,42 @@
+# Generated by Django 3.2.25 on 2024-04-08 11:57
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("contracts", "0002_increase_document_path_length"),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name="contractoragreementdocuments",
+            options={
+                "ordering": ["display_order", "-uploaded_at"],
+                "verbose_name_plural": "Contractor agreement documents",
+            },
+        ),
+        migrations.AlterModelOptions(
+            name="procurementdocuments",
+            options={"ordering": ["display_order", "-uploaded_at"], "verbose_name_plural": "Procurement documents"},
+        ),
+        migrations.AddField(
+            model_name="contractoragreementdocuments",
+            name="display_order",
+            field=models.IntegerField(
+                default=1,
+                help_text="The order in which choices are displayed on the landing page, from left to right, top to bottom. Lower values are displayed first.",
+            ),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name="procurementdocuments",
+            name="display_order",
+            field=models.IntegerField(
+                default=1,
+                help_text="The order in which choices are displayed on the landing page, from left to right, top to bottom. Lower values are displayed first.",
+            ),
+            preserve_default=False,
+        ),
+    ]
diff --git a/NEMO/apps/kiosk/templates/kiosk/kiosk.html b/NEMO/apps/kiosk/templates/kiosk/kiosk.html
index ed8057567..52e7d0be9 100644
--- a/NEMO/apps/kiosk/templates/kiosk/kiosk.html
+++ b/NEMO/apps/kiosk/templates/kiosk/kiosk.html
@@ -223,6 +223,42 @@ <h1 style="color:lightgrey" id="badge_number"></h1>
 			status_element.html("<h1>Loading tool information...</h1>").show();
             ajax_get(url, undefined, load_complete);
 		}
+		function tool_report_problem(url)
+        {
+            default_content_element.hide();
+            error_element.hide();
+            error_interlock_element.hide();
+            let data = $('#tool_control').serialize();
+            status_element.html("<h1>Loading report a problem...</h1>").show();
+            ajax_post(url, data, load_complete);
+        }
+        function report_problem()
+        {
+            default_content_element.hide();
+            error_element.hide();
+            error_interlock_element.hide();
+            let data = $('#tool_report_problem').serialize();
+            status_element.html("<h1>Reporting problem...</h1>").show();
+            ajax_post('{% url 'report_problem_from_kiosk' %}', data, load_complete);
+        }
+        function tool_post_comment(url)
+        {
+            default_content_element.hide();
+            error_element.hide();
+            error_interlock_element.hide();
+            let data = $('#tool_control').serialize();
+            status_element.html("<h1>Loading post a comment...</h1>").show();
+            ajax_post(url, data, load_complete);
+        }
+        function post_comment()
+        {
+            default_content_element.hide();
+            error_element.hide();
+            error_interlock_element.hide();
+            let data = $('#tool_post_comment').serialize();
+            status_element.html("<h1>Posting comment...</h1>").show();
+            ajax_post('{% url 'post_comment_from_kiosk' %}', data, load_complete);
+        }
 		function view_category(url)
 		{
 			default_content_element.hide();
@@ -262,6 +298,35 @@ <h1 style="color:lightgrey" id="badge_number"></h1>
         {
             ajax_post(url, data, function(response, status, xml_http_request) {interlock_success_callback(response, status, xml_http_request)}, function(response, status, xml_http_request) { return interlock_failure_callback(response, status, xml_http_request, url, data) })
         }
+
+		function enter_wait_list(tool_id, customer_id)
+        {
+			default_content_element.hide();
+			error_element.hide();
+			error_interlock_element.hide();
+			let url = "{% url 'enter_wait_list_from_kiosk' %}";
+			status_element.html("<h1>Entering wait list...</h1>").show();
+			let data = {
+                tool_id: tool_id,
+				customer_id: customer_id
+			};
+            ajax_post(url, data, load_complete);
+		}
+
+		function exit_wait_list(tool_id, customer_id)
+        {
+			default_content_element.hide();
+			error_element.hide();
+			error_interlock_element.hide();
+			let url = "{% url 'exit_wait_list_from_kiosk' %}";
+			status_element.html("<h1>Exiting wait list...</h1>").show();
+            let data = {
+                tool_id: tool_id,
+				customer_id: customer_id
+			};
+            ajax_post(url, data, load_complete);
+		}
+
 		function enable_tool(tool_id)
 		{
 			default_content_element.hide();
diff --git a/NEMO/apps/kiosk/templates/kiosk/tool_information.html b/NEMO/apps/kiosk/templates/kiosk/tool_information.html
index dac45fad6..1c081f283 100644
--- a/NEMO/apps/kiosk/templates/kiosk/tool_information.html
+++ b/NEMO/apps/kiosk/templates/kiosk/tool_information.html
@@ -4,7 +4,27 @@
         <div class="alert alert-warning" style="text-align:center; margin-bottom:0; font-size:x-large">Go back</div>
     </div>
 </div>
-<h1>{{ tool.name_or_child_in_use_name }}</h1>
+<h1 style="display:inline-block">{{ tool.name_or_child_in_use_name }}</h1>
+<button class="btn btn-default"
+        style="text-align:center;
+               margin-top:50px;
+               margin-bottom:10px;
+               margin-left:30px;
+               vertical-align:super;
+               font-size:x-large"
+        onclick="tool_report_problem('{% url 'kiosk_tool_report_problem' tool.id customer.id back %}')">
+    Report a problem
+</button>
+<button class="btn btn-default"
+        style="text-align:center;
+               margin-top:50px;
+               margin-bottom:10px;
+               margin-left:10px;
+               vertical-align:super;
+               font-size:x-large"
+        onclick="tool_post_comment('{% url 'kiosk_tool_post_comment' tool.id customer.id back %}')">
+    Post a comment
+</button>
 {# Display tool status... #}
 <div class="tool-status">
     {% if tool.in_use %}
@@ -284,118 +304,200 @@ <h2 id="answer_to_proceed" style="text-align:center">
             </div>
         {% endif %}
         <div style="width: 100%;padding-top: 40px;border-bottom: 1px dashed lightgray"></div>
-        <h1>Make a reservation</h1>
-        {% include 'kiosk/tool_project_selection_snippet.html' with active_projects=customer.active_projects tool=tool %}
-        <div id="start_reserve" class="col-xs-offset-3 col-xs-6" style="display:none;margin-top:30px">
-            <div class=" alert alert-info"
-                 style="text-align:center;
-                        margin-bottom:0;
-                        font-size:x-large"
-                 onclick="tool_reservation('{% url 'kiosk_tool_reservation' tool.id customer.id back %}')">
-                <span class="glyphicon glyphicon-calendar"></span> Reserve the {{ tool.name }}
-            </div>
-        </div>
-    {% else %}
-        {% if customer.is_staff or customer.is_service_personnel and tool in customer.qualifications.all or tool.ready_to_use and tool in customer.qualifications.all %}
+        {% if tool.allow_reservation %}
+            <h1>Make a reservation</h1>
             {% include 'kiosk/tool_project_selection_snippet.html' with active_projects=customer.active_projects tool=tool %}
-            {% if pre_usage_questions %}
-                <div id="pre_usage_questions" style="display:none">
-                    <h2>Pre usage questions</h2>
-                    {{ pre_usage_questions }}
+            <div id="start_reserve" class="col-xs-offset-3 col-xs-6" style="display:none;margin-top:30px">
+                <div class=" alert alert-info"
+                     style="text-align:center;
+                            margin-bottom:0;
+                            font-size:x-large"
+                     onclick="tool_reservation('{% url 'kiosk_tool_reservation' tool.id customer.id back %}')">
+                    <span class="glyphicon glyphicon-calendar"></span> Reserve the {{ tool.name }}
                 </div>
-            {% endif %}
-            <div id="start_reserve" style="display:none; margin-top:20px">
-                {% if tool.is_parent_tool %}
-                    <h4>You have {{ tool.tool_children_set.all|length|add:1 }} options to use this tool:</h4>
-                    <div class="col-xs-6" style="margin-top: 15px">
-                        <div class="start_button alert alert-success"
-                             style="text-align:center;
-                                    margin-bottom:0;
-                                    font-size:x-large;
-                                    display:none"
-                             onclick="enable_tool()">
-                            <span class="glyphicon glyphicon-play"></span> Start using the {{ tool.name }}
-                        </div>
+            </div>
+        {% endif %}
+    {% else %}
+        {% if show_wait_list and not wait_list_position == 1 %}
+            <a onclick="toggle_details(this)"
+               data-toggle="collapse"
+               data-target="#use-tool"
+               class="pointer"
+               style="padding-bottom: 10px;
+                      padding-top: 10px;
+                      display: block;
+                      color: darkgrey;
+                      font-size: large">
+                <span class="glyphicon glyphicon-chevron-right chevron"></span>Skip Wait List
+            </a>
+        {% endif %}
+        <div id="use-tool" class="{% if show_wait_list and not wait_list_position == 1 %}collapse{% endif %}">
+            {% if customer.is_staff or customer.is_service_personnel and tool in customer.qualifications.all or tool.ready_to_use and tool in customer.qualifications.all %}
+                {% include 'kiosk/tool_project_selection_snippet.html' with active_projects=customer.active_projects tool=tool %}
+                {% if pre_usage_questions %}
+                    <div id="pre_usage_questions" style="display:none">
+                        <h2>Pre usage questions</h2>
+                        {{ pre_usage_questions }}
                     </div>
-                    {% for alternate_tool in tool.tool_children_set.all %}
+                {% endif %}
+                <div id="start_reserve" style="display:none; margin-top:20px">
+                    {% if tool.is_parent_tool %}
+                        <h4>You have {{ tool.tool_children_set.all|length|add:1 }} options to use this tool:</h4>
                         <div class="col-xs-6" style="margin-top: 15px">
                             <div class="start_button alert alert-success"
                                  style="text-align:center;
                                         margin-bottom:0;
                                         font-size:x-large;
                                         display:none"
-                                 onclick="enable_tool('{{ alternate_tool.id }}')">
-                                <span class="glyphicon glyphicon-play"></span> Start using the {{ alternate_tool.name }}
+                                 onclick="enable_tool()">
+                                <span class="glyphicon glyphicon-play"></span> Start using the {{ tool.name }}
                             </div>
                         </div>
-                    {% endfor %}
-                    <div class="col-xs-offset-3 col-xs-5" style="margin-top: 15px">
-                        <div class=" alert alert-info"
-                             style="text-align:center;
-                                    margin-bottom:0;
-                                    font-size:x-large"
-                             onclick="tool_reservation('{% url 'kiosk_tool_reservation' tool.id customer.id back %}')">
-                            <span class="glyphicon glyphicon-calendar"></span> Reserve the {{ tool.name }}
-                        </div>
-                    </div>
-                {% else %}
-                    <div class="col-xs-offset-1 col-xs-5">
-                        <div class="start_button alert alert-success"
-                             style="text-align:center;
-                                    margin-bottom:0;
-                                    font-size:x-large;
-                                    display:none"
-                             onclick="enable_tool()">
-                            <span class="glyphicon glyphicon-play"></span> Start using the {{ tool.name }}
-                        </div>
-                    </div>
-                    <div class="col-xs-5">
-                        <div class=" alert alert-info"
-                             style="text-align:center;
-                                    margin-bottom:0;
-                                    font-size:x-large"
-                             onclick="tool_reservation('{% url 'kiosk_tool_reservation' tool.id customer.id back %}')">
-                            <span class="glyphicon glyphicon-calendar"></span> Reserve the {{ tool.name }}
+                        {% for alternate_tool in tool.tool_children_set.all %}
+                            <div class="col-xs-6" style="margin-top: 15px">
+                                <div class="start_button alert alert-success"
+                                     style="text-align:center;
+                                            margin-bottom:0;
+                                            font-size:x-large;
+                                            display:none"
+                                     onclick="enable_tool('{{ alternate_tool.id }}')">
+                                    <span class="glyphicon glyphicon-play"></span> Start using the {{ alternate_tool.name }}
+                                </div>
+                            </div>
+                        {% endfor %}
+                        {% if tool.allow_reservation %}
+                            <div class="col-xs-offset-3 col-xs-5" style="margin-top: 15px">
+                                <div class=" alert alert-info"
+                                     style="text-align:center;
+                                            margin-bottom:0;
+                                            font-size:x-large"
+                                     onclick="tool_reservation('{% url 'kiosk_tool_reservation' tool.id customer.id back %}')">
+                                    <span class="glyphicon glyphicon-calendar"></span> Reserve the {{ tool.name }}
+                                </div>
+                            </div>
+                        {% endif %}
+                    {% else %}
+                        <div class="{% if tool.allow_reservation %} col-xs-offset-1 col-xs-5 {% else %} col-xs-offset-3 col-xs-6 {% endif %}">
+                            <div class="start_button alert alert-success"
+                                 style="text-align:center;
+                                        margin-bottom:0;
+                                        font-size:x-large;
+                                        display:none"
+                                 onclick="enable_tool()">
+                                <span class="glyphicon glyphicon-play"></span> Start using the {{ tool.name }}
+                            </div>
                         </div>
-                    </div>
-                {% endif %}
-            </div>
-        {% elif tool not in customer.qualifications.all %}
-            <h2>
-                <p>You have not been qualified to use this tool yet.</p>
-                <p>
-                    Please contact the primary tool owner, {{ tool.primary_owner }}, if you would like to receive training for tool qualification.
-                </p>
-                {% if tool.backup_owners.all.exists %}
-                    <p>
-                        {% if tool.backup_owners.all.count == 1 %}
-                            If you are unable to reach {{ tool.primary_owner }} then please contact the backup tool owner, {{ tool.backup_owners.all|first }}.
-                        {% elif tool.backup_owners.all.count > 1 %}
-                            If you are unable to reach {{ tool.primary_owner }} then please contact one of the backup tool owners:
-                            <ul>
-                                {% for x in tool.backup_owners.all %}<li>{{ x }}</li>{% endfor %}
-                            </ul>
+                        {% if tool.allow_reservation %}
+                            <div class="col-xs-5">
+                                <div class=" alert alert-info"
+                                     style="text-align:center;
+                                            margin-bottom:0;
+                                            font-size:x-large"
+                                     onclick="tool_reservation('{% url 'kiosk_tool_reservation' tool.id customer.id back %}')">
+                                    <span class="glyphicon glyphicon-calendar"></span> Reserve the {{ tool.name }}
+                                </div>
+                            </div>
                         {% endif %}
+                    {% endif %}
+                </div>
+            {% elif tool not in customer.qualifications.all %}
+                <h2>
+                    <p>You have not been qualified to use this tool yet.</p>
+                    <p>
+                        Please contact the primary tool owner, {{ tool.primary_owner }}, if you would like to receive training for tool qualification.
                     </p>
-                {% endif %}
-                {% if tool.superusers.all.count == 1 %}
-                    {% with tool.superusers.all|first as superuser %}
+                    {% if tool.backup_owners.all.exists %}
                         <p>
-                            For equipment training, you can contact the primary, backup or the superuser, {{ superuser.get_contact_info_html|safe }}.
+                            {% if tool.backup_owners.all.count == 1 %}
+                                If you are unable to reach {{ tool.primary_owner }} then please contact the backup tool owner, {{ tool.backup_owners.all|first }}.
+                            {% elif tool.backup_owners.all.count > 1 %}
+                                If you are unable to reach {{ tool.primary_owner }} then please contact one of the backup tool owners:
+                                <ul>
+                                    {% for x in tool.backup_owners.all %}<li>{{ x }}</li>{% endfor %}
+                                </ul>
+                            {% endif %}
                         </p>
-                    {% endwith %}
-                {% elif tool.superusers.all.count > 1 %}
-                    <p>
-                        For equipment training, you can contact the primary, backup or one of the superusers:
-                        <ul>
-                            {% for x in tool.superusers.all %}<li>{{ x }}</li>{% endfor %}
-                        </ul>
-                    </p>
-                {% endif %}
-            </h2>
-        {% endif %}
+                    {% endif %}
+                    {% if tool.superusers.all.count == 1 %}
+                        {% with tool.superusers.all|first as superuser %}
+                            <p>
+                                For equipment training, you can contact the primary, backup or the superuser, {{ superuser.get_contact_info_html|safe }}.
+                            </p>
+                        {% endwith %}
+                    {% elif tool.superusers.all.count > 1 %}
+                        <p>
+                            For equipment training, you can contact the primary, backup or one of the superusers:
+                            <ul>
+                                {% for x in tool.superusers.all %}<li>{{ x }}</li>{% endfor %}
+                            </ul>
+                        </p>
+                    {% endif %}
+                </h2>
+            {% endif %}
+        </div>
     {% endif %}
 </form>
+{% if show_wait_list %}
+    <div style="margin-top: 15px; display: inline-block; width: 100%;">
+        <div style="margin-bottom: 10px; font-size: x-large; width: 100%;">
+            <span>This tool has a wait list. There are <strong>{{ wait_list.count }}</strong> people in line to use this tool.</span>
+            {% if not wait_list_position == 0 %}
+                <br>
+                <span>You are number <strong>{{ wait_list_position }}</strong> in line to use this tool.</span>
+            {% endif %}
+        </div>
+        <div id="wait-list" style="margin-top: 20px;">
+            {% if wait_list_position == 0 %}
+                <div class="alert alert-success"
+                     style="text-align:center;
+                            margin-bottom:0;
+                            font-size:x-large"
+                     onclick="enter_wait_list('{{ tool.id }}', '{{ customer.id }}'); return false;">
+                    <span class="glyphicon glyphicon-save"></span> Enter Wait List for {{ tool.name }}
+                </div>
+            {% else %}
+                <div class="alert alert-danger"
+                     style="text-align:center;
+                            margin-bottom:0;
+                            font-size:x-large"
+                     onclick="exit_wait_list('{{ tool.id }}', '{{ customer.id }}'); return false;">
+                    <span class="glyphicon glyphicon-trash"></span> Exit Wait List for {{ tool.name }}
+                </div>
+            {% endif %}
+            {% if wait_list.count > 0 %}
+                <div style="margin-top: 20px;">
+                    <a onclick="toggle_details(this)"
+                       style="font-size: x-large"
+                       data-toggle="collapse"
+                       data-target="#wait-list-content"
+                       class="pointer">
+                        <span class="glyphicon glyphicon-chevron-right chevron"></span>Show Wait List
+                    </a>
+                </div>
+            {% endif %}
+        </div>
+        {% if wait_list.count > 0 %}
+            <div id="wait-list-content" style="padding-top: 10px;" class="collapse">
+                <table style="margin-top: 10px;" class="table table-condensed table-striped table-bordered">
+                    <thead>
+                        <tr style="font-size: x-large;">
+                            <th>Position</th>
+                            <th>User</th>
+                        </tr>
+                    </thead>
+                    <tbody>
+                        {% for entry in wait_list %}
+                            <tr class="{% if customer.id == entry.user.id %}success{% endif %}" style="font-size: large;">
+                                <td>{{ forloop.counter }}</td>
+                                <td>{{ entry.user.get_full_name }}</td>
+                            </tr>
+                        {% endfor %}
+                    </tbody>
+                </table>
+            </div>
+        {% endif %}
+    </div>
+{% endif %}
 <div style="height:150px">{# Spacer #}</div>
 <script>
 	function update_stop_button()
@@ -427,9 +529,7 @@ <h2>
 	}
 
 	update_stop_button();
-	{% if customer.active_projects|length == 1 %}
-		update_start_button();
-	{% endif %}
+	{% if customer.active_projects|length == 1 %}update_start_button();{% endif %}
 
 	{% if tool.in_use %}
 		$('body').on('question-group-changed', update_stop_button);
diff --git a/NEMO/apps/kiosk/templates/kiosk/tool_post_comment.html b/NEMO/apps/kiosk/templates/kiosk/tool_post_comment.html
new file mode 100644
index 000000000..8e14ea096
--- /dev/null
+++ b/NEMO/apps/kiosk/templates/kiosk/tool_post_comment.html
@@ -0,0 +1,69 @@
+{% load static %}
+{% load custom_tags_and_filters %}
+{% block extrahead %}
+    <script type="text/javascript" src="{% static "pickadate/picker.js" %}"></script>
+    <script type="text/javascript" src="{% static "pickadate/picker.date.js" %}"></script>
+    <script type="text/javascript" src="{% static "pickadate/picker.time.js" %}"></script>
+    <link rel="stylesheet" type="text/css" href="{% static "pickadate/default.css" %}" />
+    <link rel="stylesheet" type="text/css" href="{% static "pickadate/default.date.css" %}" />
+    <link rel="stylesheet" type="text/css" href="{% static "pickadate/default.time.css" %}" />
+{% endblock %}
+<div class="row" style="margin-top:30px">
+    <div class="col-xs-offset-3 col-xs-6"
+         onclick="tool_information('{% url 'kiosk_tool_information' tool.id customer.id back %}')">
+        <div class="alert alert-warning" style="text-align:center; margin-bottom:0; font-size:x-large">Go back</div>
+    </div>
+</div>
+<h2>Post a comment</h2>
+<h4>
+    Use this form to comment on the operating status of the selected tool.
+    The comment will be visible to everyone on the website for <strong>informational purposes</strong>.
+    (Note: if there is something wrong with the tool then please report a problem instead of creating a comment).
+    You may remove the comment at any time because you are its author.
+</h4>
+{% if message %}
+    <div class="alert alert-danger">
+        <h4>Please correct the following errors:</h4>
+        <h4>{{ message }}</h4>
+    </div>
+{% endif %}
+<form id="tool_post_comment" method="post">
+    {% csrf_token %}
+    <input type="hidden" name="back" value="{{ back }}">
+    <input type="hidden" name="tool" value="{{ tool.id }}">
+    <input type="hidden" name="customer_id" value="{{ customer.id }}">
+    <div class="form-group">
+        <label class="form-inline">
+            How many days would you like the comment to be visible?
+            <select name="expiration" id="expiration" class="form-control" style="width:250px;font-size:18px;" required>
+                <option value="" disabled selected></option>
+                <option value="1">1 day</option>
+                <option value="7">7 days</option>
+                <option value="30">30 days</option>
+                <option value="60">60 days</option>
+                <option value="90">90 days</option>
+                <option value="-1">Indefinitely</option>
+                {% if user.is_any_part_of_staff %}<option value="0">Not visible</option>{% endif %}
+            </select>
+        </label>
+    </div>
+    <div class="form-group">
+        <label for="content-area">What would you like to say?</label>
+        <textarea name="content" id="content-area" class="form-control" rows="6" required>{{form.content.value}}</textarea>
+    </div>
+    {% if user.is_staff %}
+        <div class="form-group">
+            <input type="checkbox" id="staff_only" name="staff_only" {% if form.staff_only.value %}checked{% endif %} />
+            <label for="staff_only">Make this comment only visible to staff users</label>
+        </div>
+    {% endif %}
+    <div class="form-group text-center" style="margin-top:40px">
+        {% button type="save" submit=False style="text-align:center; margin-bottom:0; font-size:x-large" icon="glyphicon-send" onclick="post_comment()" value="Post comment" %}
+    </div>
+</form>
+<script>
+	$('#content-area').keyboard();
+	$('#expiration option[value="{{form.expiration.value}}"]').prop('selected', true);
+
+	revert(90);
+</script>
diff --git a/NEMO/apps/kiosk/templates/kiosk/tool_report_problem.html b/NEMO/apps/kiosk/templates/kiosk/tool_report_problem.html
new file mode 100644
index 000000000..b563aa347
--- /dev/null
+++ b/NEMO/apps/kiosk/templates/kiosk/tool_report_problem.html
@@ -0,0 +1,124 @@
+{% load static %}
+{% load custom_tags_and_filters %}
+{% block extrahead %}
+    <script type="text/javascript" src="{% static "pickadate/picker.js" %}"></script>
+    <script type="text/javascript" src="{% static "pickadate/picker.date.js" %}"></script>
+    <script type="text/javascript" src="{% static "pickadate/picker.time.js" %}"></script>
+    <link rel="stylesheet" type="text/css" href="{% static "pickadate/default.css" %}" />
+    <link rel="stylesheet" type="text/css" href="{% static "pickadate/default.date.css" %}" />
+    <link rel="stylesheet" type="text/css" href="{% static "pickadate/default.time.css" %}" />
+{% endblock %}
+<div class="row" style="margin-top:30px">
+    <div class="col-xs-offset-3 col-xs-6"
+         onclick="tool_information('{% url 'kiosk_tool_information' tool.id customer.id back %}')">
+        <div class="alert alert-warning" style="text-align:center; margin-bottom:0; font-size:x-large">Go back</div>
+    </div>
+</div>
+<h2>Report a problem</h2>
+<h4>
+    Use this form to report a problem relating to the currently selected tool. The {{ facility_name }} staff as well as anyone with an upcoming reservation will be notified of the problem by email and the details of the problem will be visible to everyone on the website.
+</h4>
+{% if message %}
+    <div class="alert alert-danger">
+        <h4>Please correct the following errors:</h4>
+        <h4>{{ message }}</h4>
+    </div>
+{% endif %}
+<form id="tool_report_problem" method="post">
+    {% csrf_token %}
+    <input type="hidden" name="action" value="create">
+    <input type="hidden" name="back" value="{{ back }}">
+    <input type="hidden" name="tool" value="{{ tool.id }}">
+    <input type="hidden" name="customer_id" value="{{ customer.id }}">
+    {% if task_categories %}
+        <div class="form-group">
+            <label for="problem_category">
+                Please choose a category that best describes this problem. If there is no appropriate category then leave this field blank.
+            </label>
+            <select id="problem_category" name="problem_category" class="form-control" style="max-width:300px">
+                <option value="">&nbsp;</option>
+                {% for c in task_categories %}<option value="{{ c.id }}">{{ c.name }}</option>{% endfor %}
+            </select>
+        </div>
+    {% endif %}
+    {% if task_statuses and user.is_staff %}
+        <div class="form-group">
+            <label for="status">What is the status of this task?</label>
+            <select id="status" name="status" class="form-control" style="max-width:300px">
+                <option value="">&nbsp;</option>
+                {% for s in task_statuses %}<option>{{ s.name }}</option>{% endfor %}
+            </select>
+        </div>
+    {% endif %}
+    <div class="form-group">
+        <label for="description">
+            Please provide a <strong>detailed</strong> description of the problem so that the {{ facility_name }} staff have enough information to resolve the problem efficiently.
+        </label>
+        <textarea id="description" name="description" class="form-control" rows="7" required>{{form.description.value}}</textarea>
+    </div>
+    {% if user.is_staff %}
+        <div class="form-group">
+            <div style="position:relative">
+                <label for="estimated_resolution_tm" class="control-label">
+                    What is your best estimate of when the problem will be resolved? Leave this field blank if you are unsure.
+                </label>
+                <input id="estimated_resolution_dt"
+                       name="estimated_resolution_dt"
+                       type="text"
+                       class="form-control"
+                       autocomplete="off"
+                       placeholder="Choose a date"
+                       value="{{ estimated_resolution_dt }}">
+                <br />
+                <input id="estimated_resolution_tm"
+                       name="estimated_resolution_tm"
+                       type="text"
+                       class="form-control"
+                       autocomplete="off"
+                       placeholder="Choose a time"
+                       value="{{ estimated_resolution_tm }}">
+            </div>
+        </div>
+    {% endif %}
+    <div class="form-group">
+        <div class="checkbox">
+            <label>
+                <input name="safety_hazard"
+                       id="safety_hazard"
+                       type="checkbox"
+                       {% if form.safety_hazard.value %}checked{% endif %} />
+                <span style="padding-left:10px;">This problem represents a safety hazard to the {{ facility_name }} and should be regarded as urgent.Notify the {{ facility_name }} safety officer of this issue.</span>
+            </label>
+        </div>
+        <div class="checkbox">
+            <label>
+                <input name="force_shutdown"
+                       id="force_shutdown"
+                       type="checkbox"
+                       {% if form.force_shutdown.value %}checked{% endif %} />
+                <span style="padding-left:10px;">Shut down the tool so that it may not be used until this problem is resolved.</span>
+            </label>
+        </div>
+    </div>
+    <div class="form-group text-center" style="margin-top:40px">
+        {% button type="delete" submit=False style="text-align:center; margin-bottom:0; font-size:x-large" icon="glyphicon-send" onclick="report_problem()" value="Report problem" %}
+    </div>
+</form>
+<script>
+	$('#description').keyboard();
+	{% if task_categories %}
+		$('#problem_category option[value="{{form.problem_category.value}}"]').prop('selected', true);
+	{% endif %}
+	{% if task_statuses and user.is_staff %}
+		$('#status option[value="{{form.status.value}}"]').prop('selected', true);
+	{% endif %}
+
+	let date_picker = $('#estimated_resolution_dt').pickadate({format: "yyyy-mm-dd", formatSubmit: "yyyy-mm-dd", firstDay: 1, hiddenName: true});
+	let start_time_picker = $('#estimated_resolution_tm').pickatime({interval: 15, format: "H:i",formatSubmit: "H:i", hiddenName: true});
+	// set initial date
+	if ('{{ date|default_if_none:'' }}') {
+		date_picker.pickadate('picker').set('select', '{{ date }}', {format: 'MM/DD/YYYY'})
+	}
+
+	revert(90);
+</script>
diff --git a/NEMO/apps/kiosk/urls.py b/NEMO/apps/kiosk/urls.py
index f0a29c9c2..b6cb535c6 100644
--- a/NEMO/apps/kiosk/urls.py
+++ b/NEMO/apps/kiosk/urls.py
@@ -13,6 +13,8 @@
                 path("enable_tool/", views.enable_tool, name="enable_tool_from_kiosk"),
                 path("disable_tool/", views.disable_tool, name="disable_tool_from_kiosk"),
                 path("reserve_tool/", views.reserve_tool, name="reserve_tool_from_kiosk"),
+                path("post_comment/", views.post_comment, name="post_comment_from_kiosk"),
+                path("report_problem/", views.report_problem, name="report_problem_from_kiosk"),
                 path(
                     "cancel_reservation/<int:reservation_id>/",
                     views.cancel_reservation,
@@ -34,7 +36,19 @@
                     views.tool_reservation,
                     name="kiosk_tool_reservation",
                 ),
+                path("enter_wait_list/", views.enter_wait_list, name="enter_wait_list_from_kiosk"),
+                path("exit_wait_list/", views.exit_wait_list, name="exit_wait_list_from_kiosk"),
                 path("logout_user/<int:tool_id>", views.logout_user, name="kiosk_logout_user"),
+                re_path(
+                    r"^tool_report_problem/(?P<tool_id>\d+)/(?P<user_id>\d+)/(?P<back>back_to_start|back_to_category)/$",
+                    views.tool_report_problem,
+                    name="kiosk_tool_report_problem",
+                ),
+                re_path(
+                    r"^tool_post_comment/(?P<tool_id>\d+)/(?P<user_id>\d+)/(?P<back>back_to_start|back_to_category)/$",
+                    views.tool_post_comment,
+                    name="kiosk_tool_post_comment",
+                ),
                 # Keeping for backwards compatibility (bookmarked links with location)
                 re_path(r"^(?P<location>.+)/$", views.kiosk, name="kiosk"),
                 path("", views.kiosk, name="kiosk"),
diff --git a/NEMO/apps/kiosk/views.py b/NEMO/apps/kiosk/views.py
index df2a4948f..18288724a 100644
--- a/NEMO/apps/kiosk/views.py
+++ b/NEMO/apps/kiosk/views.py
@@ -2,14 +2,26 @@
 from http import HTTPStatus
 
 from django.contrib.auth.decorators import login_required, permission_required
-from django.shortcuts import render
+from django.shortcuts import render, redirect
 from django.utils import timezone
 from django.utils.dateparse import parse_date, parse_time
 from django.views.decorators.http import require_GET, require_POST
 
 from NEMO.decorators import synchronized
 from NEMO.exceptions import RequiredUnansweredQuestionsException
-from NEMO.models import BadgeReader, Project, Reservation, ReservationItemType, Tool, UsageEvent, User
+from NEMO.forms import CommentForm, nice_errors, TaskForm
+from NEMO.models import (
+    BadgeReader,
+    Project,
+    Reservation,
+    ReservationItemType,
+    Tool,
+    ToolWaitList,
+    UsageEvent,
+    User,
+    TaskCategory,
+    TaskStatus,
+)
 from NEMO.policy import policy_class as policy
 from NEMO.utilities import localize, quiet_int
 from NEMO.views.area_access import log_out_user
@@ -22,10 +34,12 @@
 )
 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,
     interlock_bypass_allowed,
     interlock_error,
+    save_comment,
 )
 from NEMO.widgets.dynamic_form import DynamicForm
 
@@ -78,6 +92,11 @@ def do_enable_tool(request, tool_id):
         return render(request, "kiosk/acknowledgement.html", dictionary)
     new_usage_event.save()
 
+    # Remove wait list entry if it exists
+    wait_list_entry = ToolWaitList.objects.filter(tool=tool, expired=False, deleted=False, user=customer)
+    if wait_list_entry.count() > 0:
+        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)
     except Exception as e:
@@ -161,6 +180,66 @@ def do_disable_tool(request, tool_id):
     return render(request, "kiosk/acknowledgement.html", dictionary)
 
 
+@login_required
+@permission_required("NEMO.kiosk")
+@require_POST
+def enter_wait_list(request):
+    tool = Tool.objects.get(id=request.POST["tool_id"])
+    customer = User.objects.get(id=request.POST["customer_id"])
+
+    if not tool.allow_wait_list():
+        dictionary = {
+            "message": "{} does not operate in wait list mode. ".format(tool),
+            "delay": 10,
+        }
+        return render(request, "kiosk/acknowledgement.html", dictionary)
+
+    wait_list_other_users = ToolWaitList.objects.filter(tool=tool, expired=False, deleted=False).exclude(user=customer)
+    wait_list_user_entry = ToolWaitList.objects.filter(tool=tool, expired=False, deleted=False, user=customer)
+
+    # User must not be in the wait list
+    if wait_list_user_entry.count() > 0:
+        dictionary = {"message": "You are already in the wait list.", "delay": 10}
+        return render(request, "kiosk/acknowledgement.html", dictionary)
+
+    # The tool must be in use or have a wait list
+    current_usage_event = tool.get_current_usage_event()
+    if not current_usage_event and wait_list_other_users.count() == 0:
+        dictionary = {"message": "The tool is free to use.", "delay": 10}
+        return render(request, "kiosk/acknowledgement.html", dictionary)
+
+    # The user must be qualified to use the tool itself, or the parent tool in case of alternate tool.
+    tool_to_check_qualifications = tool.parent_tool if tool.is_child_tool() else tool
+    if tool_to_check_qualifications not in customer.qualifications.all() and not customer.is_staff:
+        dictionary = {"message": "You are not qualified to use this tool.", "delay": 10}
+        return render(request, "kiosk/acknowledgement.html", dictionary)
+
+    entry = ToolWaitList()
+    entry.user = customer
+    entry.tool = tool
+    entry.save()
+
+    return redirect("kiosk_tool_information", tool_id=tool.id, user_id=customer.id, back="back_to_category")
+
+
+@login_required
+@permission_required("NEMO.kiosk")
+@require_POST
+def exit_wait_list(request):
+    tool = Tool.objects.get(id=request.POST["tool_id"])
+    customer = User.objects.get(id=request.POST["customer_id"])
+    wait_list_entry = ToolWaitList.objects.filter(tool=tool, expired=False, deleted=False, user=customer)
+    if wait_list_entry.count() == 0:
+        dictionary = {"message": "You are not in the wait list.", "delay": 10}
+        return render(request, "kiosk/acknowledgement.html", dictionary)
+    do_exit_wait_list(wait_list_entry, timezone.now())
+    return redirect("kiosk_tool_information", tool_id=tool.id, user_id=customer.id, back="back_to_category")
+
+
+def do_exit_wait_list(entry, time):
+    entry.update(deleted=True, date_exited=time)
+
+
 @login_required
 @permission_required("NEMO.kiosk")
 @require_POST
@@ -365,6 +444,20 @@ def category_choices(request, category, user_id):
 def tool_information(request, tool_id, user_id, back):
     tool = Tool.objects.get(id=tool_id, visible=True)
     customer = User.objects.get(id=user_id)
+    wait_list = tool.current_wait_list()
+    user_wait_list_entry = wait_list.filter(user=user_id).first()
+    user_wait_list_position = (
+        (
+            ToolWaitList.objects.filter(
+                tool=tool, date_entered__lte=user_wait_list_entry.date_entered, expired=False, deleted=False
+            )
+            .exclude(user=customer)
+            .count()
+            + 1
+        )
+        if user_wait_list_entry
+        else 0
+    )
     dictionary = {
         "customer": customer,
         "tool": tool,
@@ -377,6 +470,19 @@ def tool_information(request, tool_id, user_id, back):
         ),
         "back": back,
         "tool_control_show_task_details": ToolCustomization.get_bool("tool_control_show_task_details"),
+        "wait_list_position": user_wait_list_position,  # 0 if not in wait list
+        "wait_list": wait_list,
+        "show_wait_list": (
+            tool.allow_wait_list()
+            and (
+                not (
+                    tool.get_current_usage_event().operator.id == customer.id
+                    or tool.get_current_usage_event().user.id == customer.id
+                )
+                if tool.in_use()
+                else wait_list.count() > 0
+            )
+        ),
     }
     try:
         current_reservation = Reservation.objects.get(
@@ -433,3 +539,112 @@ def get_badge_reader(request) -> BadgeReader:
     except BadgeReader.DoesNotExist:
         badge_reader = BadgeReader.default()
     return badge_reader
+
+
+@login_required
+@permission_required("NEMO.kiosk")
+@require_POST
+def tool_report_problem(request, tool_id, user_id, back):
+    tool = Tool.objects.get(id=tool_id, visible=True)
+    customer = User.objects.get(id=user_id)
+
+    dictionary = {
+        "tool": tool,
+        "date": None,
+        "customer": customer,
+        "back": back,
+        "task_categories": TaskCategory.objects.filter(stage=TaskCategory.Stage.INITIAL_ASSESSMENT),
+        "task_statuses": TaskStatus.objects.all(),
+    }
+
+    return render(request, "kiosk/tool_report_problem.html", dictionary)
+
+
+@login_required
+@permission_required("NEMO.kiosk")
+@require_POST
+def report_problem(request):
+    tool = Tool.objects.get(id=request.POST["tool"])
+    customer = User.objects.get(id=request.POST["customer_id"])
+    back = request.POST["back"]
+
+    dictionary = {
+        "tool": tool,
+        "customer": customer,
+        "back": back,
+        "task_categories": TaskCategory.objects.filter(stage=TaskCategory.Stage.INITIAL_ASSESSMENT),
+        "task_statuses": TaskStatus.objects.all(),
+    }
+
+    """ Report a problem for a tool. """
+    form = TaskForm(request.user, data=request.POST)
+
+    try:
+        date = parse_date(request.POST["estimated_resolution_dt"])
+        estimated_resolution_time = localize(
+            datetime.combine(date, parse_time(request.POST["estimated_resolution_tm"]))
+        )
+    except:
+        estimated_resolution_time = None
+
+    if not form.is_valid():
+        errors = nice_errors(form)
+
+        dictionary["message"] = errors.as_ul()
+        dictionary["estimated_resolution_dt"] = request.POST.get("estimated_resolution_dt")
+        dictionary["estimated_resolution_tm"] = request.POST.get("estimated_resolution_tm")
+        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)
+
+    if save_error:
+        dictionary["message"] = save_error
+        dictionary["form"] = form
+        return render(request, "kiosk/tool_report_problem.html", dictionary)
+
+    return redirect("kiosk_tool_information", tool_id=tool.id, user_id=customer.id, back=back)
+
+
+@login_required
+@permission_required("NEMO.kiosk")
+@require_POST
+def tool_post_comment(request, tool_id, user_id, back):
+    tool = Tool.objects.get(id=tool_id, visible=True)
+    customer = User.objects.get(id=user_id)
+
+    dictionary = {
+        "tool": tool,
+        "date": None,
+        "customer": customer,
+        "back": back,
+    }
+
+    return render(request, "kiosk/tool_post_comment.html", dictionary)
+
+
+@login_required
+@permission_required("NEMO.kiosk")
+@require_POST
+def post_comment(request):
+    tool = Tool.objects.get(id=request.POST["tool"])
+    customer = User.objects.get(id=request.POST["customer_id"])
+    back = request.POST["back"]
+
+    dictionary = {"back": back, "tool": tool, "customer": customer}
+
+    """ Post a comment for a tool. """
+    form = CommentForm(request.POST)
+    if not form.is_valid():
+        dictionary["message"] = nice_errors(form).as_ul()
+        dictionary["form"] = form
+
+        return render(request, "kiosk/tool_post_comment.html", dictionary)
+
+    save_comment(request.user, form)
+
+    return redirect("kiosk_tool_information", tool_id=tool.id, user_id=customer.id, back=back)
diff --git a/NEMO/apps/sensors/sensors.py b/NEMO/apps/sensors/sensors.py
index 26e18c36b..d14f66d2f 100644
--- a/NEMO/apps/sensors/sensors.py
+++ b/NEMO/apps/sensors/sensors.py
@@ -77,7 +77,7 @@ def read_values(self, sensor: Sensor_model, raise_exception=False):
                     else:
                         continue
             data_value = self.evaluate_sensor(sensor, registers=registers)
-            if data_value:
+            if data_value is not None:
                 data = SensorData.objects.create(sensor=sensor, value=data_value)
                 process_alerts(sensor, data)
                 return data
diff --git a/NEMO/context_processors.py b/NEMO/context_processors.py
index 7282715cf..b12c3e360 100644
--- a/NEMO/context_processors.py
+++ b/NEMO/context_processors.py
@@ -12,6 +12,7 @@ def hide_logout_button(request):
 
 
 def base_context(request):
+    user: User = getattr(request, "user", None)
     try:
         if "no_header" in request.GET:
             if request.GET["no_header"] == "True":
@@ -37,7 +38,7 @@ def base_context(request):
     except:
         access_user_request_allowed_exist = False
     try:
-        notification_counts = get_notification_counts(request.user)
+        notification_counts = get_notification_counts(user)
     except:
         notification_counts = {}
     try:
diff --git a/NEMO/forms.py b/NEMO/forms.py
index 8d491504c..e9fe886bf 100644
--- a/NEMO/forms.py
+++ b/NEMO/forms.py
@@ -1,4 +1,7 @@
-from datetime import datetime
+from __future__ import annotations
+
+from datetime import datetime, time, timedelta
+from typing import Union
 
 from django.contrib.admin.widgets import FilteredSelectMultiple
 from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
@@ -17,12 +20,14 @@
 )
 from django.forms.utils import ErrorDict, ErrorList
 from django.utils import timezone
+from django.utils.translation import gettext_lazy as _
 
 from NEMO.models import (
     Account,
     AdjustmentRequest,
     Alert,
     AlertCategory,
+    Area,
     BuddyRequest,
     Comment,
     Consumable,
@@ -30,6 +35,7 @@
     Project,
     RecurringConsumableCharge,
     ReservationItemType,
+    Resource,
     SafetyIssue,
     ScheduledOutage,
     StaffAbsence,
@@ -41,7 +47,15 @@
     User,
     UserPreferences,
 )
-from NEMO.utilities import bootstrap_primary_color, format_datetime, quiet_int
+from NEMO.policy import policy_class as policy
+from NEMO.utilities import (
+    RecurrenceFrequency,
+    bootstrap_primary_color,
+    format_datetime,
+    get_recurring_rule,
+    localize,
+    quiet_int,
+)
 from NEMO.views.customization import UserRequestsCustomization
 
 
@@ -67,7 +81,7 @@ class Meta:
 class ProjectForm(ModelForm):
     class Meta:
         model = Project
-        exclude = ["only_allow_tools", "allow_consumable_withdrawals"]
+        exclude = ["only_allow_tools", "allow_consumable_withdrawals", "allow_staff_charges"]
 
     principal_investigators = ModelMultipleChoiceField(
         queryset=User.objects.all(),
@@ -378,13 +392,51 @@ def clean_category(self):
 
 
 class ScheduledOutageForm(ModelForm):
-    def __init__(self, *positional_arguments, **keyword_arguments):
-        super().__init__(*positional_arguments, **keyword_arguments)
+    send_reminders = BooleanField(required=False, initial=False)
+    recurring_outage = BooleanField(required=False, initial=False)
+    recurrence_interval = IntegerField(required=False)
+    recurrence_frequency = ChoiceField(choices=RecurrenceFrequency.choices(), required=False)
+    recurrence_until = DateField(required=False)
+
+    def clean(self):
+        cleaned_data = super().clean()
+        errors = {}
+        recurring_outage = cleaned_data.get("recurring_outage", False)
+        recurrence_until = cleaned_data.get("recurrence_until")
+        recurrence_frequency = cleaned_data.get("recurrence_frequency")
+        recurrence_interval = cleaned_data.get("recurrence_interval")
+        if recurring_outage:
+            if not recurrence_interval:
+                errors["recurrence_interval"] = _("This field is required.")
+            if not recurrence_frequency:
+                errors["recurrence_frequency"] = _("This field is required.")
+            if not recurrence_until:
+                errors["recurrence_until"] = _("This field is required.")
+        send_reminders = cleaned_data.get("send_reminders", False)
+        reminder_days = cleaned_data.get("reminder_days")
+        reminder_emails = cleaned_data.get("reminder_emails")
+        if send_reminders:
+            if not reminder_days:
+                errors["reminder_days"] = _("This field is required.")
+            if not reminder_emails:
+                errors["reminder_emails"] = _("This field is required.")
+        if errors:
+            raise ValidationError(errors)
+        return cleaned_data
+
+    class Meta:
+        model = ScheduledOutage
+        exclude = ["start", "end", "resource", "creator"]
+
+
+class ResourceScheduledOutageForm(ScheduledOutageForm):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
         self.fields["details"].required = True
 
     class Meta:
         model = ScheduledOutage
-        fields = ["details", "start", "end", "resource", "category"]
+        exclude = ["tool", "area", "creator", "title"]
 
 
 class UserPreferencesForm(ModelForm):
@@ -461,6 +513,57 @@ class Meta:
         fields = "__all__"
 
 
+def save_scheduled_outage(
+    form: ScheduledOutageForm,
+    creator: User,
+    item: Union[Resource, Tool, Area],
+    start: datetime = None,
+    end: datetime = None,
+    title: str = None,
+    check_policy=True,
+):
+    outage: ScheduledOutage = form.save(commit=False)
+    outage.creator = creator
+    outage.outage_item = item
+    if title:
+        outage.title = title
+    if start:
+        outage.start = start
+    if end:
+        outage.end = end
+    duration = outage.end - outage.start
+
+    if not form.cleaned_data.get("send_reminders"):
+        outage.reminder_days = None
+        outage.reminder_emails = None
+
+    # If there is a policy problem for the outage then return the error...
+    if check_policy:
+        policy_problem = policy.check_to_create_outage(outage)
+        if policy_problem:
+            return policy_problem
+
+    if form.cleaned_data.get("recurring_outage"):
+        # we have to remove tz before creating rules otherwise 8am would become 7am after DST change for example.
+        start_no_tz = outage.start.replace(tzinfo=None)
+
+        submitted_frequency = form.cleaned_data.get("recurrence_frequency")
+        submitted_date_until = form.cleaned_data["recurrence_until"]
+        date_until_no_tz = datetime.combine(submitted_date_until, time())
+        date_until_no_tz += timedelta(days=1, seconds=-1)  # set at the end of the day
+        frequency = RecurrenceFrequency(quiet_int(submitted_frequency, RecurrenceFrequency.DAILY.index))
+        rules = get_recurring_rule(
+            start_no_tz, frequency, date_until_no_tz, int(form.cleaned_data.get("recurrence_interval", 1))
+        )
+        for rule in list(rules):
+            outage.pk = None
+            outage.start = localize(start_no_tz.replace(year=rule.year, month=rule.month, day=rule.day))
+            outage.end = outage.start + duration
+            outage.save()
+    else:
+        outage.save()
+
+
 def nice_errors(obj, non_field_msg="General form errors") -> ErrorDict:
     result = ErrorDict()
     error_dict = (
diff --git a/NEMO/management/commands/check_and_update_wait_list.py b/NEMO/management/commands/check_and_update_wait_list.py
new file mode 100644
index 000000000..39f8f6af2
--- /dev/null
+++ b/NEMO/management/commands/check_and_update_wait_list.py
@@ -0,0 +1,13 @@
+from django.core.management import BaseCommand
+
+from NEMO.views.timed_services import do_check_and_update_wait_list
+
+
+class Command(BaseCommand):
+    help = (
+        "Run every minute to check and update tool wait list."
+        "Wait list notification email and slot time to expiration have to be set in customizations for this to work correctly."
+    )
+
+    def handle(self, *args, **options):
+        do_check_and_update_wait_list()
diff --git a/NEMO/management/commands/send_email_scheduled_outage_reminders.py b/NEMO/management/commands/send_email_scheduled_outage_reminders.py
new file mode 100644
index 000000000..ce8981144
--- /dev/null
+++ b/NEMO/management/commands/send_email_scheduled_outage_reminders.py
@@ -0,0 +1,10 @@
+from django.core.management import BaseCommand
+
+from NEMO.views.timed_services import send_email_scheduled_outage_reminders
+
+
+class Command(BaseCommand):
+    help = "Run every day to trigger sending email reminders for scheduled outages."
+
+    def handle(self, *args, **options):
+        send_email_scheduled_outage_reminders()
diff --git a/NEMO/migrations/0012_version_2_0_0_squashed.py b/NEMO/migrations/0012_version_2_0_0_squashed.py
index 89f8c4b5c..c7778c3a1 100644
--- a/NEMO/migrations/0012_version_2_0_0_squashed.py
+++ b/NEMO/migrations/0012_version_2_0_0_squashed.py
@@ -25,7 +25,7 @@ class Migration(migrations.Migration):
     ]
 
     dependencies = [
-        ("NEMO", "0011_version_1_22_0"),
+        ("NEMO", "0002_version_1_0_0_squashed"),
     ]
 
     def create_proxr_interlock_card_category(apps, schema_editor):
diff --git a/NEMO/migrations/0020_version_3_0_0.py b/NEMO/migrations/0020_version_3_0_0.py
index 18bac3a85..5316c22a3 100644
--- a/NEMO/migrations/0020_version_3_0_0.py
+++ b/NEMO/migrations/0020_version_3_0_0.py
@@ -9,7 +9,7 @@
 
 class Migration(migrations.Migration):
     dependencies = [
-        ("NEMO", "0019_user_type_not_required"),
+        ("NEMO", "0012_version_2_0_0_squashed"),
     ]
 
     def rebuild_area_tree(apps, schema_editor):
diff --git a/NEMO/migrations/0069_version_5_5_0.py b/NEMO/migrations/0069_version_5_5_0.py
new file mode 100644
index 000000000..c0b34334c
--- /dev/null
+++ b/NEMO/migrations/0069_version_5_5_0.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.25 on 2024-03-20 18:04
+
+from django.db import migrations
+
+from NEMO.migrations_utils import create_news_for_version
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("NEMO", "0068_door_multiple_areas"),
+    ]
+
+    def new_version_news(apps, schema_editor):
+        create_news_for_version(apps, "5.5.0")
+
+    operations = [
+        migrations.RunPython(new_version_news),
+    ]
diff --git a/NEMO/migrations/0070_calendar_axis_time_format_rename.py b/NEMO/migrations/0070_calendar_axis_time_format_rename.py
new file mode 100644
index 000000000..c5262816c
--- /dev/null
+++ b/NEMO/migrations/0070_calendar_axis_time_format_rename.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.2.25 on 2024-03-28 12:23
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("NEMO", "0069_version_5_5_0"),
+    ]
+
+    def migrate_calendar_axis_time_format(apps, schema_editor):
+        Customization = apps.get_model("NEMO", "Customization")
+        calendar_time_format = Customization.objects.filter(name="calendar_time_format").first()
+        if calendar_time_format:
+            calendar_time_format.name = "calendar_axis_time_format"
+            calendar_time_format.save()
+            Customization.objects.filter(name="calendar_time_format").delete()
+
+    def reverse_calendar_axis_time_format(apps, schema_editor):
+        Customization = apps.get_model("NEMO", "Customization")
+        calendar_axis_time_format = Customization.objects.filter(name="calendar_axis_time_format").first()
+        if calendar_axis_time_format:
+            calendar_axis_time_format.name = "calendar_time_format"
+            calendar_axis_time_format.save()
+            Customization.objects.filter(name="calendar_axis_time_format").delete()
+
+    operations = [
+        migrations.RunPython(migrate_calendar_axis_time_format, reverse_calendar_axis_time_format),
+    ]
diff --git a/NEMO/migrations/0071_wait_list.py b/NEMO/migrations/0071_wait_list.py
new file mode 100644
index 000000000..807500b73
--- /dev/null
+++ b/NEMO/migrations/0071_wait_list.py
@@ -0,0 +1,82 @@
+# Generated by Django 3.2.25 on 2024-04-04 00:11
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("NEMO", "0070_calendar_axis_time_format_rename"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="tool",
+            name="_operation_mode",
+            field=models.IntegerField(
+                choices=[(0, "Regular"), (1, "Wait List"), (2, "Hybrid")],
+                default=0,
+                help_text="The operation mode of the tool, which determines if reservations and wait list are allowed.",
+            ),
+        ),
+        migrations.AddField(
+            model_name="userpreferences",
+            name="email_send_wait_list_notification_emails",
+            field=models.PositiveIntegerField(
+                choices=[(1, "Both emails"), (2, "Main email only")], default=1, help_text="Tool wait list notification"
+            ),
+        ),
+        migrations.CreateModel(
+            name="ToolWaitList",
+            fields=[
+                ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                (
+                    "date_entered",
+                    models.DateTimeField(auto_now_add=True, help_text="The date/time the user entered the wait list."),
+                ),
+                (
+                    "date_exited",
+                    models.DateTimeField(
+                        blank=True, help_text="The date/time the user exited the wait list.", null=True
+                    ),
+                ),
+                (
+                    "last_turn_available_at",
+                    models.DateTimeField(
+                        blank=True, help_text="The last date/time the user's turn became available.", null=True
+                    ),
+                ),
+                (
+                    "expired",
+                    models.BooleanField(
+                        default=False, help_text="Whether the user's spot in the wait list has expired."
+                    ),
+                ),
+                (
+                    "deleted",
+                    models.BooleanField(default=False, help_text="Whether the wait list entry has been deleted."),
+                ),
+                (
+                    "tool",
+                    models.ForeignKey(
+                        help_text="The target tool for the wait list entry.",
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="NEMO.tool",
+                    ),
+                ),
+                (
+                    "user",
+                    models.ForeignKey(
+                        help_text="The user in the wait list.",
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+            ],
+            options={
+                "abstract": False,
+            },
+        ),
+    ]
diff --git a/NEMO/migrations/0072_documents_display_order.py b/NEMO/migrations/0072_documents_display_order.py
new file mode 100644
index 000000000..caedb1e8e
--- /dev/null
+++ b/NEMO/migrations/0072_documents_display_order.py
@@ -0,0 +1,97 @@
+# Generated by Django 3.2.25 on 2024-04-08 11:57
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("NEMO", "0071_wait_list"),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name="projectdocuments",
+            options={"ordering": ["display_order", "-uploaded_at"], "verbose_name_plural": "Project documents"},
+        ),
+        migrations.AlterModelOptions(
+            name="safetyitemdocuments",
+            options={"ordering": ["display_order", "-uploaded_at"], "verbose_name_plural": "Safety item documents"},
+        ),
+        migrations.AlterModelOptions(
+            name="staffknowledgebaseitemdocuments",
+            options={
+                "ordering": ["display_order", "-uploaded_at"],
+                "verbose_name_plural": "Staff knowledge base item documents",
+            },
+        ),
+        migrations.AlterModelOptions(
+            name="tooldocuments",
+            options={"ordering": ["display_order", "-uploaded_at"], "verbose_name_plural": "Tool documents"},
+        ),
+        migrations.AlterModelOptions(
+            name="userdocuments",
+            options={"ordering": ["display_order", "-uploaded_at"], "verbose_name_plural": "User documents"},
+        ),
+        migrations.AlterModelOptions(
+            name="userknowledgebaseitemdocuments",
+            options={
+                "ordering": ["display_order", "-uploaded_at"],
+                "verbose_name_plural": "User knowledge base item documents",
+            },
+        ),
+        migrations.AddField(
+            model_name="projectdocuments",
+            name="display_order",
+            field=models.IntegerField(
+                default=1,
+                help_text="The order in which choices are displayed on the landing page, from left to right, top to bottom. Lower values are displayed first.",
+            ),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name="safetyitemdocuments",
+            name="display_order",
+            field=models.IntegerField(
+                default=1,
+                help_text="The order in which choices are displayed on the landing page, from left to right, top to bottom. Lower values are displayed first.",
+            ),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name="staffknowledgebaseitemdocuments",
+            name="display_order",
+            field=models.IntegerField(
+                default=1,
+                help_text="The order in which choices are displayed on the landing page, from left to right, top to bottom. Lower values are displayed first.",
+            ),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name="tooldocuments",
+            name="display_order",
+            field=models.IntegerField(
+                default=1,
+                help_text="The order in which choices are displayed on the landing page, from left to right, top to bottom. Lower values are displayed first.",
+            ),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name="userdocuments",
+            name="display_order",
+            field=models.IntegerField(
+                default=1,
+                help_text="The order in which choices are displayed on the landing page, from left to right, top to bottom. Lower values are displayed first.",
+            ),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name="userknowledgebaseitemdocuments",
+            name="display_order",
+            field=models.IntegerField(
+                default=1,
+                help_text="The order in which choices are displayed on the landing page, from left to right, top to bottom. Lower values are displayed first.",
+            ),
+            preserve_default=False,
+        ),
+    ]
diff --git a/NEMO/migrations/0073_knowledge_safety_items_display_order.py b/NEMO/migrations/0073_knowledge_safety_items_display_order.py
new file mode 100644
index 000000000..1efa21df8
--- /dev/null
+++ b/NEMO/migrations/0073_knowledge_safety_items_display_order.py
@@ -0,0 +1,52 @@
+# Generated by Django 3.2.25 on 2024-04-08 15:37
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("NEMO", "0072_documents_display_order"),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name="safetyitem",
+            options={"ordering": ["display_order", "name"]},
+        ),
+        migrations.AlterModelOptions(
+            name="staffknowledgebaseitem",
+            options={"ordering": ["display_order", "name"]},
+        ),
+        migrations.AlterModelOptions(
+            name="userknowledgebaseitem",
+            options={"ordering": ["display_order", "name"]},
+        ),
+        migrations.AddField(
+            model_name="safetyitem",
+            name="display_order",
+            field=models.IntegerField(
+                default=1,
+                help_text="The order in which the items will be displayed within the same category. Lower values are displayed first.",
+            ),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name="staffknowledgebaseitem",
+            name="display_order",
+            field=models.IntegerField(
+                default=1,
+                help_text="The order in which the items will be displayed within the same category. Lower values are displayed first.",
+            ),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name="userknowledgebaseitem",
+            name="display_order",
+            field=models.IntegerField(
+                default=1,
+                help_text="The order in which the items will be displayed within the same category. Lower values are displayed first.",
+            ),
+            preserve_default=False,
+        ),
+    ]
diff --git a/NEMO/migrations/0074_scheduled_outage_reminders.py b/NEMO/migrations/0074_scheduled_outage_reminders.py
new file mode 100644
index 000000000..395b4ece9
--- /dev/null
+++ b/NEMO/migrations/0074_scheduled_outage_reminders.py
@@ -0,0 +1,45 @@
+# Generated by Django 3.2.25 on 2024-04-05 14:42
+
+import re
+
+import django.core.validators
+from django.db import migrations, models
+
+import NEMO.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("NEMO", "0073_knowledge_safety_items_display_order"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            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=200,
+                null=True,
+                validators=[
+                    django.core.validators.RegexValidator(
+                        re.compile("^\\d+(?:,\\d+)*\\Z"),
+                        code="invalid",
+                        message="Enter only digits separated by commas.",
+                    )
+                ],
+            ),
+        ),
+        migrations.AddField(
+            model_name="scheduledoutage",
+            name="reminder_emails",
+            field=NEMO.fields.MultiEmailField(
+                blank=True,
+                help_text="The reminder email(s) will be sent to this address. A comma-separated list can be used.",
+                max_length=2000,
+                null=True,
+            ),
+        ),
+    ]
diff --git a/NEMO/migrations/0075_adjustmentrequest_applied.py b/NEMO/migrations/0075_adjustmentrequest_applied.py
new file mode 100644
index 000000000..4598938b2
--- /dev/null
+++ b/NEMO/migrations/0075_adjustmentrequest_applied.py
@@ -0,0 +1,31 @@
+# Generated by Django 3.2.25 on 2024-04-15 14:10
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("NEMO", "0074_scheduled_outage_reminders"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="adjustmentrequest",
+            name="applied",
+            field=models.BooleanField(default=False, help_text="Indicates the adjustment has been applied"),
+        ),
+        migrations.AddField(
+            model_name="adjustmentrequest",
+            name="applied_by",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="adjustment_requests_applied",
+                to=settings.AUTH_USER_MODEL,
+            ),
+        ),
+    ]
diff --git a/NEMO/models.py b/NEMO/models.py
index f35abf60f..bb043ee7c 100644
--- a/NEMO/models.py
+++ b/NEMO/models.py
@@ -17,7 +17,7 @@
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
-from django.core.validators import MinValueValidator
+from django.core.validators import MinValueValidator, validate_comma_separated_integer_list
 from django.db import connections, models, transaction
 from django.db.models import Q
 from django.db.models.manager import Manager
@@ -155,6 +155,9 @@ class BaseDocumentModel(BaseModel):
     name = models.CharField(
         null=True, blank=True, max_length=200, help_text="The optional name to display for this document"
     )
+    display_order = models.IntegerField(
+        help_text="The order in which choices are displayed on the landing page, from left to right, top to bottom. Lower values are displayed first."
+    )
     uploaded_at = models.DateTimeField(auto_now_add=True)
 
     def get_filename_upload(self, filename):
@@ -190,7 +193,7 @@ def clean(self):
             raise ValidationError({"document": "Choose either document or URL but not both."})
 
     class Meta:
-        ordering = ["-uploaded_at"]
+        ordering = ["display_order", "-uploaded_at"]
         abstract = True
 
 
@@ -362,6 +365,11 @@ class UserPreferences(BaseModel):
         choices=EmailNotificationType.on_choices(),
         help_text="Tool qualification expiration reminders",
     )
+    email_send_wait_list_notification_emails = models.PositiveIntegerField(
+        default=EmailNotificationType.BOTH_EMAILS,
+        choices=EmailNotificationType.on_choices(),
+        help_text="Tool wait list notification",
+    )
     email_send_usage_reminders = models.PositiveIntegerField(
         default=EmailNotificationType.BOTH_EMAILS, choices=EmailNotificationType.Choices, help_text="Usage reminders"
     )
@@ -1093,6 +1101,12 @@ class Meta(BaseDocumentModel.Meta):
 
 
 class Tool(SerializationByNameModel):
+    class OperationMode(object):
+        REGULAR = 0
+        WAIT_LIST = 1
+        HYBRID = 2
+        Choices = ((REGULAR, "Regular"), (WAIT_LIST, "Wait List"), (HYBRID, "Hybrid"))
+
     name = models.CharField(max_length=100, unique=True)
     parent_tool = models.ForeignKey(
         "Tool",
@@ -1291,6 +1305,11 @@ class Tool(SerializationByNameModel):
         default=False,
         help_text="Whether or not policy rules should be enforced on weekends",
     )
+    _operation_mode = models.IntegerField(
+        choices=OperationMode.Choices,
+        default=OperationMode.REGULAR,
+        help_text="The operation mode of the tool, which determines if reservations and wait list are allowed.",
+    )
 
     class Meta:
         ordering = ["name"]
@@ -1626,6 +1645,27 @@ def tool_calendar_color(self, value):
         self.raise_setter_error_if_child_tool("tool_calendar_color")
         self._tool_calendar_color = value
 
+    @property
+    def operation_mode(self):
+        return self.parent_tool.operation_mode if self.is_child_tool() else self._operation_mode
+
+    @operation_mode.setter
+    def operation_mode(self, value):
+        self.raise_setter_error_if_child_tool("operation_mode")
+        self._operation_mode = value
+
+    def allow_wait_list(self):
+        return self.operation_mode in [self.OperationMode.WAIT_LIST, self.OperationMode.HYBRID]
+
+    def allow_reservation(self):
+        return self.operation_mode in [self.OperationMode.REGULAR, self.OperationMode.HYBRID]
+
+    def current_wait_list(self):
+        return ToolWaitList.objects.filter(tool=self, expired=False, deleted=False).order_by("date_entered")
+
+    def top_wait_list_entry(self):
+        return self.current_wait_list().first()
+
     def name_or_child_in_use_name(self, parent_ids=None) -> str:
         """This method returns the tool name unless one of its children is in use."""
         """ When used in loops, provide the parent_ids list to avoid unnecessary db calls """
@@ -1925,6 +1965,22 @@ def save(self, force_insert=False, force_update=False, using=None, update_fields
         super().save(force_insert, force_update, using, update_fields)
 
 
+class ToolWaitList(BaseModel):
+    user = models.ForeignKey(
+        User,
+        on_delete=models.CASCADE,
+        help_text="The user in the wait list.",
+    )
+    tool = models.ForeignKey(Tool, on_delete=models.CASCADE, help_text="The target tool for the wait list entry.")
+    date_entered = models.DateTimeField(auto_now_add=True, help_text="The date/time the user entered the wait list.")
+    date_exited = models.DateTimeField(null=True, blank=True, help_text="The date/time the user exited the wait list.")
+    last_turn_available_at = models.DateTimeField(
+        null=True, blank=True, help_text="The last date/time the user's turn became available."
+    )
+    expired = models.BooleanField(default=False, help_text="Whether the user's spot in the wait list has expired.")
+    deleted = models.BooleanField(default=False, help_text="Whether the wait list entry has been deleted.")
+
+
 class ToolDocuments(BaseDocumentModel):
     tool = models.ForeignKey(Tool, on_delete=models.CASCADE)
 
@@ -3602,10 +3658,16 @@ class SafetyItem(BaseModel):
     category = models.ForeignKey(
         SafetyCategory, null=True, blank=True, help_text="The category for this safety item.", on_delete=models.SET_NULL
     )
+    display_order = models.IntegerField(
+        help_text="The order in which the items will be displayed within the same category. Lower values are displayed first."
+    )
 
     def __str__(self):
         return self.name
 
+    class Meta:
+        ordering = ["display_order", "name"]
+
 
 class SafetyItemDocuments(BaseDocumentModel):
     safety_item = models.ForeignKey(SafetyItem, on_delete=models.CASCADE)
@@ -3659,6 +3721,10 @@ class Alert(BaseModel):
         default=False, help_text="Indicates the alert has been deleted and won't be shown anymore"
     )
 
+    def clean(self):
+        if self.dismissible and not self.user:
+            raise ValidationError({"dismissible": "Only a user-specific alert can be dismissed by the user"})
+
     class Meta:
         ordering = ["-debut_time"]
 
@@ -3821,13 +3887,22 @@ class ScheduledOutage(BaseModel):
     tool = models.ForeignKey(Tool, blank=True, null=True, on_delete=models.CASCADE)
     area = TreeForeignKey(Area, blank=True, null=True, on_delete=models.CASCADE)
     resource = models.ForeignKey(Resource, blank=True, null=True, on_delete=models.CASCADE)
+    reminder_days = models.CharField(
+        null=True,
+        blank=True,
+        max_length=200,
+        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.",
+    )
+    reminder_emails: List[str] = fields.MultiEmailField(
+        null=True,
+        blank=True,
+        help_text="The reminder email(s) will be sent to this address. A comma-separated list can be used.",
+    )
 
     @property
-    def outage_item(self) -> Union[Tool, Area]:
-        if self.tool:
-            return self.tool
-        elif self.area:
-            return self.area
+    def outage_item(self) -> Union[Tool, Area, Resource]:
+        return self.tool or self.area or self.resource
 
     @outage_item.setter
     def outage_item(self, item):
@@ -3835,6 +3910,8 @@ def outage_item(self, item):
             self.tool = item
         elif isinstance(item, Area):
             self.area = item
+        elif isinstance(item, Resource):
+            self.resource = item
         else:
             raise AttributeError(f"This item [{item}] isn't allowed on outages.")
 
@@ -3858,6 +3935,11 @@ def has_not_ended(self):
     def has_not_started(self):
         return False if self.start <= timezone.now() else True
 
+    def get_reminder_days(self) -> List[int]:
+        if not self.reminder_emails:
+            return []
+        return [int(days) for days in self.reminder_days.split(",")]
+
     def clean(self):
         if self.start and self.end and self.start >= self.end:
             raise ValidationError(
@@ -4054,6 +4136,10 @@ class AdjustmentRequest(BaseModel):
     reviewer = models.ForeignKey(
         "User", null=True, blank=True, related_name="adjustment_requests_reviewed", on_delete=models.CASCADE
     )
+    applied = models.BooleanField(default=False, help_text="Indicates the adjustment has been applied")
+    applied_by = models.ForeignKey(
+        "User", null=True, blank=True, related_name="adjustment_requests_applied", on_delete=models.CASCADE
+    )
     deleted = models.BooleanField(
         default=False, help_text="Indicates the request has been deleted and won't be shown anymore."
     )
@@ -4121,6 +4207,19 @@ def reviewers(self) -> QuerySetType[User]:
             return area_reviewers or facility_managers
         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()
+
     def delete(self, using=None, keep_parents=False):
         adjustment_id = self.id
         super().delete(using, keep_parents)
@@ -4368,10 +4467,16 @@ class StaffKnowledgeBaseItem(BaseModel):
         help_text="The category for this item.",
         on_delete=models.SET_NULL,
     )
+    display_order = models.IntegerField(
+        help_text="The order in which the items will be displayed within the same category. Lower values are displayed first."
+    )
 
     def __str__(self):
         return self.name
 
+    class Meta:
+        ordering = ["display_order", "name"]
+
 
 class StaffKnowledgeBaseItemDocuments(BaseDocumentModel):
     item = models.ForeignKey(StaffKnowledgeBaseItem, on_delete=models.CASCADE)
@@ -4401,10 +4506,16 @@ class UserKnowledgeBaseItem(BaseModel):
         help_text="The category for this item.",
         on_delete=models.SET_NULL,
     )
+    display_order = models.IntegerField(
+        help_text="The order in which the items will be displayed within the same category. Lower values are displayed first."
+    )
 
     def __str__(self):
         return self.name
 
+    class Meta:
+        ordering = ["display_order", "name"]
+
 
 class UserKnowledgeBaseItemDocuments(BaseDocumentModel):
     item = models.ForeignKey(UserKnowledgeBaseItem, on_delete=models.CASCADE)
diff --git a/NEMO/policy.py b/NEMO/policy.py
index b1bf18d7e..9bc5074ca 100644
--- a/NEMO/policy.py
+++ b/NEMO/policy.py
@@ -224,6 +224,10 @@ def check_to_save_reservation(
 
         item_type = new_reservation.reservation_item_type
 
+        # Tool is not operating in wait list  mode
+        if new_reservation.tool and not new_reservation.tool.allow_reservation():
+            policy_problems.append("This tool is operating in wait list mode.")
+
         # Reservations may not have a start time that is earlier than the end time.
         if new_reservation.start >= new_reservation.end:
             policy_problems.append(
diff --git a/NEMO/serializers.py b/NEMO/serializers.py
index e497242be..4f8ddc921 100644
--- a/NEMO/serializers.py
+++ b/NEMO/serializers.py
@@ -20,6 +20,8 @@
 from NEMO.models import (
     Account,
     AccountType,
+    Alert,
+    AlertCategory,
     Area,
     AreaAccessRecord,
     Configuration,
@@ -102,6 +104,22 @@ def full_clean(self, instance, exclude=None, validate_unique=True):
         instance.full_clean(exclude, validate_unique)
 
 
+class AlertCategorySerializer(serializers.ModelSerializer):
+    class Meta:
+        model = AlertCategory
+        fields = "__all__"
+
+
+class AlertSerializer(FlexFieldsSerializerMixin, ModelSerializer):
+    class Meta:
+        model = Alert
+        fields = "__all__"
+        expandable_fields = {
+            "creator": "NEMO.serializers.UserSerializer",
+            "user": "NEMO.serializers.UserSerializer",
+        }
+
+
 class UserSerializer(FlexFieldsSerializerMixin, ModelSerializer):
     class Meta:
         model = User
diff --git a/NEMO/templates/alerts.html b/NEMO/templates/alerts.html
index c3a63deaa..7cf071dff 100644
--- a/NEMO/templates/alerts.html
+++ b/NEMO/templates/alerts.html
@@ -10,7 +10,7 @@
     <h1>Alerts</h1>
     {% if form.non_field_errors %}<div class="alert alert-danger">{{ form.non_field_errors }}</div>{% endif %}
     <p>
-        You can create and edit alerts that will be visible to everyone. The alert will only be visible after the debut time and will be deleted after the expiration time.
+        You can create and edit alerts that will be visible to everyone. The alert will only be visible after the debut time and will be deleted after the expiration time. (HTML syntax is allowed).
     </p>
     <form action="{% url 'alerts' %}" method="post" class="well">
         <h4>
@@ -109,7 +109,9 @@ <h4>
                         <span style="font-weight:bold">{{ a.title }}</span>
                         <br>
                     {% endif %}
-                    {{ a.contents|linebreaks }}
+                    {{ a.contents|safe|linebreaksbr }}
+                    <br>
+                    <br>
                     Debuts on {{ a.debut_time }}
                     <br>
                     {% if a.expiration_time %}
@@ -130,6 +132,6 @@ <h4>
 		$('#debut_time').datetimepicker(timepicker_properties);
 		$('#expiration_time').datetimepicker(timepicker_properties);
 		$('.glyphicon').tooltip({container: 'body'});
-	
+
     </script>
 {% endblock %}
diff --git a/NEMO/templates/calendar/calendar.html b/NEMO/templates/calendar/calendar.html
index 35019046d..3085973f4 100644
--- a/NEMO/templates/calendar/calendar.html
+++ b/NEMO/templates/calendar/calendar.html
@@ -569,22 +569,25 @@
 		"views": {
 			"day": {
 				"titleFormat": ' ',
-				"columnFormat": '{{ calendar_day_column_format }}'
+				"columnFormat": '{{ calendar_day_column_format }}',
+				"timeFormat": '{{ calendar_day_time_format }}'
 			},
 			"week": {
 				"titleFormat": ' ',
-				"columnFormat": '{{ calendar_week_column_format }}'
+				"columnFormat": '{{ calendar_week_column_format }}',
+				"timeFormat": '{{ calendar_week_time_format }}'
 			},
 			"month": {
 				"titleFormat": 'MMMM YYYY',
-				"columnFormat": '{{ calendar_month_column_format }}'
+				"columnFormat": '{{ calendar_month_column_format }}',
+				"timeFormat": '{{ calendar_month_time_format }}'
 			}
 		},
 		"firstDay": '{{ calendar_first_day_of_week }}',
 		"scrollTime": '{{ calendar_start_of_the_day }}',
 		"defaultView": '{{ calendar_view }}',
 		"nowIndicator": '{{ calendar_now_indicator }}',
-        "slotLabelFormat": '{{ calendar_time_format }}',
+        "slotLabelFormat": '{{ calendar_axis_time_format }}',
 		"allDaySlot": false,
 		"allDayDefault": false,
 		"slotDuration": '00:15:00', {# 15 minutes each row #}
diff --git a/NEMO/templates/calendar/scheduled_outage_information.html b/NEMO/templates/calendar/scheduled_outage_information.html
index e8d8bec89..dc23f73b2 100644
--- a/NEMO/templates/calendar/scheduled_outage_information.html
+++ b/NEMO/templates/calendar/scheduled_outage_information.html
@@ -14,24 +14,44 @@ <h4 id="modal-description-label" class="modal-title">Schedule an outage</h4>
     {# Hiding the dialog using the X in the top right, or clicking outside of it, will stop processing. #}
     <input type="hidden" id="dialog_cancelled" value="true">
     <form id="additional_event_parameters" onsubmit="return false">
-        <input type="hidden" id="configured" name="configured" value="true">
+        <input type="hidden" id="outage_submitted" name="outage_submitted" value="true">
+        {% if outage_submitted %}
+            {% if form.errors %}
+                <div class="col-sm-12">
+                    <div class="alert alert-danger">
+                        Oops! Something went wrong. Please correct the errors highlighted below.
+                        <br>
+                        {{ form.non_field_errors }}
+                    </div>
+                </div>
+            {% endif %}
+        {% endif %}
         <div class="form-group col-sm-12">
             <label for="title">Title</label>
+            {% if outage_submitted and form.title.errors %}
+                <span class="pull-right danger-highlight">{{ form.title.errors|striptags }}</span>
+            {% endif %}
             <input aria-labelledby="outage_title_help"
                    id="title"
                    name="title"
                    type="text"
                    maxlength="100"
                    class="form-control"
+                   value="{{ form.title.value|default_if_none:'' }}"
                    autocomplete="off">
             <div id="outage_title_help" class="light-grey">You must provide a title to create a scheduled outage</div>
         </div>
         {% if categories %}
             <div class="form-group col-sm-12">
                 <label for="category">Category</label>
+                {% if outage_submitted and form.category.errors %}
+                    <span class="pull-right danger-highlight">{{ form.category.errors|striptags }}</span>
+                {% endif %}
                 <select aria-labelledby="outage_category_help" class="form-control" name="category" id="category">
                     <option></option>
-                    {% for c in categories %}<option>{{ c }}</option>{% endfor %}
+                    {% for c in categories %}
+                        <option {% if form.category.value == c %}selected{% endif %}>{{ c }}</option>
+                    {% endfor %}
                 </select>
                 <div id="outage_category_help" class="light-grey">
                     Is there a category for this outage? This is useful for data and trend analysis
@@ -40,45 +60,125 @@ <h4 id="modal-description-label" class="modal-title">Schedule an outage</h4>
         {% endif %}
         <div class="form-group col-sm-12">
             <label for="details">Details</label>
+            {% if outage_submitted and form.details.errors %}
+                <span class="pull-right danger-highlight">{{ form.details.errors|striptags }}</span>
+            {% endif %}
             <textarea aria-labelledby="outage_details_help"
                       name="details"
                       id="details"
                       rows="7"
                       maxlength="3000"
-                      class="form-control"></textarea>
+                      class="form-control">{{ form.details.value|default_if_none:"" }}</textarea>
             <div id="outage_details_help" class="light-grey">
                 Please provide any additional details for the scheduled outage. Be descriptive so that users understand why there is a scheduled outage.
             </div>
         </div>
         <div class="col-sm-12">
-            <a onclick="toggle_details(this)"
+            <a id="reminder_outage_link"
+               onclick="toggle_details(this)"
+               data-toggle="collapse"
+               data-target="#reminder_section"
+               class="pointer"
+               style="text-decoration: none">
+                <input type="hidden" name="send_reminders" value="" />
+                <input type="checkbox"
+                       id="send_reminders"
+                       name="send_reminders"
+                       {% if form.send_reminders.value %}checked{% endif %} />
+            </a>
+            <label for="send_reminders" style="padding-left: 5px">Send reminder(s) for this outage</label>
+        </div>
+        <div id="reminder_section"
+             class="collapse {{ form.send_reminders.value|yesno:"in," }} col-sm-12"
+             style="margin: 0 auto">
+            <div class="form-group">
+                <label for="reminder_days">Reminder day(s)</label>
+                {% if outage_submitted and form.reminder_days.errors %}
+                    <span class="pull-right danger-highlight">{{ form.reminder_days.errors|striptags }}</span>
+                {% endif %}
+                <input aria-labelledby="outage_reminder_days_help"
+                       id="reminder_days"
+                       name="reminder_days"
+                       type="text"
+                       maxlength="2000"
+                       class="form-control"
+                       value="{{ form.reminder_days.value|default_if_none:'' }}"
+                       autocomplete="off">
+                <div id="outage_reminder_days_help" class="light-grey">
+                    The number of day(s) prior to the start of the outage to send the reminder on
+                </div>
+            </div>
+            <div class="form-group">
+                <label for="reminder_emails">Reminder email(s)</label>
+                {% if outage_submitted and form.reminder_emails.errors %}
+                    <span class="pull-right danger-highlight">{{ form.reminder_emails.errors|striptags }}</span>
+                {% endif %}
+                <input aria-labelledby="outage_reminder_emails_help"
+                       id="reminder_emails"
+                       name="reminder_emails"
+                       type="text"
+                       maxlength="2000"
+                       class="form-control"
+                       value="{{ form.reminder_emails.value|default_if_none:'' }}"
+                       autocomplete="off">
+                <div id="outage_reminder_emails_help" class="light-grey">The email(s) to send the reminder to</div>
+            </div>
+        </div>
+        <div class="col-sm-12">
+            <a id="recurring_outage_link"
+               onclick="toggle_details(this)"
                data-toggle="collapse"
                data-target="#recurrence"
                class="pointer"
                style="text-decoration: none">
-                <input type="checkbox" id="recurring_outage" name="recurring_outage" />
+                <input type="hidden" name="recurring_outage" value="" />
+                <input type="checkbox"
+                       id="recurring_outage"
+                       name="recurring_outage"
+                       {% if form.recurring_outage.value %}checked{% endif %} />
             </a>
             <label for="recurring_outage" style="padding-left: 5px">Create more than one occurrence of this outage</label>
         </div>
-        <div id="recurrence" class="collapse col-sm-12" style="margin: 0 auto">
-            <div class="col-sm-12">
+        <div id="recurrence"
+             class="collapse {{ form.recurring_outage.value|yesno:"in," }} col-sm-12"
+             style="margin: 0 auto">
+            <div class="row">
                 <div class="form-group col-sm-2">
                     <label for="recurrence_interval">Every</label>
-                    <input id="recurrence_interval" name="recurrence_interval" type="number" class="form-control" value="1" />
+                    <input id="recurrence_interval"
+                           name="recurrence_interval"
+                           type="number"
+                           class="form-control"
+                           value="{{ form.recurrence_interval.value|default_if_none:'1' }}" />
+                    {% if outage_submitted and form.recurrence_interval.errors %}
+                        <span class="pull-right danger-highlight">{{ form.recurrence_interval.errors|striptags }}</span>
+                    {% endif %}
                 </div>
                 <div class="form-group col-sm-6">
                     <label for="recurrence_frequency">Frequency</label>
                     <select class="form-control" name="recurrence_frequency" id="recurrence_frequency">
-                        {% for choice in recurrence_intervals %}<option value="{{ choice.0 }}">{{ choice.1 }}</option>{% endfor %}
+                        {% for choice in recurrence_intervals %}
+                            <option value="{{ choice.0 }}"
+                                    {% if form.recurrence_frequency.value == choice.0|stringformat:'i' %}selected{% endif %}>
+                                {{ choice.1 }}
+                            </option>
+                        {% endfor %}
                     </select>
+                    {% if outage_submitted and form.recurrence_frequency.errors %}
+                        <span class="pull-right danger-highlight">{{ form.recurrence_frequency.errors|striptags }}</span>
+                    {% endif %}
                 </div>
                 <div class="form-group col-sm-4">
-                    <label for="recurrence_until">Until:</label>
+                    <label for="recurrence_until">Until</label>
                     <input type="text"
                            id="recurrence_until"
                            name="recurrence_until"
                            class="form-control select-date"
+                           value="{{ form.recurrence_until.value|input_date_format }}"
                            placeholder="Choose a date">
+                    {% if outage_submitted and form.recurrence_until.errors %}
+                        <span class="pull-right danger-highlight">{{ form.recurrence_until.errors|striptags }}</span>
+                    {% endif %}
                 </div>
             </div>
         </div>
diff --git a/NEMO/templates/customizations/customizations_calendar.html b/NEMO/templates/customizations/customizations_calendar.html
index 136f3ab44..030e4c219 100644
--- a/NEMO/templates/customizations/customizations_calendar.html
+++ b/NEMO/templates/customizations/customizations_calendar.html
@@ -177,16 +177,18 @@ <h3 class="customization-section-title">Calendar format</h3>
             </div>
         </div>
         <div class="form-group">
-            <label class="control-label col-md-3" for="calendar_time_format">Time format</label>
+            <label class="control-label col-md-3" for="calendar_axis_time_format">Axis time format</label>
             <div class="col-md-3">
                 <input type="text"
-                       id="calendar_time_format"
-                       name="calendar_time_format"
+                       id="calendar_axis_time_format"
+                       name="calendar_axis_time_format"
                        class="form-control"
-                       value="{{ calendar_time_format }}"
+                       value="{{ calendar_axis_time_format }}"
                        placeholder="ha">
             </div>
-            <div class="col-md-6 help-block light-grey">The time format for the calendar.</div>
+            <div class="col-md-6 help-block light-grey">
+                The time format for the calendar axis labels  (only applies to day and week view).
+            </div>
         </div>
         <div class="form-group">
             <label class="control-label col-md-3" for="calendar_day_column_format">Day column format</label>
@@ -200,6 +202,18 @@ <h3 class="customization-section-title">Calendar format</h3>
             </div>
             <div class="col-md-6 help-block light-grey">The column date format for the day view.</div>
         </div>
+        <div class="form-group">
+            <label class="control-label col-md-3" for="calendar_day_time_format">Day time format</label>
+            <div class="col-md-3">
+                <input type="text"
+                       id="calendar_day_time_format"
+                       name="calendar_day_time_format"
+                       class="form-control"
+                       value="{{ calendar_day_time_format }}"
+                       placeholder="h:mm">
+            </div>
+            <div class="col-md-6 help-block light-grey">The reservation time format for the day view.</div>
+        </div>
         <div class="form-group">
             <label class="control-label col-md-3" for="calendar_week_column_format">Week column format</label>
             <div class="col-md-3">
@@ -212,6 +226,18 @@ <h3 class="customization-section-title">Calendar format</h3>
             </div>
             <div class="col-md-6 help-block light-grey">The column date format for the week view.</div>
         </div>
+        <div class="form-group">
+            <label class="control-label col-md-3" for="calendar_week_time_format">Week time format</label>
+            <div class="col-md-3">
+                <input type="text"
+                       id="calendar_week_time_format"
+                       name="calendar_week_time_format"
+                       class="form-control"
+                       value="{{ calendar_week_time_format }}"
+                       placeholder="h:mm">
+            </div>
+            <div class="col-md-6 help-block light-grey">The reservation time format for the week view.</div>
+        </div>
         <div class="form-group">
             <label class="control-label col-md-3" for="calendar_month_column_format">Month column format</label>
             <div class="col-md-3">
@@ -224,6 +250,18 @@ <h3 class="customization-section-title">Calendar format</h3>
             </div>
             <div class="col-md-6 help-block light-grey">The column date format for the month view.</div>
         </div>
+        <div class="form-group">
+            <label class="control-label col-md-3" for="calendar_month_time_format">Month time format</label>
+            <div class="col-md-3">
+                <input type="text"
+                       id="calendar_month_time_format"
+                       name="calendar_month_time_format"
+                       class="form-control"
+                       value="{{ calendar_month_time_format }}"
+                       placeholder="h(:mm)t">
+            </div>
+            <div class="col-md-6 help-block light-grey">The reservation time format for the month view.</div>
+        </div>
         <div class="customization-separation" style="margin-bottom: 15px"></div>
         <h3 class="customization-section-title">Reservation confirmations</h3>
         <div class="form-group">
diff --git a/NEMO/templates/customizations/customizations_requests.html b/NEMO/templates/customizations/customizations_requests.html
index 4fca0e8d5..6f517fce0 100644
--- a/NEMO/templates/customizations/customizations_requests.html
+++ b/NEMO/templates/customizations/customizations_requests.html
@@ -192,7 +192,7 @@ <h3 class="customization-section-title">Adjustment requests settings</h3>
             </div>
         </div>
         <div class="form-group">
-            <label class="control-label col-md-4">Edit approved charges</label>
+            <label class="control-label col-md-4">Approved charges</label>
             <div class="col-md-8">
                 <div class="checkbox">
                     <label>
@@ -202,6 +202,14 @@ <h3 class="customization-section-title">Adjustment requests settings</h3>
                                value="enabled">
                         Check this box to show a link to edit the original charge after approval
                     </label>
+                    <br>
+                    <label>
+                        <input type="checkbox"
+                               name="adjustment_requests_apply_button"
+                               {% if adjustment_requests_apply_button %}checked{% endif %}
+                               value="enabled">
+                        Check this box to allow reviewers to adjust the charge when approving an adjustment
+                    </label>
                     <br />
                 </div>
             </div>
diff --git a/NEMO/templates/customizations/customizations_templates.html b/NEMO/templates/customizations/customizations_templates.html
index 52d4a7b78..7a9378c5c 100644
--- a/NEMO/templates/customizations/customizations_templates.html
+++ b/NEMO/templates/customizations/customizations_templates.html
@@ -123,6 +123,24 @@ <h3 class="customization-section-title" id="adjustment_request_notification_emai
         {% include 'customizations/customizations_upload.html' with element=adjustment_request_notification_email name='adjustment request notification email' key='templates' %}
         <div class="customization-separation"></div>
     </div>
+    <div class="panel-body">
+        <h3 class="customization-section-title" id="wait_list_notification_email_id">Tool wait list notification email</h3>
+        <p>This email is sent to a person in a wait list when their turn to use the tool has arrived.</p>
+        <p>The following context variables are provided when the email is rendered:</p>
+        <ul>
+            <li>
+                <b>user</b> - the user in the wait list
+            </li>
+            <li>
+                <b>tool</b> - the tool that the user is in the wait list for
+            </li>
+            <li>
+                <b>time_to_expiration</b> - the time left for the user to use the tool
+            </li>
+        </ul>
+        {% include 'customizations/customizations_upload.html' with element=wait_list_notification_email name='wait list notification email' key='templates' %}
+        <div class="customization-separation"></div>
+    </div>
     <div class="panel-body">
         <h3 class="customization-section-title" id="cancellation_email_id">Cancellation email</h3>
         <p>
@@ -375,6 +393,20 @@ <h3 class="customization-section-title" id="safety_issue_email_id">Safety issue
         {% include 'customizations/customizations_upload.html' with element=safety_issue_email name='safety issue email' key='templates' %}
         <div class="customization-separation"></div>
     </div>
+    <div class="panel-body">
+        <h3 class="customization-section-title" id="scheduled_outage_reminder_email_id">Scheduled outage reminder email</h3>
+        <p>
+            This email is periodically sent to the reminder email(s) set on the scheduled outage.
+            The following context variables are provided when the email is rendered:
+        </p>
+        <ul>
+            <li>
+                <b>outage</b> - the upcoming scheduled outage
+            </li>
+        </ul>
+        {% include 'customizations/customizations_upload.html' with element=scheduled_outage_reminder_email name='scheduled outage reminder email' key='templates' %}
+        <div class="customization-separation"></div>
+    </div>
     <div class="panel-body">
         <h3 class="customization-section-title" id="staff_charge_reminder_email_id">Staff charge reminder email</h3>
         <p>
diff --git a/NEMO/templates/customizations/customizations_tool.html b/NEMO/templates/customizations/customizations_tool.html
index 5fec4b06a..276c8fe06 100644
--- a/NEMO/templates/customizations/customizations_tool.html
+++ b/NEMO/templates/customizations/customizations_tool.html
@@ -258,6 +258,44 @@ <h3 class="customization-section-title">Tool control</h3>
             </div>
         </div>
         <div class="customization-separation" style="margin-bottom: 15px"></div>
+        <h3 class="customization-section-title">Tool wait list</h3>
+        <div class="form-group">
+            <label class="control-label col-sm-4 col-md-3" for="tool_wait_list_spot_expiration">Time to expiration</label>
+            <div class="col-md-3">
+                <div class="input-group">
+                    <input type="number"
+                           min="0"
+                           id="tool_wait_list_spot_expiration"
+                           name="tool_wait_list_spot_expiration"
+                           class="form-control text-right"
+                           value="{% if errors.tool_wait_list_spot_expiration %}{{ errors.tool_wait_list_spot_expiration.value }}{% else %}{{ tool_wait_list_spot_expiration }}{% endif %}" />
+                    <span class="input-group-addon">minutes</span>
+                </div>
+            </div>
+            <div class="col-md-offset-3 col-md-9 help-block light-grey">
+                Time to expiration for a spot in the wait list. If a user does not claim the spot within this time, the user will be removed from the wait list.
+            </div>
+        </div>
+        <div class="form-group">
+            <label class="control-label col-sm-4 col-md-3" for="tool_wait_list_reservation_buffer">
+                Reservation wait list buffer
+            </label>
+            <div class="col-md-3">
+                <div class="input-group">
+                    <input type="number"
+                           min="0"
+                           id="tool_wait_list_reservation_buffer"
+                           name="tool_wait_list_reservation_buffer"
+                           class="form-control text-right"
+                           value="{% if errors.tool_wait_list_reservation_buffer %}{{ errors.tool_wait_list_reservation_buffer.value }}{% else %}{{ tool_wait_list_reservation_buffer }}{% endif %}" />
+                    <span class="input-group-addon">minutes</span>
+                </div>
+            </div>
+            <div class="col-md-offset-3 col-md-9 help-block light-grey">
+                For a tool operating in "hybrid" mode, if a reservation is upcoming within this time, the next user in the wait list will not be notified.
+            </div>
+        </div>
+        <div class="customization-separation" style="margin-bottom: 15px"></div>
         <h3 class="customization-section-title">Tool qualification expiration</h3>
         <p>
             If active, this feature will remove tool qualification from a user if the user has not used the tool after a while or never used it since qualified (configured separately).
diff --git a/NEMO/templates/event_details/outage_details.html b/NEMO/templates/event_details/outage_details.html
index 183787a86..cad8cbde7 100644
--- a/NEMO/templates/event_details/outage_details.html
+++ b/NEMO/templates/event_details/outage_details.html
@@ -157,6 +157,16 @@
 <td>{{ outage.end }}</td>
 {% endif %}
 </tr>
+{% if outage.reminder_days and outage.reminder_emails %}
+    <tr>
+        <td>Reminder day(s):</td>
+        <td>{{ outage.reminder_days }}</td>
+    </tr>
+    <tr>
+        <td>Reminder email(s):</td>
+        <td>{{ outage.reminder_emails|join:', ' }}</td>
+    </tr>
+{% endif %}
 <tr>
     <td>Identifier:</td>
     <td>{{ outage.id }}</td>
diff --git a/NEMO/templates/jumbotron/jumbotron_content.html b/NEMO/templates/jumbotron/jumbotron_content.html
index 6c3cc4449..920f5cd45 100644
--- a/NEMO/templates/jumbotron/jumbotron_content.html
+++ b/NEMO/templates/jumbotron/jumbotron_content.html
@@ -85,7 +85,7 @@ <h2>Alerts and outages</h2>
                                     <span style="font-weight:bold">{{ a.title }}</span>
                                     <br>
                                 {% endif %}
-                                {{ a.contents|linebreaks }}
+                                {{ a.contents|safe|linebreaksbr }}
                             </td>
                         </tr>
                     </table>
diff --git a/NEMO/templates/landing.html b/NEMO/templates/landing.html
index 901d9baa2..89290b823 100644
--- a/NEMO/templates/landing.html
+++ b/NEMO/templates/landing.html
@@ -68,11 +68,22 @@ <h3>Alerts &amp; outages</h3>
                         <div id="alerts-outages" style=";overflow-y: scroll;">
                             {% for a in alerts %}
                                 <div class="alert alert-danger">
+                                    {% if a.dismissible and a.user == user %}
+                                        <form id="delete_alert_{{ a.id }}" method="post" action="{% url 'delete_alert' a.id %}">
+                                            {% csrf_token %}
+                                            <span class="glyphicon glyphicon-remove pull-right"
+                                                  style="cursor:pointer"
+                                                  onclick="$('#delete_alert_{{ a.id }}').submit()"
+                                                  data-toggle="tooltip"
+                                                  data-placement="top"
+                                                  title="Delete this alert"></span>
+                                        </form>
+                                    {% endif %}
                                     {% if a.title %}
                                         <span style="font-weight:bold">{{ a.title }}</span>
                                         <br>
                                     {% endif %}
-                                    {{ a.contents|linebreaks }}
+                                    {{ a.contents|safe|linebreaksbr }}
                                 </div>
                             {% endfor %}
                             {% for r in disabled_resources %}
diff --git a/NEMO/templates/requests/adjustment_requests/adjustment_request.html b/NEMO/templates/requests/adjustment_requests/adjustment_request.html
index d5e46e554..0208fe475 100644
--- a/NEMO/templates/requests/adjustment_requests/adjustment_request.html
+++ b/NEMO/templates/requests/adjustment_requests/adjustment_request.html
@@ -128,7 +128,12 @@ <h1 class="form-group">
             <div class="text-right">
                 {% if form.instance.get_status_display == "Pending" %}
                     {% if instance_id and user in form.instance.reviewers %}
-                        {% button type="save" submit=False name="approve_request" title="Approve request" icon="glyphicon-ok-circle" value="Approve" onclick="confirm_review_dialog(this);" %}
+                        {% if form.instance.editable_charge and "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 %}
+                            {% button type="save" submit=False name="approve_request" title="Approve request" icon="glyphicon-ok-circle" value="Approve" onclick="confirm_review_dialog(this);" %}
+                        {% endif %}
                         {% button type="delete" submit=False name="deny_request" title="Deny request" icon="glyphicon-ban-circle" value="Deny" onclick="confirm_review_dialog(this);" %}
                     {% endif %}
                     {% if not instance_id or form.instance.creator == user %}
@@ -150,10 +155,11 @@ <h1 class="form-group">
         $('#billable_item').on('change', function() {
             window.location.href = this.options[this.selectedIndex].getAttribute("url");
         });
-        function confirm_review_dialog(review_button)
+        function confirm_review_dialog(review_button, decision_text)
         {
             let decision = review_button.innerText.trim().toLowerCase();
-            let dialog_text = "Are you sure you want to "+decision+" this request?";
+            decision_text = decision_text || decision 
+            let dialog_text = "Are you sure you want to "+decision_text+" this request?";
             let note = $("#manager_note").val();
             if (note && decision === "approve")
             {
diff --git a/NEMO/templates/requests/adjustment_requests/adjustment_requests.html b/NEMO/templates/requests/adjustment_requests/adjustment_requests.html
index fb4015213..c62f71bf5 100644
--- a/NEMO/templates/requests/adjustment_requests/adjustment_requests.html
+++ b/NEMO/templates/requests/adjustment_requests/adjustment_requests.html
@@ -77,10 +77,26 @@ <h3>
             $(button_element).html('<i class="glyphicon glyphicon-send"></i> Send');
         }
     }
+
+    function mark_adjustment_as_applied(adjustment_id)
+    {
+        let apply_url = "{% url "mark_adjustment_as_applied" 99999 %}".replace("99999", adjustment_id);
+        ajax_get(apply_url, undefined, function () {$("#apply_"+adjustment_id+", #applied_"+adjustment_id+", #edit_"+adjustment_id).remove()}, ajax_failure_callback("Request update failed", "There was a problem marking this adjustment as applied."));
+    }
+
+    function apply_adjustment(adjustment_id)
+    {
+        if (confirm("Are you sure you want to adjust this charge? This cannot be undone"))
+        {
+            let apply_url = "{% url "apply_adjustment" 99999 %}".replace("99999", adjustment_id);
+            ajax_get(apply_url, undefined, load_adjustment_requests, ajax_failure_callback("Request update failed", "There was a problem applying this adjustment."));
+        }
+    }
+
     $(function ()
     {
         {# Scroll to msg id if available #}
-        let msg_id = window.location.hash.substr(1);
+        let msg_id = window.location.hash.substring(1);
         if (msg_id) document.getElementById(msg_id).scrollIntoView();
 
         $('[data-toggle="tooltip"]').tooltip();
diff --git a/NEMO/templates/requests/adjustment_requests/adjustment_requests_table.html b/NEMO/templates/requests/adjustment_requests/adjustment_requests_table.html
index fa2240e5c..e4fd7ef5c 100644
--- a/NEMO/templates/requests/adjustment_requests/adjustment_requests_table.html
+++ b/NEMO/templates/requests/adjustment_requests/adjustment_requests_table.html
@@ -22,26 +22,34 @@
     <tbody>
         {% for a_request in adjustment_request_list %}
             <tr>
-                <td style="vertical-align: middle">
+                <td>
                     {% if a_request.id in request_notifications %}<span class="label label-success">New</span>{% endif %}
                     {{ a_request.creation_time|date:'SHORT_DATETIME_FORMAT' }}
                 </td>
-                {% if user_is_reviewer or user.is_any_part_of_staff %}
-                    <td style="vertical-align: middle">{{ a_request.creator.get_name }}</td>
-                {% endif %}
-                <td class="text-center" style="vertical-align: middle">{{ a_request.item.get_display }}</td>
-                <td class="text-center" style="vertical-align: middle">{{ a_request.get_new_start|date:'SHORT_DATETIME_FORMAT' }}</td>
-                <td class="text-center" style="vertical-align: middle">{{ a_request.get_new_end|date:'SHORT_DATETIME_FORMAT' }}</td>
-                <td class="text-center" style="vertical-align: middle">{{ a_request.get_time_difference|default_if_none:"" }}</td>
-                <td class="text-right text-nowrap" style="vertical-align: middle">
+                {% if user_is_reviewer or user.is_any_part_of_staff %}<td>{{ a_request.creator.get_name }}</td>{% endif %}
+                <td class="text-center">{{ a_request.item.get_display }}</td>
+                <td class="text-center">{{ a_request.get_new_start|date:'SHORT_DATETIME_FORMAT' }}</td>
+                <td class="text-center">{{ a_request.get_new_end|date:'SHORT_DATETIME_FORMAT' }}</td>
+                <td class="text-center">{{ a_request.get_time_difference|default_if_none:"" }}</td>
+                <td class="text-right text-nowrap" rowspan="2">
                     {% if request_status == 'approved' or request_status == 'denied' %}
                         {{ a_request.reviewer.get_name }}
-                        {% if request_status == 'approved' and a_request.editable_charge and "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 %}
+                        {% if not a_request.applied and request_status == 'approved' and a_request.editable_charge %}
+                            {% if "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 %}
+                                    <br>
+                                    {% button id="edit_"|concat:a_request.id type="edit" size="small" value="Edit charge" url=edit_url style="margin-bottom: 5px" %}
+                                {% 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" %}
+                                    <br>
+                                    {% 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 %}
                                 <br>
-                                {% button type="edit" size="small" value="Edit charge" url=edit_url %}
+                                {% button id="applied_"|concat:a_request.id icon="glyphicon-check" type="save" size="small" value="Mark as applied" onclick="mark_adjustment_as_applied("|concat:a_request.id|concat:")" style="margin-bottom: 5px" %}
                             {% endif %}
                         {% endif %}
                     {% else %}
diff --git a/NEMO/templates/requests/buddy_requests/buddy_requests.html b/NEMO/templates/requests/buddy_requests/buddy_requests.html
index 7335eee1c..1e6e8f211 100644
--- a/NEMO/templates/requests/buddy_requests/buddy_requests.html
+++ b/NEMO/templates/requests/buddy_requests/buddy_requests.html
@@ -93,7 +93,7 @@ <h3>{{ date.grouper|date }}</h3>
     $(function ()
     {
         {# Scroll to msg id if available #}
-        let msg_id = window.location.hash.substr(1);
+        let msg_id = window.location.hash.substring(1);
         if (msg_id) document.getElementById(msg_id).scrollIntoView();
 
         $('[data-toggle="tooltip"]').tooltip();
diff --git a/NEMO/templates/resources/scheduled_outage.html b/NEMO/templates/resources/scheduled_outage.html
index 3fe61ed61..4cc67b1ea 100644
--- a/NEMO/templates/resources/scheduled_outage.html
+++ b/NEMO/templates/resources/scheduled_outage.html
@@ -13,21 +13,19 @@ <h1>Schedule {{ resource }} outage</h1>
         affected tools and areas, <strong>but will not kick users off if they're already logged in to an area or using a tool</strong>.
         It is the staff's responsibility to ensure that all tools and areas that use the resource are properly shutdown.
     </p>
-    <p>
-        When a resource is unavailable:
-        <ul>
-            <li>The status message will be displayed on the landing page and tool control page of each affected tool</li>
-            <li>
-                Tools that fully depend on the resource will be <strong>unavailable to users</strong>
-            </li>
-            <li>
-                Areas that depend on the resource will be <strong>unavailable to users</strong>
-            </li>
-            <li>
-                Tools that partially depend on the resource will be available to users, however it is the user's responsibility to determine if their fabrication process will succeed without the resource available
-            </li>
-        </ul>
-    </p>
+    <p>When a resource is unavailable:</p>
+    <ul>
+        <li>The status message will be displayed on the landing page and tool control page of each affected tool</li>
+        <li>
+            Tools that fully depend on the resource will be <strong>unavailable to users</strong>
+        </li>
+        <li>
+            Areas that depend on the resource will be <strong>unavailable to users</strong>
+        </li>
+        <li>
+            Tools that partially depend on the resource will be available to users, however it is the user's responsibility to determine if their fabrication process will succeed without the resource available
+        </li>
+    </ul>
     {% if form.non_field_errors %}<div class="alert alert-danger">{{ form.non_field_errors }}</div>{% endif %}
     <form action="{% url 'schedule_resource_outage' resource.id %}" method="post" class="well">
         <h4>
@@ -53,7 +51,7 @@ <h4>
         {% endif %}
         <div class="form-group">
             <label for="details">Details</label>
-            {% if form.details.errors %}- <span style="color:red">{{ form.details.errors|striptags }}</span>{% endif %}
+            {% if form.details.errors %}- <span class="danger-highlight">{{ form.details.errors|striptags }}</span>{% endif %}
             <textarea aria-labelledby="outage_details_help"
                       class="form-control"
                       rows="3"
@@ -68,7 +66,7 @@ <h4>
             <div class="row">
                 <div class="col-sm-6">
                     <label for="start">Start time</label>
-                    {% if form.start.errors %}- <span style="color:red">{{ form.start.errors|striptags }}</span>{% endif %}
+                    {% if form.start.errors %}- <span class="danger-highlight">{{ form.start.errors|striptags }}</span>{% endif %}
                     <input type="text"
                            class="form-control"
                            name="start"
@@ -77,19 +75,139 @@ <h4>
                 </div>
                 <div class="col-sm-6">
                     <label for="end">End time</label>
-                    {% if form.end.errors %}- <span style="color:red">{{ form.end.errors|striptags }}</span>{% endif %}
+                    {% if form.end.errors %}- <span class="danger-highlight">{{ form.end.errors|striptags }}</span>{% endif %}
                     <input type="text" class="form-control" name="end" id="end" value="{{ form.end.value|input_date_format }}">
                 </div>
             </div>
         </div>
+        <div class="form-group">
+            <a id="reminder_outage_link"
+               onclick="toggle_details(this)"
+               data-toggle="collapse"
+               data-target="#reminder_section"
+               class="pointer"
+               style="text-decoration: none">
+                <input type="hidden" name="send_reminders" value="" />
+                <input type="checkbox"
+                       id="send_reminders"
+                       name="send_reminders"
+                       {% if form.send_reminders.value or editing and form.reminder_emails.value and form.reminder_days.value %}checked{% endif %} />
+            </a>
+            <label for="send_reminders" style="padding-left: 5px">Send reminder(s) for this outage</label>
+        </div>
+        <div id="reminder_section"
+             class="collapse {{ form.send_reminders.value|yesno:"in," }} {% if editing %}{{ form.reminder_emails.value|yesno:"in," }} {{ form.reminder_days.value|yesno:"in," }}{% endif %}"
+             style="margin: 0 auto">
+            <div class="form-group">
+                <label for="reminder_days">Reminder day(s)</label>
+                {% if form.reminder_days.errors %}
+                    - <span class="danger-highlight">{{ form.reminder_days.errors|striptags }}</span>
+                {% endif %}
+                <input aria-labelledby="outage_reminder_days_help"
+                       id="reminder_days"
+                       name="reminder_days"
+                       type="text"
+                       maxlength="2000"
+                       class="form-control"
+                       value="{{ form.reminder_days.value|default_if_none:'' }}"
+                       autocomplete="off">
+                <div id="outage_reminder_days_help" class="light-grey">
+                    The number of day(s) prior to the start of the outage to send the reminder on
+                </div>
+            </div>
+            <div class="form-group">
+                <label for="reminder_emails">Reminder email(s)</label>
+                {% if form.reminder_emails.errors %}
+                    - <span class="danger-highlight">{{ form.reminder_emails.errors|striptags }}</span>
+                {% endif %}
+                <input aria-labelledby="outage_reminder_emails_help"
+                       id="reminder_emails"
+                       name="reminder_emails"
+                       type="text"
+                       maxlength="2000"
+                       class="form-control"
+                       value="{{ form.reminder_emails.value|default_if_none:'' }}"
+                       autocomplete="off">
+                <div id="outage_reminder_emails_help" class="light-grey">The email(s) to send the reminder to</div>
+            </div>
+        </div>
+        {% if not editing %}
+            <div class="form-group">
+                <a id="recurring_outage_link"
+                   onclick="toggle_details(this)"
+                   data-toggle="collapse"
+                   data-target="#recurrence"
+                   class="pointer"
+                   style="text-decoration: none">
+                    <input type="hidden" name="recurring_outage" value="" />
+                    <input type="checkbox"
+                           id="recurring_outage"
+                           name="recurring_outage"
+                           {% if form.recurring_outage.value %}checked{% endif %} />
+                </a>
+                <label for="recurring_outage" style="padding-left: 5px">Create more than one occurrence of this outage</label>
+            </div>
+            <div id="recurrence"
+                 class="collapse {{ form.recurring_outage.value|yesno:"in," }}"
+                 style="margin: 0 auto">
+                <div class="row">
+                    <div class="form-group col-sm-2">
+                        <label for="recurrence_interval">Every</label>
+                        {% if form.recurrence_interval.errors %}
+                            - <span class="danger-highlight">{{ form.recurrence_interval.errors|striptags }}</span>
+                        {% endif %}
+                        <input id="recurrence_interval"
+                               name="recurrence_interval"
+                               type="number"
+                               class="form-control"
+                               value="{{ form.recurrence_interval.value|default_if_none:'1' }}" />
+                    </div>
+                    <div class="form-group col-sm-6">
+                        <label for="recurrence_frequency">Frequency</label>
+                        {% if form.recurrence_frequency.errors %}
+                            - <span class="danger-highlight">{{ form.recurrence_frequency.errors|striptags }}</span>
+                        {% endif %}
+                        <select class="form-control" name="recurrence_frequency" id="recurrence_frequency">
+                            {% for choice in recurrence_intervals %}
+                                <option value="{{ choice.0 }}"
+                                        {% if form.recurrence_frequency.value == choice.0|stringformat:'i' %}selected{% endif %}>
+                                    {{ choice.1 }}
+                                </option>
+                            {% endfor %}
+                        </select>
+                    </div>
+                    <div class="form-group col-sm-4">
+                        <label for="recurrence_until">Until</label>
+                        {% if form.recurrence_until.errors %}
+                            - <span class="danger-highlight">{{ form.recurrence_until.errors|striptags }}</span>
+                        {% endif %}
+                        <input type="text"
+                               id="recurrence_until"
+                               name="recurrence_until"
+                               class="form-control select-date"
+                               value="{{ form.recurrence_until.value|input_date_format }}"
+                               placeholder="Choose a date">
+                    </div>
+                </div>
+            </div>
+        {% endif %}
         <div class="form-group">{% button type="save" value=editing|yesno:"Save changes,Create outage" %}</div>
     </form>
     <script>
+    	let start_jq = $('#start');
+	    let end_jq = $('#end');
+        let recurrence_until_jq = $('#recurrence_until');
 		let timepicker_properties = {
             format: "{{ datetime_input_js_format }}",
 			sideBySide: true
 		};
-        set_start_end_datetime_pickers($('#start'), $('#end'), timepicker_properties);
-	
+        set_start_end_datetime_pickers(start_jq, end_jq, timepicker_properties);
+	    recurrence_until_jq.datetimepicker({format: '{{ date_input_js_format }}', useCurrent: false});
+        let recurrence_until_picker = recurrence_until_jq.data("DateTimePicker");
+	    start_jq.on("dp.change", function (e)
+		{
+            recurrence_until_picker.minDate(e.date);
+            recurrence_until_picker.maxDate(moment(e.date).add({{ calendar_outage_recurrence_limit }}, 'days').toDate());
+		});
     </script>
 {% endblock %}
diff --git a/NEMO/templates/tool_control/tool_control.html b/NEMO/templates/tool_control/tool_control.html
index ac6cf01c9..45f2c977b 100644
--- a/NEMO/templates/tool_control/tool_control.html
+++ b/NEMO/templates/tool_control/tool_control.html
@@ -184,6 +184,18 @@
             }
         }
 
+        function enter_wait_list(tool_id)
+        {
+			let failure_dialog = ajax_failure_callback("Failed to enter the wait list", "There was a problem entering the wait list.");
+			ajax_post("{% url 'enter_wait_list' %}", { tool_id }, reload_page, failure_dialog);
+		}
+
+		function exit_wait_list(tool_id)
+        {
+			let failure_dialog = ajax_failure_callback("Failed to exit the wait list", "There was a problem exiting the wait list.");
+			ajax_post("{% url 'exit_wait_list' %}", { tool_id }, reload_page, failure_dialog);
+		}
+
 		function enable_tool(tool_id)
 		{
 			$("#start button").prop("disabled", true);
diff --git a/NEMO/templates/tool_control/tool_status.html b/NEMO/templates/tool_control/tool_status.html
index d85f20453..dbea0a9f2 100644
--- a/NEMO/templates/tool_control/tool_status.html
+++ b/NEMO/templates/tool_control/tool_status.html
@@ -394,86 +394,152 @@ <h5>{{ o.creator }} scheduled this outage from {{ o.start }} until {{ o.end }}.<
                         {% endif %}
                     </div>
                 {% endif %}
-            {% else %}
-                {% if user.is_staff or user.is_service_personnel and tool in user.qualifications.all %}
-                    <h4 id="what_to_do_label">What would you like to do?</h4>
-                    <input type="hidden" id="remote_work" name="remote_work" value="">
-                    <div class="radio" role="group" aria-labelledby="what_to_do_label">
-                        <label>
-                            <input type="radio" onchange="use_tool_for_self()" name="staff_charge" value="false">
-                            Use this tool for my own project
-                        </label>
-                        {% if user.is_staff %}
-                            <br>
+            {% endif %}
+            {% if not tool.in_use %}
+                {% if show_wait_list and not wait_list_position == 1 %}
+                    <a onclick="toggle_details(this)"
+                       data-toggle="collapse"
+                       data-target="#use-tool"
+                       class="pointer"
+                       style="padding-bottom: 10px;
+                              padding-top: 10px;
+                              display: block;
+                              color: darkgrey">
+                        <span class="glyphicon glyphicon-chevron-right chevron"></span>Skip Wait List
+                    </a>
+                {% endif %}
+                <div id="use-tool" class="{% if show_wait_list and not wait_list_position == 1 %}collapse{% endif %}">
+                    {% if user.is_staff or user.is_service_personnel and tool in user.qualifications.all %}
+                        <h4 id="what_to_do_label">What would you like to do?</h4>
+                        <input type="hidden" id="remote_work" name="remote_work" value="">
+                        <div class="radio" role="group" aria-labelledby="what_to_do_label">
                             <label>
-                                <input type="radio" onchange="use_tool_for_other()" name="staff_charge" value="false">
-                                Use this tool on behalf of another user
-                                {% if "remote_work"|customization:"remote_work_ask_explicitly" %}(no remote){% endif %}
+                                <input type="radio" onchange="use_tool_for_self()" name="staff_charge" value="false">
+                                Use this tool for my own project
                             </label>
-                            {% if "remote_work"|customization:"remote_work_ask_explicitly" %}
+                            {% if user.is_staff %}
                                 <br>
                                 <label>
-                                    <input type="radio" onchange="use_tool_for_other(true)" name="staff_charge" value="false">
-                                    Use this tool for a remote project
+                                    <input type="radio" onchange="use_tool_for_other()" name="staff_charge" value="false">
+                                    Use this tool on behalf of another user
+                                    {% if "remote_work"|customization:"remote_work_ask_explicitly" %}(no remote){% endif %}
                                 </label>
-                            {% endif %}
-                            {% if user.charging_staff_time %}
-                                {% with user.get_staff_charge as staff_charge %}
+                                {% if "remote_work"|customization:"remote_work_ask_explicitly" %}
                                     <br>
-                                    <label id="staff_charge_tooltip_container"
-                                           class="strikethrough light-grey"
-                                           data-toggle="tooltip"
-                                           data-placement="right"
-                                           title="This option is not available because you are already charging staff time to {{ staff_charge.customer }} for the project named {{ staff_charge.project }} since {{ staff_charge.start }}.">
-                                        <input type="radio" onchange="use_tool_for_other()" disabled>
-                                        <span id="staff_charge_tooltip_location">Use this tool for a remote project
-                                            {% if "remote_work"|customization:"remote_work_ask_explicitly" %}
-                                                (charge staff time
-                                                {% if tool.requires_area_access and "remote_work"|customization:"remote_work_start_area_access_automatically" == "enabled" %}
-                                                    & {{ tool.requires_area_access }} time
+                                    <label>
+                                        <input type="radio" onchange="use_tool_for_other(true)" name="staff_charge" value="false">
+                                        Use this tool for a remote project
+                                    </label>
+                                {% endif %}
+                                {% if user.charging_staff_time %}
+                                    {% with user.get_staff_charge as staff_charge %}
+                                        <br>
+                                        <label id="staff_charge_tooltip_container"
+                                               class="strikethrough light-grey"
+                                               data-toggle="tooltip"
+                                               data-placement="right"
+                                               title="This option is not available because you are already charging staff time to {{ staff_charge.customer }} for the project named {{ staff_charge.project }} since {{ staff_charge.start }}.">
+                                            <input type="radio" onchange="use_tool_for_other()" disabled>
+                                            <span id="staff_charge_tooltip_location">Use this tool for a remote project
+                                                {% if "remote_work"|customization:"remote_work_ask_explicitly" %}
+                                                    (charge staff time
+                                                    {% if tool.requires_area_access and "remote_work"|customization:"remote_work_start_area_access_automatically" == "enabled" %}
+                                                        & {{ tool.requires_area_access }} time
+                                                    {% endif %}
+                                                    )
                                                 {% endif %}
-                                                )
+                                            </span>
+                                        </label>
+                                        <script>$("#staff_charge_tooltip_container").tooltip();</script>
+                                    {% endwith %}
+                                {% else %}
+                                    <br>
+                                    <label>
+                                        <input type="radio" onchange="use_tool_for_other()" name="staff_charge" value="true">
+                                        Use this tool for a remote project
+                                        {% if "remote_work"|customization:"remote_work_ask_explicitly" %}
+                                            (charge staff time
+                                            {% if tool.requires_area_access and "remote_work"|customization:"remote_work_start_area_access_automatically" == "enabled" %}
+                                                & {{ tool.requires_area_access }} time
                                             {% endif %}
-                                        </span>
+                                            )
+                                        {% endif %}
                                     </label>
-                                    <script>$("#staff_charge_tooltip_container").tooltip();</script>
-                                {% endwith %}
-                            {% else %}
+                                {% endif %}
+                            {% endif %}
+                        </div>
+                        <div id="project_choice"></div>
+                    {% 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 %}
+                    <div id="pre_usage_questions" style="display:none">{{ pre_usage_questions }}</div>
+                    {% if user.is_staff or user.is_service_personnel and tool in user.qualifications.all or tool.ready_to_use %}
+                        <div id="start" style="display:none">
+                            {% if tool.is_parent_tool %}
+                                You have {{ tool.tool_children_set.all|length|add:1 }} options to use this tool:
                                 <br>
-                                <label>
-                                    <input type="radio" onchange="use_tool_for_other()" name="staff_charge" value="true">
-                                    Use this tool for a remote project
-                                    {% if "remote_work"|customization:"remote_work_ask_explicitly" %}
-                                        (charge staff time
-                                        {% if tool.requires_area_access and "remote_work"|customization:"remote_work_start_area_access_automatically" == "enabled" %}
-                                            & {{ tool.requires_area_access }} time
-                                        {% endif %}
-                                        )
-                                    {% endif %}
-                                </label>
+                                <div style="height: 5px"></div>
+                            {% endif %}
+                            {% button type="save" submit=False icon="glyphicon-play" value="Start using the "|concat:tool.name onclick="enable_tool('"|concat:tool.id|concat:"'); return false;" %}
+                            {% if tool.is_parent_tool %}
+                                {% for alternate_tool in tool.tool_children_set.all %}
+                                    {% button style="margin-left: 10px" type="save" submit=False icon="glyphicon-play" value="Start using the "|concat:alternate_tool.name onclick="enable_tool('"|concat:alternate_tool.id|concat:"'); return false;" %}
+                                {% endfor %}
                             {% endif %}
+                        </div>
+                    {% endif %}
+                </div>
+            {% endif %}
+            {% if show_wait_list %}
+                <div>
+                    <div style="margin-bottom: 10px;">
+                        <span>This tool has a wait list. There are <strong>{{ wait_list.count }}</strong> people in line to use this tool.</span>
+                        {% if not wait_list_position == 0 %}
+                            <br>
+                            <span>You are number <strong>{{ wait_list_position }}</strong> in line to use this tool.</span>
                         {% endif %}
                     </div>
-                    <div id="project_choice"></div>
-                {% 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 %}
-                <div id="pre_usage_questions" style="display:none">{{ pre_usage_questions }}</div>
-                {% if user.is_staff or user.is_service_personnel and tool in user.qualifications.all or tool.ready_to_use %}
-                    <div id="start" style="display:none">
-                        {% if tool.is_parent_tool %}
-                            You have {{ tool.tool_children_set.all|length|add:1 }} options to use this tool:
-                            <br>
-                            <div style="height: 5px"></div>
+                    <div id="wait-list">
+                        {% if wait_list_position == 0 %}
+                            {% button type="save" style="margin-right: 20px;" submit=False icon="glyphicon-list" value="Enter Wait List for "|concat:tool.name onclick="enter_wait_list('"|concat:tool.id|concat:"'); return false;" %}
+                        {% else %}
+                            {% button type="delete" style="margin-right: 20px;" submit=False icon="glyphicon-trash" value="Exit Wait List for "|concat:tool.name onclick="exit_wait_list('"|concat:tool.id|concat:"'); return false;" %}
                         {% endif %}
-                        {% button type="save" submit=False icon="glyphicon-play" value="Start using the "|concat:tool.name onclick="enable_tool('"|concat:tool.id|concat:"'); return false;" %}
-                        {% if tool.is_parent_tool %}
-                            {% for alternate_tool in tool.tool_children_set.all %}
-                                {% button style="margin-left: 10px" type="save" submit=False icon="glyphicon-play" value="Start using the "|concat:alternate_tool.name onclick="enable_tool('"|concat:alternate_tool.id|concat:"'); return false;" %}
-                            {% endfor %}
+                        {% if wait_list.count > 0 %}
+                            <a onclick="toggle_details(this)"
+                               data-toggle="collapse"
+                               data-target="#wait-list-content"
+                               class="pointer"
+                               style="display: block;
+                                      padding-top: 10px">
+                                <span class="glyphicon glyphicon-chevron-right chevron"></span>Show Wait List
+                            </a>
                         {% endif %}
                     </div>
-                {% endif %}
+                    {% if wait_list.count > 0 %}
+                        <div id="wait-list-content"  style="padding-top: 10px;" class=" collapse">
+                            <table style="margin-top: 10px;
+                                          width: auto;
+                                          min-width: 50%"
+                                   class="table table-condensed table-striped table-bordered">
+                                <thead>
+                                    <tr>
+                                        <th>Position</th>
+                                        <th>User</th>
+                                    </tr>
+                                </thead>
+                                <tbody>
+                                    {% for entry in wait_list %}
+                                        <tr class="{% if user.id == entry.user.id %}success{% endif %}">
+                                            <td>{{ forloop.counter }}</td>
+                                            <td>{{ entry.user.get_full_name }}</td>
+                                        </tr>
+                                    {% endfor %}
+                                </tbody>
+                            </table>
+                        </div>
+                    {% endif %}
+                </div>
             {% endif %}
         </form>
         <div style="height:15px">{# Spacer #}</div>
diff --git a/NEMO/templates/usage/usage_base.html b/NEMO/templates/usage/usage_base.html
index 16d3b8e4d..5927700fa 100644
--- a/NEMO/templates/usage/usage_base.html
+++ b/NEMO/templates/usage/usage_base.html
@@ -176,9 +176,7 @@ <h1>
         </script>
         {% if project_autocomplete %}
             <div class="form-group">
-                <label class="control-label col-sm-2" for="search">
-                    <strong>For</strong>
-                </label>
+                <label class="control-label col-sm-2" for="search">For</label>
                 <div class="col-sm-7 col-md-7 col-lg-5" id="search_container">
                     <input id="search"
                            type="text"
@@ -220,8 +218,6 @@ <h1>
         {% endif %}
     </form>
     <div style="height: 5px; border-top: 1px dotted #eee"></div>
-    {% if not project_autocomplete or project_autocomplete and selection %}
-        {% block usage_content %}
-        {% endblock %}
-    {% endif %}
+    {% block usage_content %}
+    {% endblock %}
 {% endblock %}
diff --git a/NEMO/urls.py b/NEMO/urls.py
index 65a68542c..5cda1a9b1 100644
--- a/NEMO/urls.py
+++ b/NEMO/urls.py
@@ -72,12 +72,14 @@ def sort_urls(url_path):
 
 # REST API URLs
 router = routers.DefaultRouter()
-router.register(r"auth_groups", api.GroupViewSet)
-router.register(r"auth_permissions", api.PermissionViewSet)
 router.register(r"account_types", api.AccountTypeViewSet)
 router.register(r"accounts", api.AccountViewSet)
+router.register(r"alert_categories", api.AlertCategoryViewSet)
+router.register(r"alerts", api.AlertViewSet)
 router.register(r"area_access_records", api.AreaAccessRecordViewSet)
 router.register(r"areas", api.AreaViewSet)
+router.register(r"auth_groups", api.GroupViewSet)
+router.register(r"auth_permissions", api.PermissionViewSet)
 router.register(r"billing", api.BillingViewSet, basename="billing")
 router.register(r"configurations", api.ConfigurationViewSet)
 router.register(r"consumable_categories", api.ConsumableCategoryViewSet)
@@ -164,6 +166,8 @@ def sort_urls(url_path):
         name="enable_tool",
     ),
     path("disable_tool/<int:tool_id>/", tool_control.disable_tool, name="disable_tool"),
+    path("enter_wait_list/", tool_control.enter_wait_list, name="enter_wait_list"),
+    path("exit_wait_list/", tool_control.exit_wait_list, name="exit_wait_list"),
     path("tool_config_history/<int:tool_id>/", tool_control.tool_config_history, name="tool_config_history"),
     path("usage_data_history/<int:tool_id>/", tool_control.usage_data_history, name="usage_data_history"),
     path("past_comments_and_tasks/", tool_control.past_comments_and_tasks, name="past_comments_and_tasks"),
@@ -224,6 +228,16 @@ def sort_urls(url_path):
         adjustment_requests.delete_adjustment_request,
         name="delete_adjustment_request",
     ),
+    path(
+        "mark_adjustment_as_applied/<int:request_id>/",
+        adjustment_requests.mark_adjustment_as_applied,
+        name="mark_adjustment_as_applied",
+    ),
+    path(
+        "apply_adjustment/<int:request_id>/",
+        adjustment_requests.apply_adjustment,
+        name="apply_adjustment",
+    ),
     # Tasks:
     path("create_task/", tasks.create, name="create_task"),
     path("cancel_task/<int:task_id>/", tasks.cancel, name="cancel_task"),
@@ -562,6 +576,11 @@ def sort_urls(url_path):
             timed_services.email_reservation_reminders,
             name="email_reservation_reminders",
         ),
+        path(
+            "email_scheduled_outage_reminders/",
+            timed_services.email_scheduled_outage_reminders,
+            name="email_scheduled_outage_reminders",
+        ),
         path("email_usage_reminders/", timed_services.email_usage_reminders, name="email_usage_reminders"),
         path(
             "email_weekend_access_notification/",
@@ -578,6 +597,9 @@ def sort_urls(url_path):
         ),
         path("manage_recurring_charges/", timed_services.manage_recurring_charges, name="manage_recurring_charges"),
         path("auto_logout_users/", timed_services.auto_logout_users, name="auto_logout_users"),
+        path(
+            "check_and_update_wait_list/", timed_services.check_and_update_wait_list, name="check_and_update_wait_list"
+        ),
         # Abuse:
         path("abuse/", abuse.abuse, name="abuse"),
         path("abuse/user_drill_down/", abuse.user_drill_down, name="user_drill_down"),
diff --git a/NEMO/utilities.py b/NEMO/utilities.py
index b8f9e644e..e58f24567 100644
--- a/NEMO/utilities.py
+++ b/NEMO/utilities.py
@@ -404,6 +404,13 @@ def end_of_the_day(t: datetime, in_local_timezone=True) -> datetime:
     return localize(midnight) if in_local_timezone else midnight
 
 
+def is_date_in_datetime_range(date_to_check: date, start_date: datetime, end_date: datetime) -> bool:
+    # use timezone of start_date for date_to_check
+    start_of_day = beginning_of_the_day(datetime.combine(date_to_check, time()))
+    end_of_day = end_of_the_day(datetime.combine(date_to_check, time()))
+    return start_date <= end_of_day and start_of_day <= end_date
+
+
 def remove_duplicates(iterable: Union[List, Set, Tuple]) -> List:
     if not iterable:
         return []
diff --git a/NEMO/views/accounts_and_projects.py b/NEMO/views/accounts_and_projects.py
index aacafc4e2..392a77e11 100644
--- a/NEMO/views/accounts_and_projects.py
+++ b/NEMO/views/accounts_and_projects.py
@@ -262,15 +262,20 @@ def transfer_charges(request):
     if not ProjectsAccountsCustomization.get_bool("project_allow_transferring_charges"):
         return redirect("landing")
     form = ProjectTransferForm(request.POST or None)
-    project = Project.objects.filter(pk=form.data.get("project_id")).first()
-    new_project_id = form.data.get("new_project_id")
-    new_project = Project.objects.filter(pk=new_project_id).first() if new_project_id else None
-    user_id = form.data.get("user_id")
-    customer = User.objects.filter(id=user_id).first() if user_id else None
-    dictionary = {"form": form, "project": project, "new_project": new_project, "customer": customer}
+    project = None
+    new_project = None
+    customer = None
 
+    dictionary = {}
     if request.method == "POST":
         if form.is_valid():
+            project = Project.objects.filter(pk=form.cleaned_data.get("project_id")).first()
+            new_project_id = form.cleaned_data.get("new_project_id")
+            user_id = form.cleaned_data.get("user_id")
+            if new_project_id:
+                new_project = Project.objects.filter(pk=new_project_id).first()
+            if user_id:
+                customer = User.objects.filter(id=user_id).first()
             charges = get_charges_for_project_and_user(request.POST, customer.username if customer else None)
             confirm = "confirm" in request.POST
             if confirm:
@@ -279,9 +284,14 @@ def transfer_charges(request):
                     form.add_error("new_project_id", _("This field is required"))
                 else:
                     do_transfer_charges(charges, new_project.id)
-                    messages.success(request, f"{len(charges)} charges were transfered from {project} to {new_project}")
+                    messages.success(
+                        request,
+                        f"{len(charges)} charges were transferred from {project} to {new_project}",
+                        extra_tags="data-speed=25000",
+                    )
             else:
                 dictionary["charges"] = charges
+    dictionary.update({"form": form, "project": project, "new_project": new_project, "customer": customer})
     return render(request, "accounts_and_projects/transfer_charges.html", dictionary)
 
 
diff --git a/NEMO/views/adjustment_requests.py b/NEMO/views/adjustment_requests.py
index 841de4e47..acf50ba51 100644
--- a/NEMO/views/adjustment_requests.py
+++ b/NEMO/views/adjustment_requests.py
@@ -141,13 +141,23 @@ def create_adjustment_request(request, request_id=None, item_type_id=None, item_
             form.add_error(None, error)
 
         if form.is_valid():
+            adjust_charge = False
             if not edit:
                 form.instance.creator = user
             if edit and user in adjustment_request.reviewers():
-                decision = [state for state in ["approve_request", "deny_request"] if state in request.POST]
+                decision = [
+                    state
+                    for state in ["approve_request", "approve_apply_request", "deny_request"]
+                    if state in request.POST
+                ]
                 if decision:
-                    if next(iter(decision)) == "approve_request":
+                    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(
+                            "adjustment_requests_apply_button"
+                        ):
+                            adjust_charge = True
                     else:
                         adjustment_request.status = RequestStatus.DENIED
                     adjustment_request.reviewer = user
@@ -155,6 +165,10 @@ 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
+            if adjust_charge:
+                new_adjustment_request.apply_adjustment(user)
+
             reviewers: Set[User] = set(list(adjustment_request.reviewers()))
 
             create_adjustment_request_notification(new_adjustment_request)
@@ -216,7 +230,7 @@ def delete_adjustment_request(request, request_id):
 
     if adjustment_request.creator != request.user:
         return HttpResponseBadRequest("You are not allowed to delete a request you didn't create.")
-    if adjustment_request and adjustment_request.status != RequestStatus.PENDING:
+    if adjustment_request.status != RequestStatus.PENDING:
         return HttpResponseBadRequest("You are not allowed to delete a request that was already completed.")
 
     adjustment_request.deleted = True
@@ -225,6 +239,47 @@ def delete_adjustment_request(request, request_id):
     return redirect("user_requests", "adjustment")
 
 
+@login_required
+@require_GET
+def mark_adjustment_as_applied(request, request_id):
+    user: User = request.user
+    if not UserRequestsCustomization.get_bool("adjustment_requests_enabled"):
+        return HttpResponseBadRequest("Adjustment requests are not enabled")
+
+    adjustment_request = get_object_or_404(AdjustmentRequest, id=request_id)
+
+    if not user.is_user_office and user not in adjustment_request.reviewers():
+        return HttpResponseBadRequest("You are not allowed to mark an adjustment as applied unless you are a reviewer.")
+    if adjustment_request.status != RequestStatus.APPROVED:
+        return HttpResponseBadRequest(
+            "You cannot mark a adjustment as applied unless the request has been approved first"
+        )
+
+    adjustment_request.applied = True
+    adjustment_request.applied_by = request.user
+    adjustment_request.save(update_fields=["applied", "applied_by"])
+    return HttpResponse()
+
+
+@login_required
+@require_GET
+def apply_adjustment(request, request_id):
+    if not UserRequestsCustomization.get_bool("adjustment_requests_enabled"):
+        return HttpResponseBadRequest("Adjustment requests are not enabled")
+    if not UserRequestsCustomization.get_bool("adjustment_requests_apply_button"):
+        return HttpResponseBadRequest("Applying adjustments is not allowed")
+
+    adjustment_request = get_object_or_404(AdjustmentRequest, id=request_id)
+
+    if request.user not in adjustment_request.reviewers():
+        return HttpResponseBadRequest("You are not allowed to adjust the charge unless you are a reviewer.")
+    if adjustment_request.status != RequestStatus.APPROVED:
+        return HttpResponseBadRequest("You cannot apply a adjustment unless the request has been approved first")
+
+    adjustment_request.apply_adjustment(request.user)
+    return HttpResponse()
+
+
 def send_request_received_email(request, adjustment_request: AdjustmentRequest, edit, reviewers: Set[User]):
     user_office_email = EmailsCustomization.get("user_office_email_address")
     adjustment_request_notification_email = get_media_file_contents("adjustment_request_notification_email.html")
@@ -272,7 +327,8 @@ def send_request_received_email(request, adjustment_request: AdjustmentRequest,
             )
 
         # Send separate email to the user office (with the extra note) when a request is approved
-        if adjustment_request.status == RequestStatus.APPROVED:
+        # Unless it's also been applied in which case there is nothing to do so no need to notify them
+        if not adjustment_request.applied and adjustment_request.status == RequestStatus.APPROVED:
             dictionary["manager_note"] = adjustment_request.manager_note
             dictionary["user_office"] = True
             message = render_email_template(adjustment_request_notification_email, dictionary)
@@ -379,6 +435,8 @@ def adjustments_csv_export(request_list: List[AdjustmentRequest]) -> HttpRespons
     table_result.add_header(("new_end", "New end"))
     table_result.add_header(("difference", "Difference"))
     table_result.add_header(("reviewer", "Reviewer"))
+    table_result.add_header(("applied", "Applied"))
+    table_result.add_header(("applied_by", "Applied by"))
     for req in request_list:
         req: AdjustmentRequest = req
         table_result.add_row(
@@ -392,6 +450,8 @@ def adjustments_csv_export(request_list: List[AdjustmentRequest]) -> HttpRespons
                 "new_end": req.new_end,
                 "difference": req.get_time_difference(),
                 "reviewer": req.reviewer,
+                "applied": req.applied,
+                "applied_by": req.applied_by,
             }
         )
 
diff --git a/NEMO/views/api.py b/NEMO/views/api.py
index 1a70902ef..c969f2e2e 100644
--- a/NEMO/views/api.py
+++ b/NEMO/views/api.py
@@ -12,6 +12,8 @@
 from NEMO.models import (
     Account,
     AccountType,
+    Alert,
+    AlertCategory,
     Area,
     AreaAccessRecord,
     Configuration,
@@ -39,6 +41,8 @@
 from NEMO.serializers import (
     AccountSerializer,
     AccountTypeSerializer,
+    AlertCategorySerializer,
+    AlertSerializer,
     AreaAccessRecordSerializer,
     AreaSerializer,
     BillableItemSerializer,
@@ -131,6 +135,36 @@ def get_filename(self, *args, **kwargs):
         return f"{self.filename}-{export_format_datetime()}.xlsx"
 
 
+class AlertCategoryViewSet(viewsets.ModelViewSet):
+    filename = "alert_categories"
+    queryset = AlertCategory.objects.all()
+    serializer_class = AlertCategorySerializer
+    filterset_fields = {
+        "id": ["exact", "in"],
+        "name": ["exact", "iexact", "contains", "icontains"],
+    }
+
+
+class AlertViewSet(ModelViewSet):
+    filename = "alerts"
+    queryset = Alert.objects.all()
+    serializer_class = AlertSerializer
+    filterset_fields = {
+        "id": ["exact", "in"],
+        "title": ["exact", "iexact", "contains", "icontains"],
+        "category": ["exact", "iexact"],
+        "contents": ["contains", "icontains"],
+        "creation_time": ["month", "year", "day", "gte", "gt", "lte", "lt"],
+        "creator": ["exact", "in"],
+        "user": ["exact", "in"],
+        "debut_time": ["month", "year", "day", "gte", "gt", "lte", "lt", "isnull"],
+        "expiration_time": ["month", "year", "day", "gte", "gt", "lte", "lt", "isnull"],
+        "dismissible": ["exact"],
+        "expired": ["exact"],
+        "deleted": ["exact"],
+    }
+
+
 class UserViewSet(ModelViewSet):
     filename = "users"
     queryset = User.objects.all()
diff --git a/NEMO/views/calendar.py b/NEMO/views/calendar.py
index 7359f6b6d..a826b19d6 100644
--- a/NEMO/views/calendar.py
+++ b/NEMO/views/calendar.py
@@ -17,6 +17,7 @@
 
 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
 from NEMO.models import (
     Area,
     AreaAccessRecord,
@@ -38,17 +39,14 @@
     RecurrenceFrequency,
     bootstrap_primary_color,
     create_ics,
-    date_input_format,
     datetime_input_format,
     distinct_qs_value_list,
     extract_times,
     format_datetime,
     get_email_from_settings,
     get_full_url,
-    get_recurring_rule,
     localize,
     parse_parameter_string,
-    quiet_int,
     render_email_template,
     send_mail,
 )
@@ -89,10 +87,13 @@ def calendar(request, item_type=None, item_id=None):
 
     calendar_view = CalendarCustomization.get("calendar_view")
     calendar_first_day_of_week = CalendarCustomization.get("calendar_first_day_of_week")
-    calendar_time_format = CalendarCustomization.get("calendar_time_format")
+    calendar_axis_time_format = CalendarCustomization.get("calendar_axis_time_format")
     calendar_day_column_format = CalendarCustomization.get("calendar_day_column_format")
+    calendar_day_time_format = CalendarCustomization.get("calendar_day_time_format")
     calendar_week_column_format = CalendarCustomization.get("calendar_week_column_format")
+    calendar_week_time_format = CalendarCustomization.get("calendar_week_time_format")
     calendar_month_column_format = CalendarCustomization.get("calendar_month_column_format")
+    calendar_month_time_format = CalendarCustomization.get("calendar_month_time_format")
     calendar_start_of_the_day = CalendarCustomization.get("calendar_start_of_the_day")
     calendar_now_indicator = CalendarCustomization.get("calendar_now_indicator")
     calendar_all_tools = CalendarCustomization.get("calendar_all_tools")
@@ -122,10 +123,13 @@ def calendar(request, item_type=None, item_id=None):
         "auto_select_item_type": item_type,
         "calendar_view": calendar_view,
         "calendar_first_day_of_week": calendar_first_day_of_week,
-        "calendar_time_format": calendar_time_format,
+        "calendar_axis_time_format": calendar_axis_time_format,
         "calendar_day_column_format": calendar_day_column_format,
+        "calendar_day_time_format": calendar_day_time_format,
         "calendar_week_column_format": calendar_week_column_format,
+        "calendar_week_time_format": calendar_week_time_format,
         "calendar_month_column_format": calendar_month_column_format,
+        "calendar_month_time_format": calendar_month_time_format,
         "calendar_start_of_the_day": calendar_start_of_the_day,
         "calendar_now_indicator": calendar_now_indicator,
         "calendar_all_tools": calendar_all_tools,
@@ -626,29 +630,18 @@ def create_outage(request):
     """Create an outage."""
     try:
         start, end = extract_times(request.POST)
-        duration = end - start
         item_type = ReservationItemType(request.POST["item_type"])
         item_id = request.POST.get("item_id")
     except Exception as e:
         return HttpResponseBadRequest(str(e))
     item = get_object_or_404(item_type.get_object_class(), id=item_id)
     # Create the new outage:
-    outage = ScheduledOutage()
-    outage.creator = request.user
-    outage.category = request.POST.get("category", "")[:200]
-    outage.outage_item = item
-    outage.start = start
-    outage.end = end
-
-    # If there is a policy problem for the outage then return the error...
-    policy_problem = policy.check_to_create_outage(outage)
-    if policy_problem:
-        return HttpResponseBadRequest(policy_problem)
-
-    # Make sure there is at least an outage title
-    if not request.POST.get("title"):
+    form = ScheduledOutageForm(request.POST)
+    if not form.is_valid():
         calendar_outage_recurrence_limit = CalendarCustomization.get("calendar_outage_recurrence_limit")
         dictionary = {
+            "form": form,
+            "outage_submitted": request.POST.get("outage_submitted"),
             "categories": ScheduledOutageCategory.objects.all(),
             "recurrence_intervals": RecurrenceFrequency.choices(),
             "recurrence_date_start": start.date(),
@@ -656,36 +649,10 @@ def create_outage(request):
         }
         return render(request, "calendar/scheduled_outage_information.html", dictionary)
 
-    outage.title = request.POST["title"]
-    outage.details = request.POST.get("details", "")
-
-    if request.POST.get("recurring_outage") == "on":
-        # we have to remove tz before creating rules otherwise 8am would become 7am after DST change for example.
-        start_no_tz = outage.start.replace(tzinfo=None)
-        end_no_tz = outage.end.replace(tzinfo=None)
-
-        submitted_frequency = request.POST.get("recurrence_frequency")
-        submitted_date_until = request.POST.get("recurrence_until", None)
-        date_until_no_tz = end_no_tz.replace(hour=0, minute=0, second=0)
-        if submitted_date_until:
-            date_until_no_tz = datetime.strptime(submitted_date_until, date_input_format)
-        date_until_no_tz += timedelta(days=1, seconds=-1)  # set at the end of the day
-        frequency = RecurrenceFrequency(quiet_int(submitted_frequency, RecurrenceFrequency.DAILY.index))
-        rules = get_recurring_rule(
-            start_no_tz, frequency, date_until_no_tz, int(request.POST.get("recurrence_interval", 1))
-        )
-        for rule in list(rules):
-            recurring_outage = ScheduledOutage()
-            recurring_outage.creator = outage.creator
-            recurring_outage.category = outage.category
-            recurring_outage.outage_item = outage.outage_item
-            recurring_outage.title = outage.title
-            recurring_outage.details = outage.details
-            recurring_outage.start = localize(start_no_tz.replace(year=rule.year, month=rule.month, day=rule.day))
-            recurring_outage.end = recurring_outage.start + duration
-            recurring_outage.save()
-    else:
-        outage.save()
+    # If there is a policy problem for the outage then return the error...
+    errors = save_scheduled_outage(form, request.user, item, start=start, end=end)
+    if errors:
+        return HttpResponseBadRequest(errors)
 
     return HttpResponse()
 
diff --git a/NEMO/views/customization.py b/NEMO/views/customization.py
index 6b60cd15c..019d04352 100644
--- a/NEMO/views/customization.py
+++ b/NEMO/views/customization.py
@@ -256,10 +256,13 @@ class CalendarCustomization(CustomizationBase):
     variables = {
         "calendar_view": "agendaWeek",
         "calendar_first_day_of_week": "1",
-        "calendar_time_format": "ha",
+        "calendar_axis_time_format": "ha",
         "calendar_day_column_format": "dddd MM/DD/YYYY",
+        "calendar_day_time_format": "h:mm",
         "calendar_week_column_format": "ddd M/DD",
+        "calendar_week_time_format": "h:mm",
         "calendar_month_column_format": "ddd",
+        "calendar_month_time_format": "h(:mm)t",
         "calendar_start_of_the_day": "07:00:00",
         "calendar_now_indicator": "",
         "calendar_display_not_qualified_areas": "",
@@ -342,6 +345,7 @@ class UserRequestsCustomization(CustomizationBase):
         "adjustment_requests_time_limit_interval": "2",
         "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": "",
@@ -454,6 +458,8 @@ class ToolCustomization(CustomizationBase):
         "tool_problem_send_to_all_qualified_users": "",
         "tool_configuration_near_future_days": "1",
         "tool_reservation_policy_superusers_bypass": "",
+        "tool_wait_list_spot_expiration": "15",
+        "tool_wait_list_reservation_buffer": "15",
     }
 
     def validate(self, name, value):
@@ -550,6 +556,7 @@ class TemplatesCustomization(CustomizationBase):
         ("reservation_reminder_email", ".html"),
         ("reservation_warning_email", ".html"),
         ("safety_issue_email", ".html"),
+        ("scheduled_outage_reminder_email", ".html"),
         ("staff_charge_reminder_email", ".html"),
         ("task_status_notification", ".html"),
         ("tool_qualification_expiration_email", ".html"),
@@ -560,6 +567,7 @@ class TemplatesCustomization(CustomizationBase):
         ("reservation_cancelled_user_email", ".html"),
         ("weekend_access_email", ".html"),
         ("recurring_charges_reminder_email", ".html"),
+        ("wait_list_notification_email", ".html"),
     ]
 
 
diff --git a/NEMO/views/resources.py b/NEMO/views/resources.py
index 127433cf9..7d9264511 100644
--- a/NEMO/views/resources.py
+++ b/NEMO/views/resources.py
@@ -4,8 +4,10 @@
 from django.views.decorators.http import require_GET, require_POST, require_http_methods
 
 from NEMO.decorators import staff_member_required
-from NEMO.forms import ScheduledOutageForm
+from NEMO.forms import ResourceScheduledOutageForm, save_scheduled_outage
 from NEMO.models import Resource, ScheduledOutage, ScheduledOutageCategory, Tool, UsageEvent
+from NEMO.utilities import RecurrenceFrequency
+from NEMO.views.customization import CalendarCustomization
 
 
 @staff_member_required
@@ -66,25 +68,21 @@ def schedule_outage(request, resource_id):
         outage = ScheduledOutage.objects.get(id=outage_id)
     except:
         outage = None
-    if request.method == "GET":
-        form = ScheduledOutageForm(instance=outage)
-    elif request.method == "POST":
-        form = ScheduledOutageForm(data=request.POST, instance=outage)
+    form = ResourceScheduledOutageForm(data=request.POST or None, instance=outage)
+    if request.method == "POST":
         if form.is_valid():
-            outage = form.save(commit=False)
-            outage.resource = resource
-            outage.creator = request.user
-            outage.title = f"{outage.resource.name} scheduled outage"
-            outage.save()
+            title = f"{resource.name} scheduled outage"
+            save_scheduled_outage(form, request.user, resource, title=title, check_policy=False)
             return redirect("resource_details", resource.id)
-    else:
-        form = ScheduledOutageForm()
+    calendar_outage_recurrence_limit = CalendarCustomization.get("calendar_outage_recurrence_limit")
     dictionary = {
         "resource": resource,
         "form": form,
         "editing": True if form.instance.id else False,
         "resources": Resource.objects.all().prefetch_related("category").order_by("category__name", "name"),
         "outage_categories": ScheduledOutageCategory.objects.all(),
+        "recurrence_intervals": RecurrenceFrequency.choices(),
+        "calendar_outage_recurrence_limit": calendar_outage_recurrence_limit,
     }
     return render(request, "resources/scheduled_outage.html", dictionary)
 
diff --git a/NEMO/views/tasks.py b/NEMO/views/tasks.py
index e4e5cedfd..91060d1db 100644
--- a/NEMO/views/tasks.py
+++ b/NEMO/views/tasks.py
@@ -67,15 +67,26 @@ def create(request):
     task = form.save()
     task_images = save_task_images(request, task)
 
-    if not settings.ALLOW_CONDITIONAL_URLS and task.force_shutdown:
-        site_title = ApplicationCustomization.get("site_title")
+    save_error = save_task(request, task, task_images)
+
+    if save_error:
         dictionary = {
             "title": "Task creation failed",
             "heading": "Something went wrong while reporting the problem",
-            "content": 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.",
+            "content": save_error,
         }
         return render(request, "acknowledgement.html", dictionary)
 
+    return redirect("tool_control")
+
+
+def save_task(request, task: Task, task_images: List[TaskImages] = None):
+    task.save()
+    if not settings.ALLOW_CONDITIONAL_URLS and task.force_shutdown:
+        site_title = ApplicationCustomization.get("site_title")
+
+        return 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."
+
     if task.force_shutdown:
         # Shut down the tool.
         task.tool.operational = False
@@ -101,7 +112,8 @@ def create(request):
 
     send_new_task_emails(request, task, task_images)
     set_task_status(request, task, request.POST.get("status"), request.user)
-    return redirect("tool_control")
+
+    return None
 
 
 def send_new_task_emails(request, task: Task, task_images: List[TaskImages]):
diff --git a/NEMO/views/timed_services.py b/NEMO/views/timed_services.py
index 5aee8a83f..286c3add9 100644
--- a/NEMO/views/timed_services.py
+++ b/NEMO/views/timed_services.py
@@ -1,6 +1,6 @@
 from datetime import date, datetime, timedelta
 from logging import getLogger
-from typing import Dict, Iterable, List
+from typing import Dict, Iterable, List, Set
 
 from django.contrib.auth.decorators import login_required, permission_required
 from django.core.exceptions import ValidationError
@@ -24,9 +24,11 @@
     RequestStatus,
     Reservation,
     ReservationItemType,
+    ScheduledOutage,
     StaffCharge,
     TemporaryPhysicalAccessRequest,
     Tool,
+    ToolWaitList,
     UsageEvent,
     User,
 )
@@ -36,9 +38,11 @@
     as_timezone,
     beginning_of_the_day,
     bootstrap_primary_color,
+    end_of_the_day,
     format_datetime,
     get_email_from_settings,
     get_full_url,
+    is_date_in_datetime_range,
     quiet_int,
     render_email_template,
     send_mail,
@@ -237,6 +241,141 @@ def email_last_closure_occurrence(closure_time):
     )
 
 
+@login_required
+@require_GET
+@permission_required("NEMO.trigger_timed_services", raise_exception=True)
+def check_and_update_wait_list(request):
+    return do_check_and_update_wait_list()
+
+
+def do_check_and_update_wait_list(now=timezone.now()):
+    tools_with_wait_list = (
+        Tool.objects.filter(toolwaitlist__expired=False, toolwaitlist__deleted=False)
+        .exclude(_operation_mode=Tool.OperationMode.REGULAR)
+        .distinct()
+    )
+    time_to_expiration = quiet_int(ToolCustomization.get("tool_wait_list_spot_expiration"), 1)
+
+    for tool in tools_with_wait_list:
+        hybrid_mode = tool.operation_mode == Tool.OperationMode.HYBRID
+
+        # Only check the wait list if the tool is not in use
+        # And we are not in the hybrid mode exclusion zone (reservation buffer or active reservation)
+        if not tool.in_use() and not in_hybrid_mode_reservation_or_buffer_zone(tool, now):
+            top_wait_list_entry = tool.top_wait_list_entry()
+            last_turn_available_at = top_wait_list_entry.last_turn_available_at if top_wait_list_entry else None
+            turn_available_date = get_wait_list_turn_available_date(tool, top_wait_list_entry, hybrid_mode, now)
+            first_check = last_turn_available_at is None
+
+            # Notify the next user in the wait list
+            if first_check or turn_available_date > last_turn_available_at:
+                top_wait_list_entry.last_turn_available_at = turn_available_date
+                top_wait_list_entry.save()
+                last_turn_available_at = turn_available_date
+                notify_next_user_in_wait_list(top_wait_list_entry, time_to_expiration)
+
+            # Check if spot has expired
+            if not first_check and now - last_turn_available_at >= timedelta(minutes=time_to_expiration):
+                top_wait_list_entry.expired = True
+                top_wait_list_entry.date_exited = now
+                top_wait_list_entry.save()
+
+                # Use this if we want to notify next user in line in the same tick
+                next_user_entry = tool.top_wait_list_entry()
+                if next_user_entry:
+                    next_user_entry.last_turn_available_at = now
+                    next_user_entry.save()
+                    notify_next_user_in_wait_list(top_wait_list_entry, time_to_expiration)
+
+    return HttpResponse()
+
+
+def in_hybrid_mode_reservation_or_buffer_zone(tool, now=timezone.now()):
+    """
+    In hybrid mode, the wait list is not checked if there is an upcoming reservation within the next "reservation_buffer" minutes,
+    or if we are inside an active reservation slot.
+    """
+    if tool.operation_mode == Tool.OperationMode.HYBRID:
+        reservation_buffer = quiet_int(ToolCustomization.get("tool_wait_list_reservation_buffer"), 1)
+        upcoming_reservation_within_buffer_or_active_reservation = Reservation.objects.filter(
+            tool=tool,
+            cancelled=False,
+            missed=False,
+            shortened=False,
+            start__lte=now + timedelta(minutes=reservation_buffer),
+            end__gt=now,
+        ).exists()
+        return upcoming_reservation_within_buffer_or_active_reservation
+    return False
+
+
+def get_wait_list_turn_available_date(tool, entry, hybrid_mode=False, now=timezone.now()):
+    """
+    User turn becomes available starting from the latest of one of the following dates:
+    - The end of the last usage event
+    - The end of the last reservation (hybrid mode only)
+        - When a reservation is missed, the reservation end is calculated as the start date + the missed reservation threshold.
+    - The time the previous user exited the wait list
+    """
+
+    last_usage_event = (
+        UsageEvent.objects.filter(tool_id__in=tool.get_family_tool_ids(), end__lte=now).order_by("-end").first()
+    )
+    last_usage_event_end = last_usage_event.end if last_usage_event else None
+
+    last_reservation_end = None
+    if hybrid_mode:
+        last_reservation = (
+            Reservation.objects.filter(Q(end__lte=now) | Q(missed=True), tool=tool).order_by("-end").first()
+        )
+        last_reservation_end = get_reservation_end(last_reservation) if last_reservation else None
+
+    previous_wait_list_entry = (
+        ToolWaitList.objects.filter(Q(expired=True) | Q(deleted=True), tool=tool, date_entered__lt=entry.date_entered)
+        .order_by("-date_exited")
+        .first()
+    )
+    previous_wait_list_entry_exited = previous_wait_list_entry.date_exited if previous_wait_list_entry else None
+
+    return sorted(
+        [
+            last_usage_event_end,
+            last_reservation_end,
+            previous_wait_list_entry_exited,
+        ],
+        key=lambda x: (x is not None, x),
+        reverse=True,
+    )[0]
+
+
+def get_reservation_end(reservation):
+    if not reservation.missed:
+        return reservation.end
+    else:
+        return reservation.start + timedelta(minutes=reservation.tool.missed_reservation_threshold)
+
+
+def notify_next_user_in_wait_list(entry, time_to_expiration):
+    message = get_media_file_contents("wait_list_notification_email.html")
+    if message:
+        subject = "Your turn for the " + str(entry.tool)
+        message = render_email_template(
+            message, {"user": entry.user, "tool": entry.tool, "time_to_expiration": time_to_expiration}
+        )
+        recipients = entry.user.get_emails(entry.user.get_preferences().email_send_wait_list_notification_emails)
+        send_mail(
+            subject=subject,
+            content=message,
+            from_email=get_email_from_settings(),
+            to=recipients,
+            email_category=EmailCategory.TIMED_SERVICES,
+        )
+    else:
+        timed_service_logger.error(
+            "Wait list notification email couldn't be send because wait_list_notification_email.html is not defined"
+        )
+
+
 @login_required
 @require_GET
 @permission_required("NEMO.trigger_timed_services", raise_exception=True)
@@ -823,3 +962,44 @@ def do_auto_logout_users():
             record.end = record.start + timeout
             record.save(update_fields=["end"])
     return HttpResponse()
+
+
+@login_required
+@require_GET
+@permission_required("NEMO.trigger_timed_services", raise_exception=True)
+def email_scheduled_outage_reminders(request):
+    return send_email_scheduled_outage_reminders(request)
+
+
+def send_email_scheduled_outage_reminders(request=None) -> HttpResponse:
+    # Exit early if the template email is not defined
+    message = get_media_file_contents("scheduled_outage_reminder_email.html")
+    if not message:
+        timed_service_logger.error(
+            "Scheduled outage reminder email couldn't be send because scheduled_outage_reminder_email.html is not defined"
+        )
+        return HttpResponseNotFound(
+            "The scheduled outage reminder template has not been customized for your organization yet. Please visit the customization page to upload one, then scheduled outage reminder email notifications can be sent."
+        )
+    future_outages: QuerySetType[ScheduledOutage] = ScheduledOutage.objects.filter(start__gte=timezone.now())
+    outages_to_send_reminders_for: Set[ScheduledOutage] = set()
+    for future_outage in future_outages:
+        # Skip if we have no email addresses to send it to
+        if future_outage.reminder_emails:
+            for remaining_days in future_outage.get_reminder_days():
+                outage_date = date.today() + timedelta(days=remaining_days)
+                # Use the whole day of the start of the outage to check
+                start, end = beginning_of_the_day(future_outage.start), end_of_the_day(future_outage.start)
+                if is_date_in_datetime_range(outage_date, start, end):
+                    outages_to_send_reminders_for.add(future_outage)
+    for outage in outages_to_send_reminders_for:
+        subject = f"{outage.title} reminder"
+        rendered_message = render_email_template(message, {"outage": outage}, request)
+        send_mail(
+            subject=subject,
+            content=rendered_message,
+            from_email=get_email_from_settings(),
+            to=outage.reminder_emails,
+            email_category=EmailCategory.TIMED_SERVICES,
+        )
+    return HttpResponse()
diff --git a/NEMO/views/tool_control.py b/NEMO/views/tool_control.py
index 9b4ad1f52..76ec42825 100644
--- a/NEMO/views/tool_control.py
+++ b/NEMO/views/tool_control.py
@@ -32,6 +32,7 @@
     TaskStatus,
     Tool,
     ToolUsageCounter,
+    ToolWaitList,
     UsageEvent,
     User,
 )
@@ -95,6 +96,20 @@ def tool_status(request, tool_id):
     tool = get_object_or_404(Tool, id=tool_id, visible=True)
     user_is_qualified = tool.user_set.filter(id=user.id).exists()
     broadcast_upcoming_reservation = ToolCustomization.get("tool_control_broadcast_upcoming_reservation")
+    wait_list = tool.current_wait_list()
+    user_wait_list_entry = wait_list.filter(user=request.user).first()
+    user_wait_list_position = (
+        (
+            ToolWaitList.objects.filter(
+                tool=tool, date_entered__lte=user_wait_list_entry.date_entered, expired=False, deleted=False
+            )
+            .exclude(user=user)
+            .count()
+            + 1
+        )
+        if user_wait_list_entry
+        else 0
+    )
     dictionary = {
         "tool": tool,
         "tool_rate": rate_class.get_tool_rate(tool, user),
@@ -112,6 +127,19 @@ def tool_status(request, tool_id):
         "user_can_see_documents": user.is_any_part_of_staff
         or not ToolCustomization.get_bool("tool_control_show_documents_only_qualified_users")
         or user_is_qualified,
+        "wait_list_position": user_wait_list_position,  # 0 if not in wait list
+        "wait_list": wait_list,
+        "show_wait_list": (
+            tool.allow_wait_list()
+            and (
+                not (
+                    tool.get_current_usage_event().operator.id == user.id
+                    or tool.get_current_usage_event().user.id == user.id
+                )
+                if tool.in_use()
+                else wait_list.count() > 0
+            )
+        ),
     }
 
     try:
@@ -286,15 +314,8 @@ def create_comment(request):
     form = CommentForm(request.POST)
     if not form.is_valid():
         return HttpResponseBadRequest(nice_errors(form).as_ul())
-    comment = form.save(commit=False)
-    comment.content = comment.content.strip()
-    comment.author = request.user
-    comment.expiration_date = (
-        None
-        if form.cleaned_data["expiration"] == -1
-        else timezone.now() + timedelta(days=form.cleaned_data["expiration"])
-    )
-    comment.save()
+    save_comment(request.user, form)
+
     return redirect("tool_control")
 
 
@@ -311,6 +332,18 @@ def hide_comment(request, comment_id):
     return redirect("tool_control")
 
 
+def save_comment(user, form):
+    comment = form.save(commit=False)
+    comment.content = comment.content.strip()
+    comment.author = user
+    comment.expiration_date = (
+        None
+        if form.cleaned_data["expiration"] == -1
+        else timezone.now() + timedelta(days=form.cleaned_data["expiration"])
+    )
+    comment.save()
+
+
 def determine_tool_status(tool):
     # Make the tool operational when all problems are resolved that require a shutdown.
     if tool.task_set.filter(force_shutdown=True, cancelled=False, resolved=False).count() == 0:
@@ -399,6 +432,11 @@ def enable_tool(request, tool_id, user_id, project_id, staff_charge):
     new_usage_event.remote_work = remote_work
     new_usage_event.save()
 
+    # Remove wait list entry if it exists
+    wait_list_entry = ToolWaitList.objects.filter(tool=tool, expired=False, deleted=False, user=user)
+    if wait_list_entry.count() > 0:
+        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)
     except Exception as e:
@@ -481,6 +519,56 @@ def disable_tool(request, tool_id):
     return HttpResponse()
 
 
+@login_required
+@require_POST
+def enter_wait_list(request):
+    tool = get_object_or_404(Tool, id=request.POST["tool_id"])
+
+    if not tool.allow_wait_list():
+        return HttpResponseBadRequest("This tool does not operate in wait list mode.")
+
+    user = request.user
+    wait_list_other_users = ToolWaitList.objects.filter(tool=tool, expired=False, deleted=False).exclude(user=user)
+    wait_list_user_entry = ToolWaitList.objects.filter(tool=tool, expired=False, deleted=False, user=user)
+
+    # User must not be in the wait list
+    if wait_list_user_entry.count() > 0:
+        return HttpResponseBadRequest("You are already in the wait list.")
+
+    # The tool must be in use or have a wait list
+    current_usage_event = tool.get_current_usage_event()
+    if not current_usage_event and wait_list_other_users.count() == 0:
+        return HttpResponseBadRequest("The tool is free to use.")
+
+    # The user must be qualified to use the tool itself, or the parent tool in case of alternate tool.
+    tool_to_check_qualifications = tool.parent_tool if tool.is_child_tool() else tool
+    if tool_to_check_qualifications not in user.qualifications.all() and not user.is_staff:
+        return HttpResponseBadRequest("You are not qualified to use this tool.")
+
+    entry = ToolWaitList()
+    entry.user = user
+    entry.tool = tool
+    entry.save()
+
+    return HttpResponse()
+
+
+@login_required
+@require_POST
+def exit_wait_list(request):
+    tool = get_object_or_404(Tool, id=request.POST["tool_id"])
+    user = request.user
+    wait_list_entry = ToolWaitList.objects.filter(tool=tool, expired=False, deleted=False, user=user)
+    if wait_list_entry.count() == 0:
+        return HttpResponseBadRequest("You are not in the wait list.")
+    do_exit_wait_list(wait_list_entry, timezone.now())
+    return HttpResponse()
+
+
+def do_exit_wait_list(entry, time):
+    entry.update(deleted=True, date_exited=time)
+
+
 @login_required
 @require_GET
 def past_comments_and_tasks(request):
diff --git a/NEMO/views/usage.py b/NEMO/views/usage.py
index c17c9d41d..9c2d6d033 100644
--- a/NEMO/views/usage.py
+++ b/NEMO/views/usage.py
@@ -228,32 +228,30 @@ def project_usage(request):
             projects = user.active_projects()
             selection = str(user)
 
+        area_access = AreaAccessRecord.objects.filter(end__gt=start_date, end__lte=end_date).order_by("-start")
+        consumables = ConsumableWithdraw.objects.filter(date__gt=start_date, date__lte=end_date)
+        missed_reservations = Reservation.objects.filter(missed=True, end__gt=start_date, end__lte=end_date)
+        staff_charges = StaffCharge.objects.filter(end__gt=start_date, end__lte=end_date)
+        training_sessions = TrainingSession.objects.filter(date__gt=start_date, date__lte=end_date)
+        usage_events = UsageEvent.objects.filter(end__gt=start_date, end__lte=end_date)
         if projects:
-            area_access = AreaAccessRecord.objects.filter(
-                project__in=projects, end__gt=start_date, end__lte=end_date
-            ).order_by("-start")
-            consumables = ConsumableWithdraw.objects.filter(
-                project__in=projects, date__gt=start_date, date__lte=end_date
+            area_access = area_access.filter(project__in=projects)
+            consumables = consumables.filter(project__in=projects)
+            missed_reservations = missed_reservations.filter(project__in=projects)
+            staff_charges = staff_charges.filter(project__in=projects)
+            training_sessions = training_sessions.filter(project__in=projects)
+            usage_events = usage_events.filter(project__in=projects)
+        if user:
+            area_access = area_access.filter(customer=user)
+            consumables = consumables.filter(customer=user)
+            missed_reservations = missed_reservations.filter(user=user)
+            staff_charges = staff_charges.filter(customer=user)
+            training_sessions = training_sessions.filter(trainee=user)
+            usage_events = usage_events.filter(user=user)
+        if bool(request.GET.get("csv", False)):
+            return csv_export_response(
+                usage_events, area_access, training_sessions, staff_charges, consumables, missed_reservations
             )
-            missed_reservations = Reservation.objects.filter(
-                project__in=projects, missed=True, end__gt=start_date, end__lte=end_date
-            )
-            staff_charges = StaffCharge.objects.filter(project__in=projects, end__gt=start_date, end__lte=end_date)
-            training_sessions = TrainingSession.objects.filter(
-                project__in=projects, date__gt=start_date, date__lte=end_date
-            )
-            usage_events = UsageEvent.objects.filter(project__in=projects, end__gt=start_date, end__lte=end_date)
-            if user:
-                area_access = area_access.filter(customer=user)
-                consumables = consumables.filter(customer=user)
-                missed_reservations = missed_reservations.filter(user=user)
-                staff_charges = staff_charges.filter(customer=user)
-                training_sessions = training_sessions.filter(trainee=user)
-                usage_events = usage_events.filter(user=user)
-            if bool(request.GET.get("csv", False)):
-                return csv_export_response(
-                    usage_events, area_access, training_sessions, staff_charges, consumables, missed_reservations
-                )
     except:
         pass
     dictionary = {
diff --git a/resources/emails/scheduled_outage_reminder_email.html b/resources/emails/scheduled_outage_reminder_email.html
new file mode 100644
index 000000000..659339631
--- /dev/null
+++ b/resources/emails/scheduled_outage_reminder_email.html
@@ -0,0 +1,63 @@
+<html lang="en">
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+    </head>
+    <body>
+        <!--[if mso]><table width="600" align="center"><tr><td><![endif]-->
+        <table align="center"
+               style="width:100%;
+                      max-width:600px;
+                      font-family: 'Avenir Next', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'">
+            <tr>
+                <td align="center" style="color: white; background: #5bc0de; padding: 50px 0;" bgcolor="#5bc0de">
+                    <h1 style="max-width: 90%; margin: 0 auto; padding: 0;">{{ outage.title|upper }} REMINDER</h1>
+                </td>
+            </tr>
+            <tr>
+                <td style="padding: 10px;">
+                    <p>Hello,</p>
+                    <p>
+                        This is a reminder that the {{ outage.title }} is scheduled to start in {{ outage.start|timeuntil }} on {{ outage.start }}.
+                        <br>
+                        {% if outage.resource %}
+                            <br>
+                            <u>Resource</u>: {{ outage.resource }}
+                        {% endif %}
+                        {% if outage.tool %}
+                            <br>
+                            <u>Tool</u>: {{ outage.tool }}
+                        {% endif %}
+                        {% if outage.area %}
+                            <br>
+                            <u>Area</u>: {{ outage.area }}
+                        {% endif %}
+                        <br>
+                        <u>Start time</u>: {{ outage.start }}
+                        <br>
+                        <u>End time</u>: {{ outage.end }}
+                        {% if outage.category %}
+                            <br>
+                            <u>Category</u>: {{ outage.category }}
+                        {% endif %}
+                        {% if outage.resource and outage.resource.visible_fully_dependent_tools %}
+                            <br>
+                            <u>Affected tools</u>: {{ outage.resource.visible_fully_dependent_tools|join:', ' }}
+                        {% endif %}
+                        {% if outage.resource and outage.resource.visible_partially_dependent_tools %}
+                            <br>
+                            <u>Partially affected tools</u>: {{ outage.resource.visible_partially_dependent_tools|join:', ' }}
+                        {% endif %}
+                        {% if outage.details %}
+                            <br>
+                            <u>Details</u>:
+                            <br>
+                            {{ outage.details|linebreaksbr }}
+                        {% endif %}
+                    </p>
+                </td>
+            </tr>
+        </table>
+        <!--[if mso]></td></tr></table><![endif]-->
+    </body>
+</html>
diff --git a/resources/emails/wait_list_notification_email.html b/resources/emails/wait_list_notification_email.html
new file mode 100644
index 000000000..1f5df0073
--- /dev/null
+++ b/resources/emails/wait_list_notification_email.html
@@ -0,0 +1,34 @@
+<html lang="en">
+    <head>
+        <meta charset="utf-8">
+        <meta name="viewport" content="width=device-width, initial-scale=1">
+    </head>
+    <body>
+        <!--[if mso]><table width="600" align="center"><tr><td><![endif]-->
+        <table align="center"
+               style="width:100%;
+                      max-width:600px;
+                      font-family: 'Avenir Next', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'">
+            <tr>
+                <td align="center"
+                    style="color: white;
+                           background: #5bc0de;
+                           margin: 0;
+                           padding: 50px 0"
+                    bgcolor="#5bc0de">
+                    <h1 style="max-width: 90%; margin: 0 auto; padding: 0;">Your turn to use {{ tool.name }}</h1>
+                </td>
+            </tr>
+            <tr>
+                <td style="padding: 10px;">
+                    <p>Dear {{ user.first_name }},</p>
+                    <p>This is a notification that it is now your turn to use {{ tool.name }}.</p>
+                    <p>
+                        We will hold your spot in the wait list for {{ time_to_expiration }} minutes. If you do not use the tool within that time, you will be removed from the wait list.
+                    </p>
+                </td>
+            </tr>
+        </table>
+        <!--[if mso]></td></tr></table><![endif]-->
+    </body>
+</html>
diff --git a/resources/fixtures/splash_pad.json b/resources/fixtures/splash_pad.json
index ed0ab0f98..8680bf799 100644
--- a/resources/fixtures/splash_pad.json
+++ b/resources/fixtures/splash_pad.json
@@ -694,6 +694,7 @@
     "pk": 1,
     "fields": {
       "name": "How to handle chemicals",
+      "display_order": 1,
       "description": "This is a very important safety informational item. Please check out the following documents:",
       "category": [
         "Chemical handling"
@@ -705,6 +706,7 @@
     "pk": 2,
     "fields": {
       "name": "Dispose of chemicals",
+      "display_order": 2,
       "description": "Use the bins at your disposals throughout the facility. Check the following link for more information:",
       "category": [
         "Chemical handling"
@@ -716,6 +718,7 @@
     "pk": 3,
     "fields": {
       "name": "Code of conduct",
+      "display_order": 1,
       "description": "Please click on the following document to find out more:",
       "category": [
         "Cleanroom safety"
@@ -727,6 +730,7 @@
     "pk": 1,
     "fields": {
       "safety_item": 1,
+      "display_order": 1,
       "document": "",
       "url": "https://filesamples.com/samples/video/mp4/sample_960x400_ocean_with_audio.mp4",
       "name": "MP4 video example URL",
@@ -738,6 +742,7 @@
     "pk": 2,
     "fields": {
       "safety_item": 1,
+      "display_order": 2,
       "document": "",
       "url": "https://filesamples.com/samples/document/pdf/sample1.pdf",
       "name": "PDF document example URL",
@@ -749,6 +754,7 @@
     "pk": 3,
     "fields": {
       "safety_item": 2,
+      "display_order": 1,
       "document": "",
       "url": "https://filesamples.com/samples/video/ogv/sample_640x360.ogv",
       "name": "OGV video example URL",
@@ -760,6 +766,7 @@
     "pk": 4,
     "fields": {
       "safety_item": 3,
+      "display_order": 1,
       "document": "",
       "url": "https://www.wikihow.com/Enter-a-Cleanroom",
       "name": "Entering a cleanroom",
@@ -981,6 +988,13 @@
       "value": "0"
     }
   },
+  {
+    "model": "NEMO.customization",
+    "pk": "adjustment_requests_apply_button",
+    "fields": {
+      "value": "enabled"
+    }
+  },
   {
     "model": "NEMO.customization",
     "pk": "calendar_all_tools",
@@ -2354,7 +2368,7 @@
       "manager_note": "",
       "new_start": "2023-03-04T15:54:00Z",
       "new_end": "2023-03-05T11:00:00Z",
-      "status": 0,
+      "status": 1,
       "reviewer": null,
       "deleted": false
     }
@@ -2380,7 +2394,7 @@
       "manager_note": "Please enter this in the billing system with the note \"Approved by Captain NEMO\"",
       "new_start": "2023-01-20T23:54:00Z",
       "new_end": "2023-01-21T18:00:00Z",
-      "status": 1,
+      "status": 0,
       "reviewer": [
         "captain"
       ],
@@ -8725,7 +8739,7 @@
       "name": "Total gold used",
       "description": "",
       "value": 203,
-      "tool": 63,
+      "tool": ["Unaxis ICP Etcher"],
       "tool_usage_question": "gold_used",
       "last_reset_value": null,
       "last_reset": null,
@@ -10085,6 +10099,7 @@
     "pk": 1,
     "fields": {
       "name": "Cleaning equipment",
+      "display_order": 1,
       "description": "Please refer to the SOP for equipment and safety rules",
       "category": [
         "Staff procedures"
@@ -10096,6 +10111,7 @@
     "pk": 1,
     "fields": {
       "name": "Equipment training",
+      "display_order": 1,
       "description": "Please contact the tool owner or tool superusers for equipment training.<br>This information is available in the tool control page, under the details tab.",
       "category": [
         "User training"
diff --git a/resources/systemd/nemo_check_update_wait_list.service b/resources/systemd/nemo_check_update_wait_list.service
new file mode 100644
index 000000000..387113aab
--- /dev/null
+++ b/resources/systemd/nemo_check_update_wait_list.service
@@ -0,0 +1,6 @@
+[Unit]
+Description=Check and update tool wait list
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/docker exec nemo django-admin check_and_update_wait_list
diff --git a/resources/systemd/nemo_check_update_wait_list.timer b/resources/systemd/nemo_check_update_wait_list.timer
new file mode 100644
index 000000000..1be9815b3
--- /dev/null
+++ b/resources/systemd/nemo_check_update_wait_list.timer
@@ -0,0 +1,8 @@
+[Unit]
+Description=Check and update tool wait list every minute in NEMO
+
+[Timer]
+OnCalendar=minutely
+
+[Install]
+WantedBy=multi-user.target
diff --git a/resources/systemd/nemo_email_scheduled_outage_reminders.service b/resources/systemd/nemo_email_scheduled_outage_reminders.service
new file mode 100644
index 000000000..9a7d40b53
--- /dev/null
+++ b/resources/systemd/nemo_email_scheduled_outage_reminders.service
@@ -0,0 +1,6 @@
+[Unit]
+Description=Send scheduled outage reminder emails from NEMO
+
+[Service]
+Type=oneshot
+ExecStart=/usr/bin/docker exec nemo django-admin send_email_scheduled_outage_reminders
diff --git a/resources/systemd/nemo_email_scheduled_outage_reminders.timer b/resources/systemd/nemo_email_scheduled_outage_reminders.timer
new file mode 100644
index 000000000..c1c4bb6b3
--- /dev/null
+++ b/resources/systemd/nemo_email_scheduled_outage_reminders.timer
@@ -0,0 +1,8 @@
+[Unit]
+Description=Send scheduled outage reminder emails every day
+
+[Timer]
+OnCalendar=daily
+
+[Install]
+WantedBy=multi-user.target
diff --git a/setup.py b/setup.py
index d1576f2bd..2aa207a89 100644
--- a/setup.py
+++ b/setup.py
@@ -2,7 +2,7 @@
 
 setup(
     name="NEMO",
-    version="5.4.1",
+    version="5.5.0",
     python_requires=">=3.8, <4",
     packages=find_namespace_packages(exclude=["resources", "resources.*", "build", "build.*"]),
     include_package_data=True,
@@ -30,14 +30,14 @@
     install_requires=[
         "cryptography==42.0.5",
         "Django==3.2.25",
-        "django-auditlog==2.3.0",
+        "django-auditlog==3.0.0",
         "django-filter==23.5",
         "django-mptt==0.14.0",
-        "djangorestframework==3.14.0",
+        "djangorestframework==3.15.1",
         "drf-excel==2.4.0",
         "drf-flex-fields==1.0.2",
         "ldap3==2.9.1",
-        "Pillow==10.2.0",
+        "Pillow==10.3.0",
         "pymodbus==3.3.2",
         "python-dateutil==2.9.0",
         "pytz==2024.1",


Test patch:
diff --git a/NEMO/tests/test_calendar/test_outage.py b/NEMO/tests/test_calendar/test_outage.py
index 75070822d..50e768924 100644
--- a/NEMO/tests/test_calendar/test_outage.py
+++ b/NEMO/tests/test_calendar/test_outage.py
@@ -57,7 +57,7 @@ def test_outage_policy_problems(self):
     def outage_policy_problems(self, item_id: int, item_type: ReservationItemType):
         start = datetime.now()
         end = start - timedelta(hours=1)
-        data = self.get_outage_data(start=start, end=end, item_id=item_id, item_type=item_type)
+        data = self.get_outage_data(title="Outage", start=start, end=end, item_id=item_id, item_type=item_type)
 
         # regular user should not be able to create outage
         login_as_user(self.client)
@@ -85,7 +85,7 @@ def outage_policy_problems(self, item_id: int, item_type: ReservationItemType):
             Reservation.objects.create(
                 user=owner, creator=owner, area=area, start=start_reservation, end=end_reservation, short_notice=False
             )
-        data = self.get_outage_data(start=start, end=end, item_id=item_id, item_type=item_type)
+        data = self.get_outage_data(title="Outage", start=start, end=end, item_id=item_id, item_type=item_type)
         response = self.client.post(reverse("create_outage"), data, follow=True)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
@@ -95,7 +95,11 @@ def outage_policy_problems(self, item_id: int, item_type: ReservationItemType):
 
         # try to schedule an outage that starts before but ends slightly after the reservation starts
         data = self.get_outage_data(
-            start=start - timedelta(hours=1), end=end - timedelta(minutes=59), item_id=item_id, item_type=item_type
+            title="Outage",
+            start=start - timedelta(hours=1),
+            end=end - timedelta(minutes=59),
+            item_id=item_id,
+            item_type=item_type,
         )
         response = self.client.post(reverse("create_outage"), data, follow=True)
         self.assertEqual(response.status_code, 400)
@@ -106,7 +110,11 @@ def outage_policy_problems(self, item_id: int, item_type: ReservationItemType):
 
         # try to schedule an outage that starts slightly before the reservation ends
         data = self.get_outage_data(
-            start=start + timedelta(minutes=59), end=end + timedelta(hours=1), item_id=item_id, item_type=item_type
+            title="Outage",
+            start=start + timedelta(minutes=59),
+            end=end + timedelta(hours=1),
+            item_id=item_id,
+            item_type=item_type,
         )
         response = self.client.post(reverse("create_outage"), data, follow=True)
         self.assertEqual(response.status_code, 400)
diff --git a/NEMO/tests/test_sensors/test_modbus_sensors.py b/NEMO/tests/test_sensors/test_modbus_sensors.py
index b0d246b6e..f3735fb24 100644
--- a/NEMO/tests/test_sensors/test_modbus_sensors.py
+++ b/NEMO/tests/test_sensors/test_modbus_sensors.py
@@ -39,9 +39,6 @@ def test_read_coils_function(self, mock_args):
         # result should be 0 or 1 (random), so test a few times to make sure we get both
         first_result = evaluate_modbus_expression(sensor)
         second_result = evaluate_modbus_expression(sensor)
-        print(first_result)
-        print(second_result)
         while first_result == second_result:
             second_result = evaluate_modbus_expression(sensor)
-            print(second_result)
             self.assertIn(second_result, [0, 1])
diff --git a/NEMO/tests/test_tool.py b/NEMO/tests/test_tool.py
index 5460d0ece..e42c92102 100644
--- a/NEMO/tests/test_tool.py
+++ b/NEMO/tests/test_tool.py
@@ -4,8 +4,8 @@
 from django.urls import reverse
 
 from NEMO.admin import InterlockCardAdminForm, ToolAdminForm
-from NEMO.models import Account, Area, Door, Interlock, InterlockCardCategory, PhysicalAccessLevel, Project, Tool, User
-from NEMO.tests.test_utilities import login_as, login_as_access_user, login_as_user
+from NEMO.models import Area, Door, Interlock, InterlockCardCategory, PhysicalAccessLevel, Tool, User
+from NEMO.tests.test_utilities import create_user_and_project, login_as
 
 tool: Optional[Tool] = None
 alternate_tool: Optional[Tool] = None
@@ -54,13 +54,19 @@ def setUp(self):
             "_policy_off_end_time": "4:00 PM",
             "_policy_off_weekend": True,
             "visible": True,
+            "_operation_mode": Tool.OperationMode.REGULAR,
         }
         area_door = Door.objects.create(name="cleanroom door", interlock=cleanroom_interlock)
         area_door.areas.set([cleanroom])
         tool_form = ToolAdminForm(tool_data)
         self.assertTrue(tool_form.is_valid(), tool_form.errors.as_text())
         tool = tool_form.save()
-        alternate_tool_data = {"name": "alt_test_tool", "parent_tool": tool.id, "visible": True}
+        alternate_tool_data = {
+            "name": "alt_test_tool",
+            "parent_tool": tool.id,
+            "visible": True,
+            "_operation_mode": Tool.OperationMode.REGULAR,
+        }
         alternate_tool_form = ToolAdminForm(alternate_tool_data)
         self.assertTrue(alternate_tool_form.is_valid(), alternate_tool_form.errors.as_text())
         alternate_tool = alternate_tool_form.save()
@@ -106,14 +112,10 @@ def test_tool_and_parent_properties(self):
         self.assertEqual(tool.get_absolute_url(), alternate_tool.get_absolute_url())
 
     def test_tool_in_use(self):
-        user = login_as_user(self.client)
+        user, project = create_user_and_project(add_area_access_permissions=True)
         # make the tool operational
         tool.operational = True
         tool.save()
-        project = Project.objects.create(
-            name="test project", application_identifier="sadasd", account=Account.objects.create(name="test account")
-        )
-        user.projects.add(project)
         # user needs to be qualified to use the tool
         user.qualifications.add(tool)
         user.physical_access_levels.add(PhysicalAccessLevel.objects.get(name="cleanroom access"))
@@ -121,7 +123,7 @@ def test_tool_in_use(self):
         user.training_required = False
         user.save()
         # log into the area
-        login_as_access_user(self.client)
+        login_as(self.client, user)
         response = self.client.post(
             reverse("login_to_area", kwargs={"door_id": area_door.id}), {"badge_number": user.badge_number}, follow=True
         )
diff --git a/NEMO/tests/test_urls.py b/NEMO/tests/test_urls.py
index f3d89bfdb..d7d263250 100644
--- a/NEMO/tests/test_urls.py
+++ b/NEMO/tests/test_urls.py
@@ -28,9 +28,13 @@
     "cancel_reservation_from_kiosk": {"kwargs": {"reservation_id": 1}, "post": {"customer_id": 1}},
     "kiosk_category_choices": {"kwargs": {"category": "test", "user_id": 1}},
     "kiosk_tool_information": {"kwargs": {"tool_id": 1, "user_id": 1, "back": "back_to_start"}},
+    "kiosk_tool_post_comment": {"kwargs": {"tool_id": 1, "user_id": 1, "back": "back_to_start"}},
+    "kiosk_tool_report_problem": {"kwargs": {"tool_id": 1, "user_id": 1, "back": "back_to_start"}},
     "enable_tool_from_kiosk": {"post": {"tool_id": 1, "customer_id": 1, "project_id": 1}},
     "disable_tool_from_kiosk": {"post": {"tool_id": 1, "customer_id": 1}},
     "reserve_tool_from_kiosk": {"post": {"tool_id": 1, "customer_id": 1, "project_id": 1, "back": ""}},
+    "post_comment_from_kiosk": {"post": {"tool": 1, "customer_id": 1, "back": "back_to_start"}},
+    "report_problem_from_kiosk": {"post": {"tool": 1, "customer_id": 1, "back": "back_to_start"}},
     "login_to_area": {"kwargs": {"door_id": 1}, "post": {"badge_number": 1}},
     "logout_of_area": {"kwargs": {"door_id": 1}, "post": {"badge_number": 1}},
     "open_door": {"kwargs": {"door_id": 1}, "post": {"badge_number": 1}},
@@ -101,6 +105,10 @@
     "customize": {"kwargs": {"key": "application"}, "post": {"facility_name": "test facility"}},
     "create_adjustment_request": {"kwargs": {}},
     "edit_adjustment_request": {"kwargs": {"request_id": 1}},
+    "mark_adjustment_as_applied": {"login_id": 1},
+    "apply_adjustment": {"login_id": 1},
+    "delete_adjustment_request": {"kwargs": {"request_id": 2}, "login_id": 3},
+    "adjustment_request_reply": {"kwargs": {"request_id": 2}, "login_id": 3, "post": {"reply_content": "new message"}},
     "change_reservation_date": {
         "login_id": 1,
         "post": {
@@ -131,6 +139,7 @@
 }
 
 urls_to_skip = [
+    "api-root",
     "kiosk_tool_reservation",
     "cancel_reservation_from_kiosk",
     "create_reservation",
@@ -148,8 +157,11 @@
     "media_view",
     "media_list_view",
     "remove_document_from_project",
-    "adjustment_request_reply",
-    "delete_adjustment_request",
+    "enter_wait_list_from_kiosk",
+    "exit_wait_list_from_kiosk",
+    "enter_wait_list",
+    "exit_wait_list",
+    "check_and_update_wait_list",
 ]
 
 
diff --git a/NEMO/tests/test_utilities.py b/NEMO/tests/test_utilities.py
index 0bf14e30e..0641cd978 100644
--- a/NEMO/tests/test_utilities.py
+++ b/NEMO/tests/test_utilities.py
@@ -28,15 +28,6 @@ def login_as_user(client: Client) -> User:
     return user
 
 
-def login_as_access_user(client: Client) -> User:
-    user, created = User.objects.get_or_create(username="area_access_user", first_name="Area", last_name="Access")
-    user.user_permissions.add(Permission.objects.get(codename="add_areaaccessrecord"))
-    user.user_permissions.add(Permission.objects.get(codename="change_areaaccessrecord"))
-    user.save()
-    login_as(client, user)
-    return user
-
-
 def login_as_user_with_permissions(client: Client, permissions: List[str]) -> User:
     user, created = User.objects.get_or_create(
         username="test_user", first_name="Testy", last_name="McTester", badge_number=222222
@@ -64,7 +55,9 @@ def validate_model_error(test_case: TestCase, model, error_fields, strict=False)
                 test_case.fail(f"{diff2} have errors but shouldn't")
 
 
-def create_user_and_project(is_staff=False) -> Tuple[User, Project]:
+def create_user_and_project(
+    is_staff=False, add_kiosk_permission=False, add_area_access_permissions=False
+) -> Tuple[User, Project]:
     count = User.objects.count()
     user: User = User.objects.create(
         first_name="Testy",
@@ -73,6 +66,11 @@ def create_user_and_project(is_staff=False) -> Tuple[User, Project]:
         email=f"test{count}@test.com",
         is_staff=is_staff,
     )
+    if add_kiosk_permission:
+        user.user_permissions.add(Permission.objects.get(codename="kiosk"))
+    if add_area_access_permissions:
+        user.user_permissions.add(Permission.objects.get(codename="add_areaaccessrecord"))
+        user.user_permissions.add(Permission.objects.get(codename="change_areaaccessrecord"))
     project = Project.objects.create(
         name=f"TestProject{count}", account=Account.objects.create(name=f"TestAccount{count}")
     )
diff --git a/NEMO/tests/test_waitlist.py b/NEMO/tests/test_waitlist.py
new file mode 100644
index 000000000..632bb570d
--- /dev/null
+++ b/NEMO/tests/test_waitlist.py
@@ -0,0 +1,850 @@
+from django.core import mail
+from django.test import TestCase
+from django.urls import reverse
+from django.utils import timezone
+
+from NEMO.models import Reservation, Tool, ToolWaitList, UsageEvent, User
+from NEMO.tests.test_utilities import create_user_and_project, login_as
+from NEMO.views.customization import EmailsCustomization, ToolCustomization, store_media_file
+from NEMO.views.timed_services import do_check_and_update_wait_list
+from NEMO.views.tool_control import do_exit_wait_list
+
+
+class ToolTestCase(TestCase):
+    def setUp(self) -> None:
+        EmailsCustomization.set("user_office_email_address", "user_office_email_address@wait_list_test.com")
+        store_media_file(
+            open("resources/emails/wait_list_notification_email.html", "r"), "wait_list_notification_email.html"
+        )
+
+    def test_user_enter_wait_list_regular_mode_fail(self):
+        self.enter_wait_list_mode_fail(reverse("enter_wait_list"), Tool.OperationMode.REGULAR)
+
+    def test_user_enter_wait_list_regular_mode_kiosk_fail(self):
+        self.enter_wait_list_mode_fail(reverse("enter_wait_list_from_kiosk"), Tool.OperationMode.REGULAR, True)
+
+    def test_user_enter_wait_list_wait_list_mode_pass(self):
+        self.enter_wait_list_mode_pass(reverse("enter_wait_list"), Tool.OperationMode.WAIT_LIST)
+
+    def test_user_enter_wait_list_wait_list_mode_kiosk_pass(self):
+        self.enter_wait_list_mode_pass(reverse("enter_wait_list_from_kiosk"), Tool.OperationMode.WAIT_LIST)
+
+    def test_user_enter_wait_list_hybrid_mode_pass(self):
+        self.enter_wait_list_mode_pass(reverse("enter_wait_list"), Tool.OperationMode.HYBRID)
+
+    def test_user_enter_wait_list_hybrid_mode_kiosk_pass(self):
+        self.enter_wait_list_mode_pass(reverse("enter_wait_list_from_kiosk"), Tool.OperationMode.HYBRID)
+
+    def test_user_enter_wait_list_twice(self):
+        self.user_enter_wait_list_twice(reverse("enter_wait_list"))
+
+    def test_user_enter_wait_list_kiosk_twice(self):
+        self.user_enter_wait_list_twice(reverse("enter_wait_list_from_kiosk"), True)
+
+    def test_user_exit_wait_list(self):
+        self.exit_wait_list_pass(reverse("enter_wait_list"), reverse("exit_wait_list"))
+
+    def test_user_exit_wait_list_kiosk(self):
+        self.exit_wait_list_pass(reverse("enter_wait_list_from_kiosk"), reverse("exit_wait_list_from_kiosk"))
+
+    def test_user_exit_wait_list_not_in_wait_list(self):
+        self.exit_wait_list_not_in_wait_list(reverse("exit_wait_list"))
+
+    def test_user_exit_wait_list_kiosk_not_in_wait_list(self):
+        self.exit_wait_list_not_in_wait_list(reverse("exit_wait_list_from_kiosk"), True)
+
+    def test_wait_list_mode_reservation(self):
+        time_to_expiration_saved, reservation_buffer_saved = get_configuration()
+        try:
+            (
+                time_to_expiration,
+                reservation_buffer,
+                user,
+                project,
+                user1,
+                project1,
+                user2,
+                project2,
+                tool,
+                usage,
+                email_outbox_count,
+                current_wait_list,
+                user1_wait_list_entry,
+                end_usage_date,
+            ) = self.starting_sequence(Tool.OperationMode.WAIT_LIST, 5, 10)
+
+            # reservation starting in 5 minutes (within reservation buffer threshold)
+            reservation_start = end_usage_date + timezone.timedelta(minutes=reservation_buffer - 2)
+            create_reservation(user, tool, reservation_start, reservation_start + timezone.timedelta(minutes=30))
+
+            # run timed service as end_usage_date
+            time = run_time_service_as(end_usage_date)
+
+            # User 1 should be sent an email notification
+            email_outbox_count += 1
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, end_usage_date, email_outbox_count
+            )
+
+            # Expire user1 then user2
+            self.expiring_user1_then_user2_sequence(
+                tool, user1_wait_list_entry, user2, time, time_to_expiration, email_outbox_count
+            )
+
+        finally:
+            setup_configuration(time_to_expiration_saved, reservation_buffer_saved)
+
+    def test_wait_list_mode_user_exit(self):
+        self.user_exit(Tool.OperationMode.WAIT_LIST)
+
+    def test_hybrid_mode_user_exit(self):
+        self.user_exit(Tool.OperationMode.HYBRID)
+
+    def test_wait_list_mode_user_exit_out_of_order(self):
+        self.user_exit_out_of_order(Tool.OperationMode.WAIT_LIST)
+
+    def test_hybrid_mode_user_exit_out_of_order(self):
+        self.user_exit_out_of_order(Tool.OperationMode.HYBRID)
+
+    def test_wait_list_mode_no_usage(self):
+        self.no_usage(Tool.OperationMode.WAIT_LIST)
+
+    def test_wait_list_mode_usage_during_turn(self):
+        self.usage_during_turn(Tool.OperationMode.WAIT_LIST)
+
+    def test_hybrid_mode_no_usage(self):
+        self.no_usage(Tool.OperationMode.HYBRID)
+
+    def test_hybrid_mode_usage_during_turn(self):
+        self.usage_during_turn(Tool.OperationMode.HYBRID)
+
+    def test_hybrid_mode_reservation(self):
+        time_to_expiration_saved, reservation_buffer_saved = get_configuration()
+        try:
+            (
+                time_to_expiration,
+                reservation_buffer,
+                user,
+                project,
+                user1,
+                project1,
+                user2,
+                project2,
+                tool,
+                usage,
+                email_outbox_count,
+                current_wait_list,
+                user1_wait_list_entry,
+                end_usage_date,
+            ) = self.starting_sequence(Tool.OperationMode.HYBRID, 5, 10)
+
+            # reservation starting within reservation buffer threshold 2 thirds of the way
+            reservation_start = end_usage_date + timezone.timedelta(minutes=reservation_buffer * (2 / 3))
+            reservation_end = reservation_start + timezone.timedelta(minutes=30)
+            create_reservation(user, tool, reservation_start, reservation_end)
+
+            # run timed service halfway within buffer zone
+            run_time_service_as(end_usage_date + timezone.timedelta(minutes=reservation_buffer / 2))
+
+            # During the reservation buffer zone, wait list check should be skipped
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, None, email_outbox_count
+            )
+
+            # run timed service 5 minutes before reservation end
+            run_time_service_as(reservation_end - timezone.timedelta(minutes=5))
+
+            # During the reservation period, wait list check should be skipped
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, None, email_outbox_count
+            )
+
+            # run timed service when reservation ends
+            time = run_time_service_as(reservation_end)
+
+            # After the reservation period, turn available date should be reservation end date and email notification sent
+            email_outbox_count += 1
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, reservation_end, email_outbox_count
+            )
+
+            # Expire user1 then user2
+            self.expiring_user1_then_user2_sequence(
+                tool, user1_wait_list_entry, user2, time, time_to_expiration, email_outbox_count
+            )
+
+        finally:
+            setup_configuration(time_to_expiration_saved, reservation_buffer_saved)
+
+    def test_hybrid_mode_reservation_missed(self):
+        time_to_expiration_saved, reservation_buffer_saved = get_configuration()
+        try:
+            missed_threshold = 5
+            (
+                time_to_expiration,
+                reservation_buffer,
+                user,
+                project,
+                user1,
+                project1,
+                user2,
+                project2,
+                tool,
+                usage,
+                email_outbox_count,
+                current_wait_list,
+                user1_wait_list_entry,
+                end_usage_date,
+            ) = self.starting_sequence(Tool.OperationMode.HYBRID, 5, 10, missed_threshold)
+
+            # reservation starting within reservation buffer threshold 2 thirds of the way
+            reservation_start = end_usage_date + timezone.timedelta(minutes=reservation_buffer * (2 / 3))
+            reservation_end = reservation_start + timezone.timedelta(minutes=30)
+            reservation = create_reservation(user, tool, reservation_start, reservation_end)
+
+            # run timed service halfway within the reservation missed threshold
+            run_time_service_as(reservation_start + timezone.timedelta(minutes=missed_threshold / 2))
+
+            # within missed threshold, wait list check should be skipped
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, None, email_outbox_count
+            )
+
+            # Missed reservation
+            missed_time = reservation_start + timezone.timedelta(minutes=missed_threshold)
+            reservation.missed = True
+            reservation.save()
+
+            # Run timed service after missed threshold
+            time = run_time_service_as(missed_time)
+
+            # After the reservation is missed, turn available date should be missed date and email notification sent
+            email_outbox_count += 1
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, missed_time, email_outbox_count
+            )
+
+            # Expire user1 then user2
+            self.expiring_user1_then_user2_sequence(
+                tool, user1_wait_list_entry, user2, time, time_to_expiration, email_outbox_count
+            )
+
+        finally:
+            setup_configuration(time_to_expiration_saved, reservation_buffer_saved)
+
+    def test_hybrid_mode_reservation_and_usage(self):
+        time_to_expiration_saved, reservation_buffer_saved = get_configuration()
+        try:
+            (
+                time_to_expiration,
+                reservation_buffer,
+                user,
+                project,
+                user1,
+                project1,
+                user2,
+                project2,
+                tool,
+                usage,
+                email_outbox_count,
+                current_wait_list,
+                user1_wait_list_entry,
+                end_usage_date,
+            ) = self.starting_sequence(Tool.OperationMode.HYBRID, 5, 10)
+
+            # reservation starting within reservation buffer threshold 2 thirds of the way
+            reservation_start = end_usage_date + timezone.timedelta(minutes=reservation_buffer * (2 / 3))
+            reservation_end = reservation_start + timezone.timedelta(minutes=30)
+            create_reservation(user, tool, reservation_start, reservation_end)
+
+            # run timed service halfway within buffer zone
+            run_time_service_as(end_usage_date + timezone.timedelta(minutes=reservation_buffer / 2))
+
+            # During the reservation buffer zone, wait list check should be skipped
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, None, email_outbox_count
+            )
+
+            # run timed service 5 minutes before reservation end
+            run_time_service_as(reservation_end - timezone.timedelta(minutes=5))
+
+            # During the reservation period, wait list check should be skipped
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, None, email_outbox_count
+            )
+
+            # Usage has started during the reservation period (5minutes before the end)
+            second_usage = create_usage(user, project, tool, reservation_end - timezone.timedelta(minutes=5), None)
+
+            # run timed service after reservation end
+            time = run_time_service_as(reservation_end + timezone.timedelta(minutes=10))
+
+            # After reservation end, wait list check should be skipped because there is a usage going on
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, None, email_outbox_count
+            )
+
+            # End second usage
+            second_usage_end = end_usage(second_usage, time + timezone.timedelta(minutes=5))
+
+            # run timed service after second usage end
+            time = run_time_service_as(second_usage_end + timezone.timedelta(minutes=10))
+
+            # After the second usage ends, turn available date should be second usage end date and email notification sent
+            email_outbox_count += 1
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, second_usage_end, email_outbox_count
+            )
+
+            # Expire user1 then user2
+            self.expiring_user1_then_user2_sequence(
+                tool, user1_wait_list_entry, user2, time, time_to_expiration, email_outbox_count
+            )
+
+        finally:
+            setup_configuration(time_to_expiration_saved, reservation_buffer_saved)
+
+    def test_hybrid_mode_consecutive_reservations_with_gap(self):
+        time_to_expiration_saved, reservation_buffer_saved = get_configuration()
+        try:
+            (
+                time_to_expiration,
+                reservation_buffer,
+                user,
+                project,
+                user1,
+                project1,
+                user2,
+                project2,
+                tool,
+                usage,
+                email_outbox_count,
+                current_wait_list,
+                user1_wait_list_entry,
+                end_usage_date,
+            ) = self.starting_sequence(Tool.OperationMode.HYBRID, 5, 10)
+
+            # reservation one starting within reservation buffer threshold 2 thirds of the way
+            reservation_one_start = end_usage_date + timezone.timedelta(minutes=reservation_buffer * (2 / 3))
+            reservation_one_end = reservation_one_start + timezone.timedelta(minutes=30)
+            create_reservation(user, tool, reservation_one_start, reservation_one_end)
+
+            # reservation two starting in reservation_buffer + 2 minutes gap after reservation one
+            reservation_two_start = reservation_one_end + timezone.timedelta(minutes=reservation_buffer + 2)
+            reservation_two_end = reservation_two_start + timezone.timedelta(minutes=30)
+            create_reservation(user, tool, reservation_two_start, reservation_two_end)
+
+            # run timed service halfway within buffer zone of reservation one
+            run_time_service_as(end_usage_date + timezone.timedelta(minutes=reservation_buffer / 2))
+
+            # During the reservation one buffer zone, wait list check should be skipped
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, None, email_outbox_count
+            )
+
+            # run timed service 5 minutes before reservation one end
+            run_time_service_as(reservation_one_end - timezone.timedelta(minutes=5))
+
+            # During the reservation one period, wait list check should be skipped
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, None, email_outbox_count
+            )
+
+            # run timed service in gap between reservation one and reservation two buffer zone
+            run_time_service_as(reservation_one_end + timezone.timedelta(minutes=1))
+
+            # During the gap between reservation one and reservation two
+            # turn available should be end of reservation one and email notification sent
+            email_outbox_count += 1
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, reservation_one_end, email_outbox_count
+            )
+
+            # run timed service in reservation two (5 minutes before the end)
+            run_time_service_as(reservation_two_end - timezone.timedelta(minutes=1))
+
+            # During the reservation two period (5 minutes before the end), wait list check should be skipped
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, reservation_one_end, email_outbox_count
+            )
+
+            # run timed service after reservation two end
+            time = run_time_service_as(reservation_two_end)
+
+            # After the reservation two has ended
+            # turn available should be reservation two end and email notification sent
+            email_outbox_count += 1
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, reservation_two_end, email_outbox_count
+            )
+
+            # Expire user1 then user2
+            self.expiring_user1_then_user2_sequence(
+                tool, user1_wait_list_entry, user2, time, time_to_expiration, email_outbox_count
+            )
+
+        finally:
+            setup_configuration(time_to_expiration_saved, reservation_buffer_saved)
+
+    def enter_wait_list(self, user, tool):
+        login_as(self.client, user)
+        response = self.client.post(
+            reverse("enter_wait_list"),
+            {"tool_id": tool.id},
+        )
+        self.assertEqual(response.status_code, 200)
+
+    def exit_wait_list(self, user, tool, time):
+        wait_list_entry = ToolWaitList.objects.filter(tool=tool, expired=False, deleted=False, user=user)
+        do_exit_wait_list(wait_list_entry, time)
+
+    def setup_entities(self, mode, missed_threshold=None):
+        user, project = create_user_and_project(True)
+        user1, project1 = create_user_and_project(True)
+        user2, project2 = create_user_and_project(True)
+        tool = create_tool("WaitList Test Tool", mode, missed_threshold)
+        usage = create_usage(user, project, tool, timezone.now() - timezone.timedelta(minutes=10), None)
+        return user, project, user1, project1, user2, project2, tool, usage
+
+    def check_wail_list_size_and_top(self, tool, user, size):
+        current_wait_list = tool.current_wait_list()
+        wait_list_entry = tool.top_wait_list_entry()
+        self.assertEqual(current_wait_list.count(), size)
+        self.assertEqual(wait_list_entry.user.id, user)
+        return current_wait_list, wait_list_entry
+
+    def check_entry_user_and_timer_start_date_and_email_count(self, wait_list_entry, user, time, email_count):
+        wait_list_entry.refresh_from_db()
+        self.assertEqual(wait_list_entry.last_turn_available_at, time)
+        self.assertEqual(wait_list_entry.user.id, user.id)
+        self.assertEqual(wait_list_entry.expired, False)
+        self.assertEqual(len(mail.outbox), email_count)
+
+    def check_entry_has_expired(self, tool, wait_list_entry, new_size):
+        wait_list_entry.refresh_from_db()
+        current_wait_list = tool.current_wait_list()
+        self.assertEqual(wait_list_entry.expired, True)
+        self.assertEqual(current_wait_list.count(), new_size)
+
+    def starting_sequence(self, mode, time_to_expiration, reservation_buffer, missed_threshold=None):
+        time_to_expiration, reservation_buffer = setup_configuration(time_to_expiration, reservation_buffer)
+        user, project, user1, project1, user2, project2, tool, usage = self.setup_entities(mode, missed_threshold)
+        email_outbox_count = 0
+
+        # User 1 enter wait list
+        self.enter_wait_list(user1, tool)
+
+        # User 2 enter wait list
+        self.enter_wait_list(user2, tool)
+
+        # Check wait list size and top
+        current_wait_list, user1_wait_list_entry = self.check_wail_list_size_and_top(tool, user1.id, 2)
+
+        now = timezone.now()
+
+        # end current usage
+        end_usage_date = end_usage(usage, now)
+
+        return (
+            time_to_expiration,
+            reservation_buffer,
+            user,
+            project,
+            user1,
+            project1,
+            user2,
+            project2,
+            tool,
+            usage,
+            email_outbox_count,
+            current_wait_list,
+            user1_wait_list_entry,
+            end_usage_date,
+        )
+
+    def expiring_user1_then_user2_sequence(
+        self, tool, user1_entry, user2, time, time_to_expiration, email_outbox_count
+    ):
+        # After time_to_expiration minutes
+        # This tick expires user1
+        time = run_time_service_as(time + timezone.timedelta(minutes=time_to_expiration))
+        # This tick bumps user2 to the top of the wait list
+        time = run_time_service_as(time)
+
+        # Expect user1 to be removed from the wait list (EXPIRED=TRUE)
+        self.check_entry_has_expired(tool, user1_entry, 1)
+
+        # Expect user2 to be at the top of wait list and sent an email notification
+        user2_wait_list_entry = tool.top_wait_list_entry()
+        email_outbox_count += 1
+        self.check_entry_user_and_timer_start_date_and_email_count(
+            user2_wait_list_entry, user2, time, email_outbox_count
+        )
+
+        # After time_to_expiration minutes
+        time = run_time_service_as(time + timezone.timedelta(minutes=time_to_expiration))
+
+        # Expect user2 to be removed from the wait list (EXPIRED=TRUE)
+        self.check_entry_has_expired(tool, user2_wait_list_entry, 0)
+
+    def no_usage(self, mode):
+        time_to_expiration_saved, reservation_buffer_saved = get_configuration()
+        try:
+            (
+                time_to_expiration,
+                reservation_buffer,
+                user,
+                project,
+                user1,
+                project1,
+                user2,
+                project2,
+                tool,
+                usage,
+                email_outbox_count,
+                current_wait_list,
+                user1_wait_list_entry,
+                end_usage_date,
+            ) = self.starting_sequence(mode, 5, 10)
+
+            # run timed service as end_usage_date
+            time = run_time_service_as(end_usage_date)
+
+            # User 1 should be sent an email notification
+            email_outbox_count += 1
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, end_usage_date, email_outbox_count
+            )
+
+            # Expire user1 then user2
+            self.expiring_user1_then_user2_sequence(
+                tool, user1_wait_list_entry, user2, time, time_to_expiration, email_outbox_count
+            )
+
+        finally:
+            setup_configuration(time_to_expiration_saved, reservation_buffer_saved)
+
+    def usage_during_turn(self, mode):
+        time_to_expiration_saved, reservation_buffer_saved = get_configuration()
+        try:
+            (
+                time_to_expiration,
+                reservation_buffer,
+                user,
+                project,
+                user1,
+                project1,
+                user2,
+                project2,
+                tool,
+                usage,
+                email_outbox_count,
+                current_wait_list,
+                user1_wait_list_entry,
+                end_usage_date,
+            ) = self.starting_sequence(mode, 5, 10)
+
+            # run timed service as end_usage_date
+            time = run_time_service_as(end_usage_date)
+
+            # User 1 should be sent an email notification
+            email_outbox_count += 1
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, end_usage_date, email_outbox_count
+            )
+
+            # After 2 minutes a random user starts using the tool (skip line)
+            time = time + timezone.timedelta(minutes=2)
+            second_usage = create_usage(user, project, tool, time, None)
+
+            # run timed service after 10 minutes
+            time = run_time_service_as(time + timezone.timedelta(minutes=10))
+
+            # User 1 should stay on top of the list and not be sent an email notification
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, end_usage_date, email_outbox_count
+            )
+
+            # end second usage 2 minutes later
+            end_second_usage_date = end_usage(second_usage, time + timezone.timedelta(minutes=2))
+
+            # run timed service
+            time = run_time_service_as(end_second_usage_date)
+
+            # User 1 should be sent another email notification and turn available date should be updated
+            email_outbox_count += 1
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, end_second_usage_date, email_outbox_count
+            )
+
+            # Expire user1 then user2
+            self.expiring_user1_then_user2_sequence(
+                tool, user1_wait_list_entry, user2, time, time_to_expiration, email_outbox_count
+            )
+
+        finally:
+            setup_configuration(time_to_expiration_saved, reservation_buffer_saved)
+
+    def user_exit(self, mode):
+        time_to_expiration_saved, reservation_buffer_saved = get_configuration()
+        try:
+            (
+                time_to_expiration,
+                reservation_buffer,
+                user,
+                project,
+                user1,
+                project1,
+                user2,
+                project2,
+                tool,
+                usage,
+                email_outbox_count,
+                current_wait_list,
+                user1_wait_list_entry,
+                end_usage_date,
+            ) = self.starting_sequence(mode, 5, 10)
+
+            # run timed service as end_usage_date
+            time = run_time_service_as(end_usage_date)
+
+            # User 1 should be sent an email notification
+            email_outbox_count += 1
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, end_usage_date, email_outbox_count
+            )
+
+            # user 1 exits the wait list 1 minute after the end_usage_date
+            time_of_exit = end_usage_date + timezone.timedelta(minutes=1)
+            self.exit_wait_list(user1, tool, time_of_exit)
+            user1_wait_list_entry.refresh_from_db()
+
+            # After halfway through expiration time for user2 (time user1 exits + time_to_expiration / 2)
+            time = run_time_service_as(
+                user1_wait_list_entry.date_exited + timezone.timedelta(minutes=time_to_expiration / 2)
+            )
+
+            # Expect user2 to be at the top of wait list and sent an email notification
+            user2_wait_list_entry = tool.top_wait_list_entry()
+            email_outbox_count += 1
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user2_wait_list_entry, user2, time_of_exit, email_outbox_count
+            )
+
+            # After time_to_expiration minutes
+            run_time_service_as(user1_wait_list_entry.date_exited + timezone.timedelta(minutes=time_to_expiration))
+
+            # Expect user2 to be removed from the wait list (EXPIRED=TRUE)
+            self.check_entry_has_expired(tool, user2_wait_list_entry, 0)
+
+        finally:
+            setup_configuration(time_to_expiration_saved, reservation_buffer_saved)
+
+    def user_exit_out_of_order(self, mode):
+        time_to_expiration_saved, reservation_buffer_saved = get_configuration()
+        try:
+            (
+                time_to_expiration,
+                reservation_buffer,
+                user,
+                project,
+                user1,
+                project1,
+                user2,
+                project2,
+                tool,
+                usage,
+                email_outbox_count,
+                current_wait_list,
+                user1_wait_list_entry,
+                end_usage_date,
+            ) = self.starting_sequence(mode, 5, 10)
+
+            user3, project3 = create_user_and_project(True)
+
+            # User 3 enter wait list
+            self.enter_wait_list(user3, tool)
+
+            # run timed service as end_usage_date
+            run_time_service_as(end_usage_date)
+
+            # User 1 should be sent an email notification
+            email_outbox_count += 1
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user1_wait_list_entry, user1, end_usage_date, email_outbox_count
+            )
+
+            # user 2 exits the wait list 1 minute after the end_usage_date
+            time_of_user2_exit = end_usage_date + timezone.timedelta(minutes=1)
+            self.exit_wait_list(user2, tool, time_of_user2_exit)
+
+            # user 1 exits the wait list 2 minute after the end_usage_date
+            time_of_user1_exit = end_usage_date + timezone.timedelta(minutes=2)
+            self.exit_wait_list(user1, tool, time_of_user1_exit)
+            user1_wait_list_entry.refresh_from_db()
+
+            # After 3 minutes
+            time = run_time_service_as(end_usage_date + timezone.timedelta(minutes=3))
+
+            # Expect user3 to be at the top of wait list and sent an email notification
+            user3_wait_list_entry = tool.top_wait_list_entry()
+            email_outbox_count += 1
+            self.check_entry_user_and_timer_start_date_and_email_count(
+                user3_wait_list_entry, user3, time_of_user1_exit, email_outbox_count
+            )
+
+            # After time_to_expiration minutes
+            run_time_service_as(time_of_user1_exit + timezone.timedelta(minutes=time_to_expiration))
+
+            # Expect user3 to be removed from the wait list (EXPIRED=TRUE)
+            self.check_entry_has_expired(tool, user3_wait_list_entry, 0)
+
+        finally:
+            setup_configuration(time_to_expiration_saved, reservation_buffer_saved)
+
+    def enter_wait_list_mode_fail(self, url, mode, kiosk=False):
+        user, project = create_user_and_project(True, True)
+        tool = create_tool("WaitList Test Tool", mode)
+        usage = create_usage(user, project, tool, timezone.now() - timezone.timedelta(minutes=10), None)
+        login_as(self.client, user)
+        response = self.client.post(
+            url,
+            {"tool_id": tool.id, "customer_id": user.id},
+            follow=True,
+        )
+        self.assertContains(response, "does not operate in wait list mode", status_code=200 if kiosk else 400)
+        self.assertEqual(tool.current_wait_list().count(), 0)
+
+    def enter_wait_list_mode_pass(self, url, mode):
+        user, project = create_user_and_project(True, True)
+        tool = create_tool("WaitList Test Tool", mode)
+        usage = create_usage(user, project, tool, timezone.now() - timezone.timedelta(minutes=10), None)
+        login_as(self.client, user)
+        response = self.client.post(
+            url,
+            {"tool_id": tool.id, "customer_id": user.id},
+            follow=True,
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(tool.current_wait_list().count(), 1)
+        self.assertEqual(tool.top_wait_list_entry().user, user)
+
+    def user_enter_wait_list_twice(self, url, kiosk=False):
+        user, project = create_user_and_project(True, True)
+        tool = create_tool("WaitList Test Tool", Tool.OperationMode.WAIT_LIST)
+        usage = create_usage(user, project, tool, timezone.now() - timezone.timedelta(minutes=10), None)
+        login_as(self.client, user)
+        response = self.client.post(
+            url,
+            {"tool_id": tool.id, "customer_id": user.id},
+            follow=True,
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(tool.current_wait_list().count(), 1)
+        self.assertEqual(tool.top_wait_list_entry().user, user)
+
+        response = self.client.post(
+            url,
+            {"tool_id": tool.id, "customer_id": user.id},
+            follow=True,
+        )
+        self.assertContains(response, "already in the wait list", status_code=200 if kiosk else 400)
+        self.assertEqual(tool.current_wait_list().count(), 1)
+        self.assertEqual(tool.top_wait_list_entry().user, user)
+
+    def exit_wait_list_pass(self, enter_url, exit_url):
+        user, project = create_user_and_project(True, True)
+        tool = create_tool("WaitList Test Tool", Tool.OperationMode.WAIT_LIST)
+        usage = create_usage(user, project, tool, timezone.now() - timezone.timedelta(minutes=10), None)
+        login_as(self.client, user)
+        response = self.client.post(
+            enter_url,
+            {"tool_id": tool.id, "customer_id": user.id},
+            follow=True,
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(tool.current_wait_list().count(), 1)
+        self.assertEqual(tool.top_wait_list_entry().user, user)
+        response = self.client.post(
+            exit_url,
+            {"tool_id": tool.id, "customer_id": user.id},
+            follow=True,
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(tool.current_wait_list().count(), 0)
+
+    def exit_wait_list_not_in_wait_list(self, url, kiosk=False):
+        user, project = create_user_and_project(True, True)
+        tool = create_tool("WaitList Test Tool", Tool.OperationMode.WAIT_LIST)
+        login_as(self.client, user)
+        response = self.client.post(
+            url,
+            {"tool_id": tool.id, "customer_id": user.id},
+            follow=True,
+        )
+        self.assertContains(response, "not in the wait list", status_code=200 if kiosk else 400)
+        self.assertEqual(tool.current_wait_list().count(), 0)
+
+
+def create_tool(name, mode, missed_threshold=None):
+    return Tool.objects.create(
+        name="[Test Tool] " + name,
+        _operation_mode=mode,
+        _missed_reservation_threshold=missed_threshold,
+    )
+
+
+def create_usage(user, project, tool, start, end=None):
+    return UsageEvent.objects.create(
+        user=user,
+        operator=user,
+        project=project,
+        tool=tool,
+        start=start,
+        end=end,
+    )
+
+
+def create_user(is_facility_manager=False):
+    count = User.objects.count()
+    return User.objects.create(
+        first_name="Testy",
+        last_name="McTester",
+        username=f"test{count}",
+        email=f"test{count}@test.com",
+        is_facility_manager=is_facility_manager,
+    )
+
+
+def create_reservation(user, tool, start, end):
+    return Reservation.objects.create(
+        tool=tool,
+        start=start,
+        end=end,
+        creator=user,
+        user=user,
+        short_notice=False,
+    )
+
+
+def end_usage(usage, time):
+    usage.end = time
+    usage.save()
+    return time
+
+
+def run_time_service_as(time):
+    do_check_and_update_wait_list(time)
+    return time
+
+
+def setup_configuration(time_to_expiration, reservation_buffer):
+    ToolCustomization.set("tool_wait_list_spot_expiration", time_to_expiration)
+    ToolCustomization.set("tool_wait_list_reservation_buffer", reservation_buffer)
+    return time_to_expiration, reservation_buffer
+
+
+def get_configuration():
+    time_to_expiration = ToolCustomization.get("tool_wait_list_spot_expiration")
+    reservation_buffer = ToolCustomization.get("tool_wait_list_reservation_buffer")
+    return time_to_expiration, reservation_buffer