Repository: usnistgov/NEMO (language: python)

Issue:
[Feature req] Confirmation when booking reservation
When booking a tool reservation a confirmation pop-up would be helpful.  This could be a user preference, in case some users prefer the no pop-up experience.

To (presumably) minimize additional code that needs to be written, one implementation might be...
After dragging out a time slot, pop up the "reservation details" window that normally appears when you click an existing reservation.  Add a "confirm" button to this window, beside the existing cancel button.
Tooltips in kiosk usage table allow users to browse NEMO
Hi,
I recently found out a bug on the changes we introduced in #178.
In particular, the kiosk usage table (now shared with Jumbotron and Tool Usage) also renders the tool tooltip and the corresponding links in it.
This means it can send user to either `/tool_control/<tool id>`, '/get_email_form_for_user/<owner or backup>', `/get_email_form` or `tel:/<phone>`.
All options (except the latter) let user access the whole booking system under `kiosk`user.
Security-wise this is not a huge deal as long as the user is correctly configured with minimum permission, but can still leave the kiosk in a non-working state which requires reboot.
Also, the users can log out from the kiosk user and leave the kiosk in a non-working state which additionally requires to re-insert credentials.

I was looking at possible fixes for submitting a PR. Not sure if it is possible to add a call to hide links in [fetch_usage](https://github.com/usnistgov/NEMO/blob/8093659c879294f9d197d5dfc2cc2d5aa0c2386b/NEMO/apps/kiosk/templates/kiosk/kiosk.html#L350) or should be addressed elsewhere (maybe [here](https://github.com/usnistgov/NEMO/blob/8093659c879294f9d197d5dfc2cc2d5aa0c2386b/NEMO/templates/status_dashboard/tools.html#L66)?).

A more drastic option would be to disable the tooltip at all (If possible I would avoid the latter, since it can be helpful to quickly check who are tool owners.

Just wondering what would be the best way to proceed, also considering the pending general refactoring:

              Thanks, after a bit more reflection on occupancy and alerts etc. I believe it makes more sense to have all of those partial views somewhere in NEMO itself. They could be in a `partial_views.py` or something like that and have the templates in the `snippets` template folder.

              Since they will be used by the jumbotron which is in NEMO, it seems more logical to have them in NEMO.
_Originally posted by @rptmat57 in https://github.com/usnistgov/NEMO/issues/178#issuecomment-1813552087_
Update additional demo links on README
Completes commit 199d097


Discussion / hints:
Can I ask what the reason behind this feature request is?
Do people miss click and then have to cancel reservations?
Yes, that's essentially it.  People mistakenly click (frequently) and have to either cancel or modify the reservation, which generates multiple calendar emails to deal with, and so on.  This 'feature' wouldn't enable new capability, but would just be quality of life change to address a point of irritation that a couple of users have raised to me.
one thing you can do is to require a minimum of 30 min reservation for each tool. then a quick click would break the policy and show the dialog. not ideal, but it's something
This is a customization we've implemented at UC Irvine as well. Our users didn't want to accidentally create reservations.

It uses a JavaScript `prompt` so users can click "Ok" or "Cancel". We  have some code for it, but may need to add a toggle in the settings to enable or disable this feature.

![image](https://github.com/usnistgov/NEMO/assets/135279879/e605fd88-4988-4974-86e7-902009c17b14)

I think that would work well here.
I am debating whether this should be a general setting or if this should be something each user can enable in their preferences, or both:

1. General setting, same for everyone
2. User preference setting, set for each user individually
3. General setting, and if enabled, then an override would be available in the user preferences to turn it off

3 is the most flexible but slightly more complicated to implement

What do you guys think?
I think 1 or 3 work best for us so that we can enable it for everyone by default. I agree 3 is probably the best since it's the most flexible.
I favor user preference settings since it's impossible to make everyone happy, so my vote would be for 2 or 3.  3 has the obvious benefit of flexibility, but given the extra implementation effort involved, I wouldn't be opposed to directing users to take the 30 seconds to set the toggle themselves if they have a strong opinion one way or the other.  Effectively, crowdsource the effort of making a selection.  Half the users are going to want to toggle their preference anyway, so I don't feel strongly about whether the setting is default on in code, default off in code, or default on/off based on a toggle I set.
looks like 3 is the way to go
Ok, I'll work on an implementation for this feature



Fix patch:
diff --git a/.gitignore b/.gitignore
index e093bc46c..925874e60 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,5 @@ test_nemo.log
 nemo.log
 .DS_Store
 conf/
+*.log
+.vs/
diff --git a/NEMO/admin.py b/NEMO/admin.py
index c22e9dbc6..9097304bb 100644
--- a/NEMO/admin.py
+++ b/NEMO/admin.py
@@ -88,6 +88,9 @@
     StaffAvailability,
     StaffAvailabilityCategory,
     StaffCharge,
+    StaffKnowledgeBaseCategory,
+    StaffKnowledgeBaseItem,
+    StaffKnowledgeBaseItemDocuments,
     Task,
     TaskCategory,
     TaskHistory,
@@ -103,6 +106,9 @@
     UsageEvent,
     User,
     UserDocuments,
+    UserKnowledgeBaseCategory,
+    UserKnowledgeBaseItem,
+    UserKnowledgeBaseItemDocuments,
     UserPreferences,
     UserType,
     record_active_state,
@@ -168,9 +174,10 @@ class Media:
     def __init__(self, *args, **kwargs):
         super(ToolAdminForm, self).__init__(*args, **kwargs)
         # Limit interlock selection to ones not already linked (make sure to include current one)
-        self.fields["_interlock"].queryset = Interlock.objects.filter(
-            Q(id=self.instance._interlock_id) | Q(tool__isnull=True, door__isnull=True)
-        )
+        if "_interlock" in self.fields:
+            self.fields["_interlock"].queryset = Interlock.objects.filter(
+                Q(id=self.instance._interlock_id) | Q(tool__isnull=True, door__isnull=True)
+            )
         if self.instance.pk:
             self.fields["qualified_users"].initial = self.instance.user_set.all()
             self.fields["required_resources"].initial = self.instance.required_resource_set.all()
@@ -204,6 +211,7 @@ class ToolAdmin(admin.ModelAdmin):
         "operational_display",
         "problematic",
         "is_configurable",
+        "has_pre_usage_questions",
         "has_post_usage_questions",
         "id",
     )
@@ -216,7 +224,12 @@ class ToolAdmin(admin.ModelAdmin):
         "_location",
         ("_requires_area_access", admin.RelatedOnlyFieldListFilter),
     )
-    readonly_fields = ("_post_usage_preview",)
+    readonly_fields = ("_post_usage_preview", "_pre_usage_preview")
+    autocomplete_fields = [
+        "_primary_owner",
+        "parent_tool",
+        "_grant_physical_access_level_upon_qualification",
+    ]
     actions = [duplicate_tool_configuration]
     form = ToolAdminForm
     fieldsets = (
@@ -229,6 +242,8 @@ class ToolAdmin(admin.ModelAdmin):
                     "_category",
                     "qualified_users",
                     "_qualifications_never_expire",
+                    "_pre_usage_questions",
+                    "_pre_usage_preview",
                     "_post_usage_questions",
                     "_post_usage_preview",
                 )
@@ -276,17 +291,25 @@ class ToolAdmin(admin.ModelAdmin):
                     "_grant_badge_reader_access_upon_qualification",
                     "_interlock",
                     "_allow_delayed_logoff",
+                    "_ask_to_leave_area_when_done_using",
                 )
             },
         ),
         ("Dependencies", {"fields": ("required_resources", "nonrequired_resources")}),
     )
 
-    @admin.display(description="Questions", ordering="_post_usage_questions", boolean=True)
+    @admin.display(description="Pre Questions", ordering="_pre_usage_questions", boolean=True)
+    def has_pre_usage_questions(self, obj: Tool):
+        return True if obj.pre_usage_questions else False
+
+    @admin.display(description="Post Questions", ordering="_post_usage_questions", boolean=True)
     def has_post_usage_questions(self, obj: Tool):
         return True if obj.post_usage_questions else False
 
-    def _post_usage_preview(self, obj):
+    def _pre_usage_preview(self, obj: Tool):
+        return admin_render_dynamic_form_preview(obj.pre_usage_questions, "tool_usage_group_question", obj.id)
+
+    def _post_usage_preview(self, obj: Tool):
         return admin_render_dynamic_form_preview(obj.post_usage_questions, "tool_usage_group_question", obj.id)
 
     def formfield_for_foreignkey(self, db_field, request, **kwargs):
@@ -355,7 +378,6 @@ class AreaAdmin(DraggableMPTTAdmin):
                 "fields": (
                     "requires_reservation",
                     "logout_grace_period",
-                    "welcome_message",
                     "auto_logout_time",
                     "buddy_system_allowed",
                 )
@@ -428,6 +450,7 @@ class TrainingSessionAdmin(ObjPermissionAdminMixin, ModelAdminRedirectMixin, adm
         ("trainee", admin.RelatedOnlyFieldListFilter),
     )
     date_hierarchy = "date"
+    autocomplete_fields = ["trainer", "trainee", "tool", "project", "validated_by"]
 
     def formfield_for_foreignkey(self, db_field, request, **kwargs):
         """We only want staff user and tool superusers to be possible trainers"""
@@ -445,6 +468,7 @@ class StaffChargeAdmin(ObjPermissionAdminMixin, ModelAdminRedirectMixin, admin.M
         ("staff_member", admin.RelatedOnlyFieldListFilter),
     )
     date_hierarchy = "start"
+    autocomplete_fields = ["staff_member", "customer", "project", "validated_by"]
 
 
 @register(AreaAccessRecord)
@@ -452,6 +476,7 @@ class AreaAccessRecordAdmin(ObjPermissionAdminMixin, ModelAdminRedirectMixin, ad
     list_display = ("id", "customer", "area", "project", "start", "end")
     list_filter = (("area", TreeRelatedFieldListFilter), "start")
     date_hierarchy = "start"
+    autocomplete_fields = ["customer", "project", "validated_by"]
 
 
 @register(Configuration)
@@ -467,12 +492,14 @@ class ConfigurationAdmin(admin.ModelAdmin):
     )
     filter_horizontal = ("maintainers",)
     actions = [duplicate_configuration]
+    autocomplete_fields = ["tool"]
 
 
 @register(ConfigurationHistory)
 class ConfigurationHistoryAdmin(admin.ModelAdmin):
     list_display = ("id", "configuration", "user", "modification_time", "slot")
     date_hierarchy = "modification_time"
+    autocomplete_fields = ["user"]
 
 
 @register(Account)
@@ -506,9 +533,10 @@ class Meta:
 
     def __init__(self, *args, **kwargs):
         super(ProjectAdminForm, self).__init__(*args, **kwargs)
-        self.fields["application_identifier"].label = ProjectsAccountsCustomization.get(
-            "project_application_identifier_name"
-        )
+        if "application_identifier" in self.fields:
+            self.fields["application_identifier"].label = ProjectsAccountsCustomization.get(
+                "project_application_identifier_name"
+            )
         if self.instance.pk:
             self.fields["members"].initial = self.instance.user_set.all()
             self.fields["principal_investigators"].initial = self.instance.manager_set.all()
@@ -526,6 +554,7 @@ class ProjectAdmin(admin.ModelAdmin):
     list_filter = ("active", ("account", admin.RelatedOnlyFieldListFilter), "start_date")
     inlines = [ProjectDocumentsInline]
     form = ProjectAdminForm
+    autocomplete_fields = ["account"]
 
     @display(ordering="application_identifier")
     def get_application_identifier(self, project: Project):
@@ -596,6 +625,7 @@ class ReservationAdmin(ObjPermissionAdminMixin, ModelAdminRedirectMixin, admin.M
     )
     date_hierarchy = "start"
     inlines = [ConfigurationOptionInline]
+    autocomplete_fields = ["user", "creator", "tool", "project", "cancelled_by", "validated_by"]
 
 
 class ReservationQuestionsForm(forms.ModelForm):
@@ -665,6 +695,7 @@ class UsageEventAdmin(ObjPermissionAdminMixin, ModelAdminRedirectMixin, admin.Mo
     list_display = ("id", "tool", "user", "operator", "project", "start", "end", "duration", "remote_work")
     list_filter = ("remote_work", "start", "end", ("tool", admin.RelatedOnlyFieldListFilter))
     date_hierarchy = "start"
+    autocomplete_fields = ["tool", "user", "operator", "project", "validated_by"]
 
 
 @register(Consumable)
@@ -681,6 +712,7 @@ class ConsumableAdmin(admin.ModelAdmin):
         "id",
     )
     list_filter = ("visible", ("category", admin.RelatedOnlyFieldListFilter), "reusable", "allow_self_checkout")
+    filter_horizontal = ["self_checkout_only_users"]
     search_fields = ("name",)
     readonly_fields = ("reminder_threshold_reached",)
 
@@ -695,6 +727,7 @@ class ConsumableWithdrawAdmin(ObjPermissionAdminMixin, ModelAdminRedirectMixin,
     list_display = ("id", "customer", "merchant", "consumable", "quantity", "project", "date")
     list_filter = ("date", ("consumable", admin.RelatedOnlyFieldListFilter))
     date_hierarchy = "date"
+    autocomplete_fields = ["customer", "merchant", "consumable", "project", "validated_by"]
 
 
 @register(RecurringConsumableCharge)
@@ -703,6 +736,7 @@ class RecurringConsumableChargeAdmin(admin.ModelAdmin):
     list_display = ("name", "customer", "project", "get_recurrence_display", "last_charge", "next_charge")
     list_filter = (("customer", admin.RelatedOnlyFieldListFilter),)
     readonly_fields = ("last_charge", "last_updated", "last_updated_by")
+    autocomplete_fields = ["customer", "consumable", "project"]
 
     def save_model(self, request, obj: RecurringConsumableCharge, form, change):
         obj.save_with_user(request.user)
@@ -727,6 +761,7 @@ def clean(self):
 
 @register(InterlockCard)
 class InterlockCardAdmin(admin.ModelAdmin):
+    search_fields = ["name", "server"]
     form = InterlockCardAdminForm
     list_display = ("name", "enabled", "server", "port", "number", "category", "even_port", "odd_port")
     actions = [disable_selected_cards, enable_selected_cards]
@@ -750,6 +785,7 @@ def clean(self):
 
 @register(Interlock)
 class InterlockAdmin(admin.ModelAdmin):
+    search_fields = ["card__name", "card__server"]
     form = InterlockAdminForm
     list_display = (
         "id",
@@ -771,6 +807,7 @@ class InterlockAdmin(admin.ModelAdmin):
     )
     actions = [lock_selected_interlocks, unlock_selected_interlocks, synchronize_with_tool_usage, create_next_interlock]
     readonly_fields = ["state", "most_recent_reply", "most_recent_reply_time"]
+    autocomplete_fields = ["card"]
 
     @display(boolean=True, ordering="card__enabled", description="Card Enabled")
     def get_card_enabled(self, obj):
@@ -806,6 +843,7 @@ class TaskAdmin(admin.ModelAdmin):
         ("creator", admin.RelatedOnlyFieldListFilter),
     )
     date_hierarchy = "creation_time"
+    autocomplete_fields = ["tool", "creator", "last_updated_by", "resolver"]
 
 
 @register(TaskCategory)
@@ -829,6 +867,7 @@ class TaskHistoryAdmin(admin.ModelAdmin):
     list_display = ("id", "task", "status", "time", "user")
     readonly_fields = ("time",)
     date_hierarchy = "time"
+    autocomplete_fields = ["user"]
 
 
 @register(TaskImages)
@@ -856,6 +895,7 @@ class CommentAdmin(admin.ModelAdmin):
     list_filter = ("visible", "creation_date", ("tool", admin.RelatedOnlyFieldListFilter), "staff_only")
     date_hierarchy = "creation_date"
     search_fields = ("content",)
+    autocomplete_fields = ["author", "tool", "hidden_by"]
 
 
 @register(Resource)
@@ -894,6 +934,7 @@ class MembershipHistoryAdmin(admin.ModelAdmin):
         ("child_content_type", admin.RelatedOnlyFieldListFilter),
     )
     date_hierarchy = "date"
+    autocomplete_fields = ["authorizer"]
 
     @admin.display(description="Parent")
     def get_parent(self, obj: MembershipHistory):
@@ -1024,6 +1065,7 @@ class UserAdmin(admin.ModelAdmin):
         "last_name",
         "email",
         "is_active",
+        "access_expiration",
         "domain",
         "is_staff",
         "is_user_office",
@@ -1037,6 +1079,7 @@ class UserAdmin(admin.ModelAdmin):
     )
     list_filter = (
         "is_active",
+        "access_expiration",
         "domain",
         "is_staff",
         "is_user_office",
@@ -1093,6 +1136,7 @@ class SafetyIssueAdmin(admin.ModelAdmin):
     )
     readonly_fields = ("creation_time", "resolution_time")
     search_fields = ("location", "concern", "progress", "resolution")
+    autocomplete_fields = ["reporter", "resolver"]
 
 
 @register(SafetyCategory)
@@ -1115,19 +1159,65 @@ def get_documents_number(self, obj: SafetyItem):
         return SafetyItemDocuments.objects.filter(safety_item=obj).count()
 
 
+@register(StaffKnowledgeBaseCategory)
+class StaffKnowledgeBaseCategoryAdmin(admin.ModelAdmin):
+    list_display = ("name", "display_order")
+
+
+class StaffKnowledgeBaseItemDocumentsInline(DocumentModelAdmin):
+    model = StaffKnowledgeBaseItemDocuments
+
+
+@register(StaffKnowledgeBaseItem)
+class StaffKnowledgeBaseItemAdmin(admin.ModelAdmin):
+    inlines = [StaffKnowledgeBaseItemDocumentsInline]
+    list_display = ("name", "category", "get_documents_number")
+    list_filter = (("category", admin.RelatedOnlyFieldListFilter),)
+
+    @display(description="Documents")
+    def get_documents_number(self, obj: StaffKnowledgeBaseItem):
+        return StaffKnowledgeBaseItemDocuments.objects.filter(item=obj).count()
+
+
+@register(UserKnowledgeBaseCategory)
+class UserKnowledgeBaseCategoryAdmin(admin.ModelAdmin):
+    list_display = ("name", "display_order")
+
+
+class UserKnowledgeBaseItemDocumentsInline(DocumentModelAdmin):
+    model = UserKnowledgeBaseItemDocuments
+
+
+@register(UserKnowledgeBaseItem)
+class UserKnowledgeBaseItemAdmin(admin.ModelAdmin):
+    inlines = [UserKnowledgeBaseItemDocumentsInline]
+    list_display = ("name", "category", "get_documents_number")
+    list_filter = (("category", admin.RelatedOnlyFieldListFilter),)
+
+    @display(description="Documents")
+    def get_documents_number(self, obj: UserKnowledgeBaseItem):
+        return UserKnowledgeBaseItemDocuments.objects.filter(item=obj).count()
+
+
 class DoorAdminForm(forms.ModelForm):
     def __init__(self, *args, **kwargs):
         super(DoorAdminForm, self).__init__(*args, **kwargs)
         # Limit interlock selection to ones not already linked (and exclude current one)
-        self.fields["interlock"].queryset = Interlock.objects.filter(
-            Q(id=self.instance.interlock_id) | Q(tool__isnull=True, door__isnull=True)
-        )
+        if "interlock" in self.fields:
+            self.fields["interlock"].queryset = Interlock.objects.filter(
+                Q(id=self.instance.interlock_id) | Q(tool__isnull=True, door__isnull=True)
+            )
 
 
 @register(Door)
 class DoorAdmin(admin.ModelAdmin):
-    list_display = ("name", "area", "interlock", "get_absolute_url", "id")
+    list_display = ("name", "get_areas", "interlock", "get_absolute_url", "id")
     form = DoorAdminForm
+    filter_horizontal = ["areas"]
+
+    @display(description="Areas", ordering="areas")
+    def get_areas(self, door: Door):
+        return mark_safe("<br>".join([area.name for area in door.areas.order_by("name")]))
 
 
 @register(AlertCategory)
@@ -1158,6 +1248,7 @@ class AlertAdmin(admin.ModelAdmin):
         "deleted",
     )
     list_filter = ("category", "dismissible", "expired", "deleted")
+    date_hierarchy = "creation_time"
     form = AlertAdminForm
 
 
@@ -1205,6 +1296,7 @@ def clean(self):
 
 @register(PhysicalAccessLevel)
 class PhysicalAccessLevelAdmin(admin.ModelAdmin):
+    search_fields = ["name", "area__name"]
     form = PhysicalAccessLevelForm
     list_display = ("name", "area", "get_schedule_display_with_times", "allow_staff_access", "allow_user_request")
     list_filter = (("area", TreeRelatedFieldListFilter), "allow_staff_access", "allow_user_request")
@@ -1340,6 +1432,7 @@ class TemporaryPhysicalAccessAdmin(admin.ModelAdmin):
         ("user", admin.RelatedOnlyFieldListFilter),
     )
     form = TemporaryPhysicalAccessAdminForm
+    autocomplete_fields = ["user", "physical_access_level"]
 
     @admin.display(ordering="physical_access_level__area", description="Area")
     def get_area_name(self, tpa: TemporaryPhysicalAccess) -> str:
@@ -1378,6 +1471,7 @@ class TemporaryPhysicalAccessRequestAdmin(admin.ModelAdmin):
     )
     filter_horizontal = ("other_users",)
     actions = [access_requests_export_csv]
+    autocomplete_fields = ["creator", "last_updated_by", "reviewer", "physical_access_level"]
 
     @admin.display(ordering="other_users", description="Buddies")
     def other_users_display(self, access_request: TemporaryPhysicalAccessRequest):
@@ -1396,6 +1490,7 @@ class ContactInformationCategoryAdmin(admin.ModelAdmin):
 @register(ContactInformation)
 class ContactInformationAdmin(admin.ModelAdmin):
     list_display = ("name", "category", "user")
+    autocomplete_fields = ["user"]
 
 
 @register(LandingPageChoice)
@@ -1431,6 +1526,7 @@ class ScheduledOutageAdmin(admin.ModelAdmin):
         ("resource", admin.RelatedOnlyFieldListFilter),
         ("creator", admin.RelatedOnlyFieldListFilter),
     )
+    autocomplete_fields = ["tool", "creator"]
 
 
 @register(News)
@@ -1446,6 +1542,7 @@ class NotificationAdmin(admin.ModelAdmin):
         ("content_type", admin.RelatedOnlyFieldListFilter),
         "notification_type",
     )
+    autocomplete_fields = ["user"]
 
     @admin.display(description="Item")
     def get_item(self, obj: Notification):
@@ -1457,7 +1554,7 @@ class BadgeReaderAdmin(admin.ModelAdmin):
     list_display = ("id", "name", "send_key", "record_key")
 
 
-class CounterAdminForm(forms.ModelForm):
+class ToolUsageCounterAdminForm(forms.ModelForm):
     class Meta:
         model = ToolUsageCounter
         fields = "__all__"
@@ -1468,34 +1565,39 @@ def clean(self):
         tool_usage_question_name = cleaned_data.get("tool_usage_question")
         if tool and tool_usage_question_name:
             error = None
-            if tool.post_usage_questions:
-                post_usage_form = DynamicForm(tool.post_usage_questions)
-                tool_question = post_usage_form.filter_questions(
-                    lambda x: (
-                        isinstance(x, PostUsageNumberFieldQuestion) or isinstance(x, PostUsageFloatFieldQuestion)
+            if tool.post_usage_questions or tool.pre_usage_questions:
+                candidate_questions = []
+                if tool.post_usage_questions:
+                    usage_form = DynamicForm(tool.post_usage_questions)
+                    candidate_questions.extend(
+                        usage_form.filter_questions(
+                            lambda x: isinstance(x, (PostUsageNumberFieldQuestion, PostUsageFloatFieldQuestion))
+                        )
                     )
-                    and x.name == tool_usage_question_name
-                )
-                if not tool_question:
-                    candidates = [
-                        question.name
-                        for question in post_usage_form.filter_questions(
-                            lambda x: isinstance(x, PostUsageNumberFieldQuestion)
-                            or isinstance(x, PostUsageFloatFieldQuestion)
+                if tool.pre_usage_questions:
+                    usage_form = DynamicForm(tool.pre_usage_questions)
+                    candidate_questions.extend(
+                        usage_form.filter_questions(
+                            lambda x: isinstance(x, (PostUsageNumberFieldQuestion, PostUsageFloatFieldQuestion))
                         )
-                    ]
-                    error = "The tool has no post usage question of type Number or Float with this name."
+                    )
+                matching_tool_question = any(
+                    question for question in candidate_questions if question.name == tool_usage_question_name
+                )
+                if not matching_tool_question:
+                    candidates = {question.name for question in candidate_questions}
+                    error = "The tool has no pre/post usage question of type Number or Float with this name."
                     if candidates:
                         error += f" Valid question names are: {', '.join(candidates)}"
             else:
-                error = "The tool does not have any post usage questions."
+                error = "The tool does not have any pre/post usage questions."
             if error:
                 self.add_error("tool_usage_question", error)
         return cleaned_data
 
 
 @register(ToolUsageCounter)
-class CounterAdmin(admin.ModelAdmin):
+class ToolUsageCounterAdmin(admin.ModelAdmin):
     list_display = (
         "name",
         "tool",
@@ -1508,12 +1610,14 @@ class CounterAdmin(admin.ModelAdmin):
     )
     list_filter = (("tool", admin.RelatedOnlyFieldListFilter), "last_reset")
     readonly_fields = ("warning_threshold_reached",)
-    form = CounterAdminForm
+    form = ToolUsageCounterAdminForm
+    autocomplete_fields = ["tool", "last_reset_by"]
 
 
 class RequestMessageInlines(GenericStackedInline):
     model = RequestMessage
     extra = 1
+    autocomplete_fields = ["author"]
 
 
 @register(BuddyRequest)
@@ -1527,6 +1631,7 @@ class BuddyRequestAdmin(admin.ModelAdmin):
         ("user", admin.RelatedOnlyFieldListFilter),
         ("area", TreeRelatedFieldListFilter),
     )
+    autocomplete_fields = ["user"]
 
     @admin.display(ordering="replies", description="Replies")
     def reply_count(self, buddy_request: BuddyRequest):
@@ -1566,6 +1671,10 @@ def reply_count(self, adjustment_request: AdjustmentRequest):
     def get_item(self, adjustment_request: AdjustmentRequest):
         return admin_get_item(adjustment_request.item_type, adjustment_request.item_id)
 
+    def delete_queryset(self, request, queryset):
+        for obj in queryset:
+            obj.delete()
+
 
 @register(StaffAbsenceType)
 class StaffAbsenceTypeAdmin(admin.ModelAdmin):
@@ -1579,8 +1688,15 @@ class StaffAvailabilityCategoryAdmin(admin.ModelAdmin):
 
 @register(StaffAvailability)
 class StaffAvailabilityAdmin(admin.ModelAdmin):
+    search_fields = (
+        "staff_member__first_name",
+        "staff_member__last_name",
+        "staff_member__username",
+        "staff_member__email",
+    )
     list_display = ("staff_member", "category", "visible", "start_time", "end_time", *StaffAvailability.DAYS)
     list_filter = ("category", *StaffAvailability.DAYS)
+    autocomplete_fields = ["staff_member"]
 
     def formfield_for_foreignkey(self, db_field, request, **kwargs):
         """We only want active users here"""
@@ -1599,6 +1715,7 @@ class StaffAbsenceAdmin(admin.ModelAdmin):
         "end_date",
         "creation_time",
     )
+    autocomplete_fields = ["staff_member"]
 
 
 class ChemicalHazardAdminForm(forms.ModelForm):
diff --git a/NEMO/apps/area_access/templates/area_access/already_logged_in.html b/NEMO/apps/area_access/templates/area_access/already_logged_in.html
index b56cb56b5..6d0371db1 100644
--- a/NEMO/apps/area_access/templates/area_access/already_logged_in.html
+++ b/NEMO/apps/area_access/templates/area_access/already_logged_in.html
@@ -1,4 +1,4 @@
-<h1>According to our records, you're already logged into the {{ area }} under the project named {{ project }}.</h1>
+<h1>According to our records, you're already logged in the {{ area }} under the project named {{ project }}.</h1>
 {% if max_capacity_reached %}
 	{% if max_capacity_reached.area == area %}
 		<h2>However, this area has reached its maximum occupancy</h2>
@@ -15,13 +15,13 @@ <h2>Please visit the {{ facility_name}} staff if you believe this is an error.</
     <h2>What would you like to do?</h2>
     <p>
         <a href="javascript:void(0)" role="button" class="btn btn-success btn-lg btn-block btn-extra-large" onclick="revert_to_default_content()">
-            Do nothing and keep me logged in
+            Do nothing and keep me logged in (<span class="countdown">15</span>)
         </a>
     </p>
     {# Don't offer to open the door if there is no interlock #}
     {% if door.interlock %}
         <p>
-            <a href="javascript:void(0)" role="button" class="btn btn-success btn-lg btn-block btn-extra-large" onclick="just_open_the_door('{{ badge_number }}')">
+            <a href="javascript:void(0)" role="button" class="btn btn-success btn-lg btn-block btn-extra-large" onclick="just_open_the_door('{{ badge_number }}', '{{ area.id }}')">
                 Open the door and keep me logged in
             </a>
         </p>
diff --git a/NEMO/apps/area_access/templates/area_access/base.html b/NEMO/apps/area_access/templates/area_access/base.html
index 00391211e..e167a96dc 100644
--- a/NEMO/apps/area_access/templates/area_access/base.html
+++ b/NEMO/apps/area_access/templates/area_access/base.html
@@ -69,11 +69,13 @@ <h3 id="interlock_message" class="text-center"></h3>
         </div>
 		<div id="status" style="display: none"></div>
 		<div><h1 style="color:lightgrey" id="badge_number"></h1></div>
-        <div id="countdown" style="position: fixed; bottom: 0; right: 0;font-size: x-large"></div>
+        <div id="countdown" class="countdown" style="position: fixed; bottom: 0; right: 0;font-size: x-large"></div>
 	</div>
 	{% include 'base/footer.html' %}
 	<script type="text/javascript">
-		let area_access_badge_reader = new BadgeReader(send_badge_number, "{{ badge_reader.send_key }}" {% if badge_reader.record_key %}, "{{ badge_reader.record_key }}"{% endif %});
+        {% with show_badge_number="application"|customization:"show_badge_number" %}
+		let area_access_badge_reader = new BadgeReader(send_badge_number, "{{ badge_reader.send_key }}", {% if badge_reader.record_key %}"{{ badge_reader.record_key }}",{% else %}undefined,{% endif %} {{ show_badge_number|yesno:"true,false" }});
+        {% endwith %}
 		let timeout_handle = null;
 		let countdown_handle = null;
 		let occupancy = new URLSearchParams(window.location.search).get('occupancy');
@@ -110,12 +112,12 @@ <h3 id="interlock_message" class="text-center"></h3>
 		function revert(delay)
 		{
 			clear_timeout();
-			let countdown = delay;
+			let countdown = delay - 1;
 			delay = delay * 1000;
 			timeout_handle = setTimeout(revert_to_default_content, delay);
 			countdown_handle = setInterval(function()
             {
-				$('#countdown').html(countdown);
+				$('.countdown').html(countdown);
 				countdown --;
 			}, 1000);
 		}
@@ -144,23 +146,24 @@ <h3 id="interlock_message" class="text-center"></h3>
         {
             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 just_open_the_door(badge_number)
+		function just_open_the_door(badge_number, area_id)
 		{
-			let data = 'door_id={{ door.id }}&csrfmiddlewaretoken={{ csrf_token }}&badge_number=' + badge_number;
+			let data = 'door_id={{ door.id }}&csrfmiddlewaretoken={{ csrf_token }}&badge_number=' + badge_number + '&area_id=' + area_id;
 			default_content_element.hide();
 			error_element.hide();
 			error_interlock_element.hide();
 			status_element.html("<h1>Opening the door...</h1>").show();
             try_post_catch_interlock_error('{% url 'open_door' door.id %}', data);
 		}
-		function enter_area(badge_number, project_id)
+		function enter_area(badge_number, project_id, area_id)
 		{
             let data = 'door_id={{ door.id }}&csrfmiddlewaretoken={{ csrf_token }}&badge_number=' + badge_number;
 			if(project_id) data += '&project_id=' + project_id;
+            if(area_id) data += '&area_id='+area_id;
 			default_content_element.hide();
 			error_element.hide();
 			error_interlock_element.hide();
-			status_element.html("<h1>Logging you in to the {{ area }}...</h1>").show();
+			status_element.html("<h1>Logging you in...</h1>").show();
 			try_post_catch_interlock_error('{% url 'login_to_area' door.id %}', data);
 		}
 		function exit_area(badge_number)
@@ -169,7 +172,7 @@ <h3 id="interlock_message" class="text-center"></h3>
 			default_content_element.hide();
 			error_element.hide();
 			error_interlock_element.hide();
-			status_element.html("<h1>Logging you out of the {{ area }}...</h1>").show();
+			status_element.html("<h1>Logging you out...</h1>").show();
 			try_post_catch_interlock_error('{% url 'logout_of_area' door.id %}', data);
 		}
 		function csrf_token()
diff --git a/NEMO/apps/area_access/templates/area_access/choose_area.html b/NEMO/apps/area_access/templates/area_access/choose_area.html
new file mode 100644
index 000000000..8f8a764f2
--- /dev/null
+++ b/NEMO/apps/area_access/templates/area_access/choose_area.html
@@ -0,0 +1,16 @@
+{% load custom_tags_and_filters %}
+<h1>Welcome, {{ badge_user.first_name }}!</h1>
+<h2>Please choose an area to log into.</h2>
+{% for other_area in door.areas.all %}
+	<p>
+		<button type="button" class="btn btn-success btn-lg btn-block btn-extra-large" onclick="enter_area('{{ badge_user.badge_number }}', undefined, {{ other_area.id }})">
+			{{ other_area.name }}
+		</button>
+	</p>
+{% endfor %}
+<p>
+	<button type="button" class="btn btn-warning btn-lg btn-block btn-extra-large" onclick="revert_to_default_content()">
+		Nevermind, don't log in (<span class="countdown">30</span>)
+	</button>
+</p>
+<script>revert(30);</script>
\ No newline at end of file
diff --git a/NEMO/apps/area_access/templates/area_access/choose_project.html b/NEMO/apps/area_access/templates/area_access/choose_project.html
index 32fbd9a0b..7ff0cf3f6 100644
--- a/NEMO/apps/area_access/templates/area_access/choose_project.html
+++ b/NEMO/apps/area_access/templates/area_access/choose_project.html
@@ -1,17 +1,17 @@
 {% load custom_tags_and_filters %}
-<h1>Welcome to the {{ area }}, {{ user.first_name }}!</h1>
+<h1>Welcome to the {{ area }}, {{ badge_user.first_name }}!</h1>
 <h2>Please choose a project to bill for area access.</h2>
 <h2>You can always change your choice later.</h2>
-{% for project in user.active_projects %}
+{% for project in badge_user.active_projects %}
 	<p>
-		<button type="button" class="btn btn-success btn-lg btn-block btn-extra-large" onclick="enter_area('{{ user.badge_number }}', {{ project.id }})">
+		<button type="button" class="btn btn-success btn-lg btn-block btn-extra-large" onclick="enter_area('{{ badge_user.badge_number }}', {{ project.id }}, {{ area_id }})">
 			{% project_selection_display project %}
 		</button>
 	</p>
 {% endfor %}
 <p>
 	<button type="button" class="btn btn-warning btn-lg btn-block btn-extra-large" onclick="revert_to_default_content()">
-		Nevermind, don't log in
+		Nevermind, don't log in (<span class="countdown">30</span>)
 	</button>
 </p>
 <script>revert(30);</script>
\ No newline at end of file
diff --git a/NEMO/apps/area_access/templates/area_access/farewell_screen.html b/NEMO/apps/area_access/templates/area_access/farewell_screen.html
index 6845ea2ea..109259fae 100644
--- a/NEMO/apps/area_access/templates/area_access/farewell_screen.html
+++ b/NEMO/apps/area_access/templates/area_access/farewell_screen.html
@@ -1,7 +1,7 @@
 {% extends 'area_access/base.html' %}
 {% block title %}Farewell{% endblock %}
 {% block default_content %}
-	<h1>Scan your badge to log out of the {{ area }}.</h1>
+	<h1>Scan your badge to log out.</h1>
 {% endblock %}
 {% block action %}
 	exit_area(badge_number);
diff --git a/NEMO/apps/area_access/templates/area_access/logout_success.html b/NEMO/apps/area_access/templates/area_access/logout_success.html
index d6bbbe037..e3dde1f20 100644
--- a/NEMO/apps/area_access/templates/area_access/logout_success.html
+++ b/NEMO/apps/area_access/templates/area_access/logout_success.html
@@ -1,4 +1,23 @@
 <script>$('body').removeClass().addClass('logout-success')</script>
-<h1><p>{% now "h:i A" %}</p>You're now logged out of the {{ area }}, {{ name }}.</h1>
+<h1><p>{% now "h:i A" %}</p>You're now logged out of the {{ area }}, {{ badge_user.first_name }}.</h1>
 <audio src="{% url 'media' 'success.mp3' %}" autoplay></audio>
-<script>revert(7);</script>
\ No newline at end of file
+{% if door.areas.count > 1 %}
+    <div class="alert alert-success clearfix" style="margin-top: 55px;">
+        <h3 style="margin-bottom: 0">Would you like to log in into another area?</h3>
+        <p>
+            <button role="button" class="btn btn-default btn-lg btn-block btn-extra-large" onclick="revert_to_default_content()">
+                Exit (<span class="countdown">15</span>)
+            </button>
+        </p>
+        {% for other_area in door.areas.all %}
+            {% if other_area != area %}
+                <p>
+                    <button type="button" class="btn btn-success btn-lg btn-block btn-extra-large" onclick="enter_area('{{ badge_user.badge_number }}', undefined, {{ other_area.id }})">
+                        Login to the {{ other_area.name }}
+                    </button>
+                </p>
+            {% endif %}
+        {% endfor %}
+    </div>
+{% endif %}
+<script>{% if door.areas.count > 1 %}revert(15){% else %}revert(7){% endif %};</script>
\ No newline at end of file
diff --git a/NEMO/apps/area_access/templates/area_access/logout_warning.html b/NEMO/apps/area_access/templates/area_access/logout_warning.html
index 857018753..ef0e1deb3 100644
--- a/NEMO/apps/area_access/templates/area_access/logout_warning.html
+++ b/NEMO/apps/area_access/templates/area_access/logout_warning.html
@@ -1,5 +1,5 @@
 <script>$('body').removeClass().addClass('logout-warning')</script>
-<h1><p>{% now "h:i A" %}</p>You're now logged out of the {{ area }}, {{ name }}.</h1>
+<h1><p>{% now "h:i A" %}</p>You're now logged out of the {{ area }}, {{ badge_user.first_name }}.</h1>
 <audio src="{% url 'media' 'warning.mp3' %}" autoplay></audio>
 <h2>
 	{% if tools_in_use %}
@@ -18,4 +18,23 @@ <h2>
 		</ul>
 	{% endif %}
 </h2>
+{% if door.areas.count != 1 %}
+    <div class="alert alert-warning clearfix" style="margin-top: 55px;">
+        <h3 class="" style="margin-bottom: 0">Would you like to log into another area?</h3>
+        <p>
+            <button role="button" class="btn btn-default btn-lg btn-block btn-extra-large" onclick="revert_to_default_content()">
+                Exit (<span class="countdown">15</span>)
+            </button>
+        </p>
+        {% for other_area in door.areas.all %}
+            {% if other_area != area %}
+                <p>
+                    <button type="button" class="btn btn-success btn-lg btn-block btn-extra-large" onclick="enter_area('{{ badge_user.badge_number }}', undefined, {{ other_area.id }})">
+                        Login to the {{ other_area.name }}
+                    </button>
+                </p>
+            {% endif %}
+        {% endfor %}
+    </div>
+{% endif %}
 <script>revert(15);</script>
\ No newline at end of file
diff --git a/NEMO/apps/area_access/templates/area_access/welcome_screen.html b/NEMO/apps/area_access/templates/area_access/welcome_screen.html
index e665b2a9a..a249b9058 100644
--- a/NEMO/apps/area_access/templates/area_access/welcome_screen.html
+++ b/NEMO/apps/area_access/templates/area_access/welcome_screen.html
@@ -1,7 +1,7 @@
 {% extends 'area_access/base.html' %}
 {% block title %}Welcome{% endblock %}
 {% block default_content %}
-	{{ area.welcome_message|safe }}
+	{{ door.welcome_message|safe }}
 {% endblock %}
 {% block action %}
 	enter_area(badge_number, undefined);
diff --git a/NEMO/apps/area_access/views.py b/NEMO/apps/area_access/views.py
index cd876afce..f9ae601ee 100644
--- a/NEMO/apps/area_access/views.py
+++ b/NEMO/apps/area_access/views.py
@@ -22,16 +22,17 @@
     UnavailableResourcesUserError,
 )
 from NEMO.models import (
+    Alert,
+    Area,
     AreaAccessRecord,
     BadgeReader,
     Door,
     PhysicalAccessLog,
     PhysicalAccessType,
     Project,
+    Resource,
     UsageEvent,
     User,
-    Alert,
-    Resource,
 )
 from NEMO.policy import policy_class as policy
 from NEMO.views.area_access import log_in_user_to_area, log_out_user
@@ -44,7 +45,7 @@
 @require_GET
 def welcome_screen(request, door_id):
     door = get_object_or_404(Door, id=door_id)
-    dictionary = {"area": door.area, "door": door, "badge_reader": get_badge_reader(request)}
+    dictionary = {"door": door, "badge_reader": get_badge_reader(request)}
     return render(
         request,
         "area_access/welcome_screen.html",
@@ -57,7 +58,7 @@ def welcome_screen(request, door_id):
 @require_GET
 def farewell_screen(request, door_id):
     door = get_object_or_404(Door, id=door_id)
-    dictionary = {"area": door.area, "door": door, "badge_reader": get_badge_reader(request)}
+    dictionary = {"door": door, "badge_reader": get_badge_reader(request)}
     return render(request, "area_access/farewell_screen.html", dictionary)
 
 
@@ -68,6 +69,11 @@ def login_to_area(request, door_id):
     door = get_object_or_404(Door, id=door_id)
 
     badge_number = request.POST.get("badge_number")
+    area_id = request.POST.get("area_id", 0)
+    if door.areas.count() == 1:
+        area = door.areas.first()
+    else:
+        area = Area.objects.filter(id=area_id).first()
     bypass_interlock = request.POST.get("bypass", "False") == "True"
     if not badge_number:
         return render(request, "area_access/badge_not_found.html")
@@ -76,6 +82,15 @@ def login_to_area(request, door_id):
     except User.DoesNotExist:
         return render(request, "area_access/badge_not_found.html")
 
+    current_area_access_record = user.area_access_record()
+    if current_area_access_record:
+        if ApplicationCustomization.get_bool("area_logout_already_logged_in"):
+            return logout_of_area(request, door_id)
+
+    if not area:
+        # Multiple areas but none were chosen, ask the user
+        return render(request, "area_access/choose_area.html", {"door": door, "badge_user": user})
+
     log = PhysicalAccessLog()
     log.user = user
     log.door = door
@@ -114,7 +129,7 @@ def login_to_area(request, door_id):
     scheduled_outage_in_progress = False
     # Check policy to enter this area
     try:
-        policy.check_to_enter_area(area=door.area, user=user)
+        policy.check_to_enter_area(area=area, user=user)
     except NoAccessiblePhysicalAccessUserError as error:
         if error.closure_time:
             log.details = (
@@ -146,10 +161,7 @@ def login_to_area(request, door_id):
         # deal with this error after checking if the user is already logged in
         reservation_requirement_failed = True
 
-    current_area_access_record = user.area_access_record()
-    if current_area_access_record and current_area_access_record.area == door.area:
-        if ApplicationCustomization.get_bool("area_logout_already_logged_in"):
-            return logout_of_area(request, door_id)
+    if current_area_access_record and current_area_access_record.area == area:
         # No log entry necessary here because all validation checks passed.
         # The log entry is captured when the subsequent choice is made by the user.
         return render(
@@ -157,7 +169,7 @@ def login_to_area(request, door_id):
             "area_access/already_logged_in.html",
             {
                 "door": door,
-                "area": door.area,
+                "area": area,
                 "project": current_area_access_record.project,
                 "badge_number": user.badge_number,
                 "reservation_requirement_failed": reservation_requirement_failed,
@@ -181,7 +193,7 @@ def login_to_area(request, door_id):
         return render(request, "area_access/physical_access_denied.html", {"message": message})
 
     if reservation_requirement_failed:
-        log.details = f"The user was blocked from entering this area because the user does not have a current reservation for the {door.area}."
+        log.details = f"The user was blocked from entering this area because the user does not have a current reservation for the {area}."
         log.save()
         message = "You do not have a current reservation for this area. Please make a reservation before trying to access this area."
         return render(request, "area_access/physical_access_denied.html", {"message": message})
@@ -194,12 +206,16 @@ def login_to_area(request, door_id):
             if not project_id:
                 # No log entry necessary here because all validation checks passed, and the user must indicate which project
                 # the wish to login under. The log entry is captured when the subsequent choice is made by the user.
-                return render(request, "area_access/choose_project.html", {"area": door.area, "user": user})
+                return render(
+                    request,
+                    "area_access/choose_project.html",
+                    {"area": area, "badge_user": user, "area_id": area_id},
+                )
             else:
                 project = get_object_or_404(Project, id=project_id)
                 try:
                     policy.check_billing_to_project(
-                        project, user, door.area, AreaAccessRecord(area=door.area, project=project, customer=user)
+                        project, user, area, AreaAccessRecord(area=area, project=project, customer=user)
                     )
                 except ProjectChargeException as e:
                     log.details = "The user attempted to bill the project named {} but got error: {}".format(
@@ -227,11 +243,11 @@ def login_to_area(request, door_id):
 
             delay_lock_door(door.id)
 
-        log_in_user_to_area(door.area, user, project)
+        log_in_user_to_area(area, user, project)
 
         dictionary = {
             "door": door,
-            "area": door.area,
+            "area": area,
             "name": user.first_name,
             "project": project,
             "previous_area": previous_area,
@@ -251,6 +267,7 @@ def delay_lock_door(door_id):
 @permission_required("NEMO.change_areaaccessrecord")
 @require_POST
 def logout_of_area(request, door_id):
+    door = get_object_or_404(Door, id=door_id)
     badge_number = request.POST.get("badge_number")
     if not badge_number:
         return render(request, "area_access/badge_not_found.html")
@@ -269,13 +286,19 @@ def logout_of_area(request, door_id):
                 "area_access/logout_warning.html",
                 {
                     "area": record.area,
-                    "name": user.first_name,
+                    "door": door,
+                    "badge_user": user,
                     "tools_in_use": busy_tools,
                     "staff_charge": staff_charge,
+                    "badge_number": user.badge_number,
                 },
             )
         else:
-            return render(request, "area_access/logout_success.html", {"area": record.area, "name": user.first_name})
+            return render(
+                request,
+                "area_access/logout_success.html",
+                {"area": record.area, "door": door, "badge_user": user, "badge_number": user.badge_number},
+            )
     else:
         return render(request, "area_access/not_logged_in.html")
 
@@ -286,13 +309,15 @@ def logout_of_area(request, door_id):
 def open_door(request, door_id):
     door = get_object_or_404(Door, id=door_id)
     badge_number = request.POST.get("badge_number")
+    area_id = request.POST.get("area_id", 0)
+    area = door.areas.first() if door.areas.count() == 1 else get_object_or_404(Area, id=area_id)
     if not badge_number:
         return render(request, "area_access/badge_not_found.html")
     try:
         user = User.objects.get(badge_number=badge_number)
     except User.DoesNotExist:
         return render(request, "area_access/badge_not_found.html")
-    if user.area_access_record() and user.area_access_record().area == door.area:
+    if user.area_access_record() and user.area_access_record().area == area:
         log = PhysicalAccessLog(
             user=user,
             door=door,
@@ -308,7 +333,7 @@ def open_door(request, door_id):
                 return interlock_error(bypass_allowed=False)
             delay_lock_door(door.id)
         return render(request, "area_access/door_is_open.html")
-    return render(request, "area_access/not_logged_in.html", {"area": door.area})
+    return render(request, "area_access/not_logged_in.html")
 
 
 def interlock_error(action: str = None, user: User = None, bypass_allowed: bool = None):
diff --git a/NEMO/apps/contracts/admin.py b/NEMO/apps/contracts/admin.py
index 748be1cd9..901dda534 100644
--- a/NEMO/apps/contracts/admin.py
+++ b/NEMO/apps/contracts/admin.py
@@ -1,7 +1,6 @@
 from django import forms
 from django.contrib import admin
 
-from NEMO.mixins import ModelAdminRedirectMixin
 from NEMO.apps.contracts.customization import ContractsCustomization
 from NEMO.apps.contracts.models import (
     ContractorAgreement,
@@ -11,6 +10,7 @@
     ServiceContract,
 )
 from NEMO.apps.contracts.views.contracts import export_contractor_agreements, export_procurements
+from NEMO.mixins import ModelAdminRedirectMixin
 
 
 @admin.action(description="Export selected procurements in CSV")
@@ -88,7 +88,10 @@ class ContractorAgreementDocumentsInline(admin.TabularInline):
 class ContractorAgreementAdminForm(forms.ModelForm):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self.fields["contract"].empty_label = ContractsCustomization.get("contracts_contractors_default_empty_label")
+        if "contract" in self.fields:
+            self.fields["contract"].empty_label = ContractsCustomization.get(
+                "contracts_contractors_default_empty_label"
+            )
 
 
 @admin.register(ContractorAgreement)
diff --git a/NEMO/apps/contracts/templates/contracts/contracts_and_procurements.html b/NEMO/apps/contracts/templates/contracts/contracts_and_procurements.html
index cac7f1b0f..b6edaedd8 100644
--- a/NEMO/apps/contracts/templates/contracts/contracts_and_procurements.html
+++ b/NEMO/apps/contracts/templates/contracts/contracts_and_procurements.html
@@ -75,6 +75,17 @@ <h1 style="margin-top: 0; margin-bottom: 25px">Contracts & procurements</h1>
             $(this).removeData('bs.tooltip');
             $(".tooltip").remove();
             $(this).tooltip({trigger: 'manual', html: 'true', container: '#contract-panel-content'}).tooltip('toggle');
+        }).on("shown.bs.tooltip", function (e)
+        {
+            // This function will be executed when the tooltip is completely shown
+            let tooltipEl = $(e.target).data('bs.tooltip').$tip;
+            let top = tooltipEl.offset().top;
+            let height = tooltipEl.height();
+            if (top < 0) {
+                tooltipEl.css("top", "0px");
+                tooltipEl.css("max-height", String(height + top - 5) + "px");
+                tooltipEl.css("overflow-y", "auto");
+            }
         });
         $("[data-toggle='tooltip']").tooltip({html: 'true'});
 
diff --git a/NEMO/apps/contracts/templates/contracts/procurements.html b/NEMO/apps/contracts/templates/contracts/procurements.html
index b7fdd8127..820dedcd1 100644
--- a/NEMO/apps/contracts/templates/contracts/procurements.html
+++ b/NEMO/apps/contracts/templates/contracts/procurements.html
@@ -30,7 +30,7 @@
                     <td>{{ procurement.display_cost|default_if_none:"" }}</td>
                     <td class="text-right text-nowrap">
                         {% if procurement.notes %}
-                            {% button icon="glyphicon-info-sign" type="warn" size="small" value="Notes" title=procurement.notes|default_if_none:""|linebreaksbr data_toggle="tooltip" %}
+                            {% button icon="glyphicon-info-sign" type="warn" size="small" value="Notes" title=procurement.notes|default_if_none:""|linebreaksbr data_toggle="tooltip-notes" %}
                         {% endif %}
                         {% if procurement.procurementdocuments_set.all %}
                             <form method="post" style="display: inline">
diff --git a/NEMO/apps/kiosk/templates/kiosk/acknowledgement.html b/NEMO/apps/kiosk/templates/kiosk/acknowledgement.html
index 915d0c9a1..5e74724b3 100644
--- a/NEMO/apps/kiosk/templates/kiosk/acknowledgement.html
+++ b/NEMO/apps/kiosk/templates/kiosk/acknowledgement.html
@@ -1,7 +1,26 @@
 <h1>{{ message }}</h1>
 
 {% if badge_number %}
-	<script>load_choices('{{ badge_number }}')</script>
+    {% if not delay %}
+	    <script>load_choices('{{ badge_number }}')</script>
+    {% else %}
+	    <script>revert({{ delay }}, function(){load_choices('{{ badge_number }}')});</script>
+    {% endif %}
 {% else %}
 	<script>revert({{ delay|default:5 }});</script>
+{% endif %}
+{% if ask_logout %}
+    <div class="alert alert-info clearfix" style="margin-top: 55px;">
+        <h3 class="col-sm-12" style="margin-bottom: 0">Now that you have finished using the {{ tool.name_or_child_in_use_name }}, would you like to also log out of the {{ area.name }}?</h3>
+        <div class="col-sm-6">
+            <a href="javascript:void(0)" role="button" class="btn btn-default btn-lg btn-block btn-extra-large" onclick="revert(null, function(){load_choices('{{ badge_number }}')});">
+                Continue without logging out (<span class="countdown">{{ delay }}</span>)
+            </a>
+        </div>
+        <div class="col-sm-6">
+            <a href="javascript:void(0)" role="button" class="btn btn-success btn-lg btn-block btn-extra-large" onclick="logout_user('{{ tool.id }}', '{{ badge_number }}');">
+                Log out to the {{ area.name }}
+            </a>
+        </div>
+    </div>
 {% endif %}
\ No newline at end of file
diff --git a/NEMO/apps/kiosk/templates/kiosk/kiosk.html b/NEMO/apps/kiosk/templates/kiosk/kiosk.html
index 0376aaa2e..f3a28eb83 100644
--- a/NEMO/apps/kiosk/templates/kiosk/kiosk.html
+++ b/NEMO/apps/kiosk/templates/kiosk/kiosk.html
@@ -32,7 +32,7 @@
 	{# Badge Reader #}
 	<script type="text/javascript" src="{% static "badge_reader.js" %}"></script>
 
-	<!--suppress CssUnusedSymbol -->
+    <!--suppress CssUnusedSymbol -->
 	<style>
 		.btn-extra-large
 		{
@@ -40,6 +40,11 @@
 			margin-bottom: 50px;
 			font-size: 36px;
 		}
+        {# Prevent links in tooltips #}
+        .tooltip .tooltip-inner a
+        {
+            pointer-events: none;
+        }
 	</style>
 </head>
 <body>
@@ -90,11 +95,13 @@ <h3 id="interlock_message" class="text-center"></h3>
 			<div id="status" style="display: none"></div>
 			<div><h1 style="color:lightgrey" id="badge_number"></h1></div>
 		</div>
-		<div id="countdown" style="position: fixed; bottom: 0; right: 0;font-size: x-large"></div>
+		<div class="countdown" style="position: fixed; bottom: 0; right: 0;font-size: x-large"></div>
 	</div>
 	{% include 'base/footer.html' %}
 	<script type="text/javascript">
-		let kiosk_badge_reader = new BadgeReader(send_badge_number, "{{ badge_reader.send_key }}" {% if badge_reader.record_key %}, "{{ badge_reader.record_key }}"{% endif %});
+        {% with show_badge_number="application"|customization:"show_badge_number" %}
+		let kiosk_badge_reader = new BadgeReader(send_badge_number, "{{ badge_reader.send_key }}", {% if badge_reader.record_key %}"{{ badge_reader.record_key }}"{% else %}undefined{% endif %}, {{ show_badge_number|yesno:"true,false" }});
+        {% endwith %}
 		let timeout_handle = null;
 		let countdown_handle = null;
 		let occupancy = new URLSearchParams(window.location.search).get('occupancy');
@@ -156,15 +163,16 @@ <h3 id="interlock_message" class="text-center"></h3>
 			$("#badge_number").html("");
 			show_alerts();
 		}
-		function revert(delay)
+		function revert(delay, callback)
 		{
+            callback = callback || revert_to_default_content;
 			clear_timeout();
-			let countdown = delay;
+			let countdown = delay - 1;
 			delay = delay * 1000;
-			timeout_handle = setTimeout(revert_to_default_content, delay);
+			timeout_handle = setTimeout(callback, delay);
 			countdown_handle = setInterval(function()
             {
-				$('#countdown').html(countdown);
+				$('.countdown').html(countdown);
 				countdown --;
 			}, 1000);
 		}
@@ -190,14 +198,15 @@ <h3 id="interlock_message" class="text-center"></h3>
 			if (countdown_handle != null)
 				clearInterval(countdown_handle);
 			countdown_handle = null;
-			$("#countdown").html("");
+			$(".countdown").html("");
 		}
 		function select_project(element, project_id)
 		{
 			$(".project-choice").removeClass('active');
 			$(element).addClass('active');
 			$("#project_id").val(project_id);
-			$("#start_reserve").show();
+            $("#pre_usage_questions").show();
+			update_start_button();
 		}
 		function load_choices(badge_number)
 		{
@@ -346,7 +355,11 @@ <h3 id="interlock_message" class="text-center"></h3>
 			    ajax_get('{% url 'kiosk_occupancy' %}', {'occupancy': occupancy}, function(response) { $("#occupancy").html(response); });
             }
 		}
-
+        function logout_user(tool_id, badge_number)
+		{
+			status_element.html("<h1>Logging you out...</h1>").show();
+            ajax_get('{% url 'kiosk_logout_user' '999' %}'.replace('999', tool_id), {"badge_number": badge_number}, load_complete);
+		}
         function fetch_usage()
 		{
             if(usage)
diff --git a/NEMO/apps/kiosk/templates/kiosk/tool_information.html b/NEMO/apps/kiosk/templates/kiosk/tool_information.html
index 68984a03b..3e7fece19 100644
--- a/NEMO/apps/kiosk/templates/kiosk/tool_information.html
+++ b/NEMO/apps/kiosk/templates/kiosk/tool_information.html
@@ -250,7 +250,7 @@ <h2 id="answer_to_proceed" style="text-align:center">Please answer the required
 		<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 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>
@@ -258,17 +258,23 @@ <h1>Make a reservation</h1>
 	{% 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 %}
 			{% include 'kiosk/tool_project_selection_snippet.html' with active_projects=customer.active_projects tool=tool %}
-			<div id="start_reserve" style="display:none; margin-top:20px">
+			{% if pre_usage_questions %}
+				<div id="pre_usage_questions" style="display:none">
+                <h2>Pre usage questions</h2>
+				{{ pre_usage_questions }}
+                </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="alert alert-success" style="text-align:center; margin-bottom:0; font-size:x-large" onclick="enable_tool()">
+						<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>
 					{% for alternate_tool in tool.tool_children_set.all %}
 						<div class="col-xs-6" style="margin-top: 15px">
-							<div class="alert alert-success" style="text-align:center; margin-bottom:0; font-size:x-large" onclick="enable_tool('{{ alternate_tool.id }}')">
+							<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>
@@ -280,7 +286,7 @@ <h4>You have {{ tool.tool_children_set.all|length|add:1 }} options to use this t
 					</div>
 				{% else %}
 					<div class="col-xs-offset-1 col-xs-5">
-						<div class="alert alert-success" style="text-align:center; margin-bottom:0; font-size:x-large" onclick="enable_tool()">
+						<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>
@@ -343,9 +349,32 @@ <h2>
 		}
 	}
 
+    function update_start_button()
+	{
+		$("#start_reserve").show();
+
+		if(document.querySelector('#tool_control').checkValidity())
+		{
+			$(".start_button").show();
+		}
+		else
+		{
+			$(".start_button").hide();
+		}
+	}
+
 	update_stop_button();
-	$('body').on('question-group-changed', update_stop_button);
-	$('body').on('change keyup', '#tool_control input, #tool_control select, #tool_control textarea, #downtime', update_stop_button);
+	{% if customer.active_projects|length == 1 %}
+		update_start_button();
+	{% endif %}
+
+	{% if tool.in_use %}
+		$('body').on('question-group-changed', update_stop_button);
+		$('body').on('change keyup', '#tool_control input, #tool_control select, #tool_control textarea, #downtime', update_stop_button);
+	{% else %}
+    	$('body').on('question-group-changed', update_start_button);
+		$('body').on('change keyup', '#tool_control input, #tool_control select, #tool_control textarea, #downtime', update_start_button);
+	{% endif %}
 	$('#downtime').numpad({'readonly': false, 'hidePlusMinusButton': true, 'hideDecimalButton': true});
 	$('#delayed_logoff_help').popover();
 	revert(60);
diff --git a/NEMO/apps/kiosk/templates/kiosk/tool_project_selection_snippet.html b/NEMO/apps/kiosk/templates/kiosk/tool_project_selection_snippet.html
index 4d42337fc..c5175ff9f 100644
--- a/NEMO/apps/kiosk/templates/kiosk/tool_project_selection_snippet.html
+++ b/NEMO/apps/kiosk/templates/kiosk/tool_project_selection_snippet.html
@@ -6,6 +6,7 @@ <h2>You cannot {% if tool.in_use %}reserve{% else %}use{% endif %} any tools bec
 	<h2>Tool {% if tool.in_use %}reservation will be associated with{% else %}usage will be billed to{% endif %} the project named &quot;{{ active_projects.0 }}&quot;.</h2>
 	<input type="hidden" name="project_id" value="{{ active_projects.0.id }}">
 	<script>
+        $("#pre_usage_questions").show();
 		$("#start_reserve").show();
 	</script>
 {% else %}
diff --git a/NEMO/apps/kiosk/urls.py b/NEMO/apps/kiosk/urls.py
index b464302bb..f0a29c9c2 100644
--- a/NEMO/apps/kiosk/urls.py
+++ b/NEMO/apps/kiosk/urls.py
@@ -34,6 +34,7 @@
                     views.tool_reservation,
                     name="kiosk_tool_reservation",
                 ),
+                path("logout_user/<int:tool_id>", views.logout_user, name="kiosk_logout_user"),
                 # 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 8e7e26dfe..df2a4948f 100644
--- a/NEMO/apps/kiosk/views.py
+++ b/NEMO/apps/kiosk/views.py
@@ -12,6 +12,7 @@
 from NEMO.models import BadgeReader, Project, Reservation, ReservationItemType, Tool, UsageEvent, User
 from NEMO.policy import policy_class as policy
 from NEMO.utilities import localize, quiet_int
+from NEMO.views.area_access import log_out_user
 from NEMO.views.calendar import (
     cancel_the_reservation,
     extract_reservation_questions,
@@ -66,8 +67,24 @@ def do_enable_tool(request, tool_id):
     new_usage_event.user = customer
     new_usage_event.project = project
     new_usage_event.tool = tool
+
+    # Collect post-usage questions
+    dynamic_form = DynamicForm(tool.pre_usage_questions)
+
+    try:
+        new_usage_event.pre_run_data = dynamic_form.extract(request)
+    except RequiredUnansweredQuestionsException as e:
+        dictionary = {"message": str(e), "delay": 10}
+        return render(request, "kiosk/acknowledgement.html", dictionary)
     new_usage_event.save()
 
+    try:
+        dynamic_form.charge_for_consumables(new_usage_event, new_usage_event.pre_run_data, request)
+    except Exception as e:
+        dictionary = {"message": str(e), "delay": 10}
+        return render(request, "kiosk/acknowledgement.html", dictionary)
+    dynamic_form.update_tool_counters(new_usage_event.pre_run_data, tool.id)
+
     dictionary = {"message": "You can now use the {}".format(tool), "badge_number": customer.badge_number}
     return render(request, "kiosk/acknowledgement.html", dictionary)
 
@@ -82,7 +99,7 @@ def disable_tool(request):
 @synchronized("tool_id")
 def do_disable_tool(request, tool_id):
     tool = Tool.objects.get(id=tool_id)
-    customer = User.objects.get(id=request.POST["customer_id"])
+    customer: User = User.objects.get(id=request.POST["customer_id"])
     downtime = timedelta(minutes=quiet_int(request.POST.get("downtime")))
     bypass_interlock = request.POST.get("bypass", "False") == "True"
     response = policy.check_to_disable_tool(tool, customer, downtime)
@@ -122,14 +139,25 @@ def do_disable_tool(request, tool_id):
             return render(request, "kiosk/acknowledgement.html", dictionary)
 
     try:
-        dynamic_form.charge_for_consumables(current_usage_event, request)
+        dynamic_form.charge_for_consumables(current_usage_event, current_usage_event.run_data, request)
     except Exception as e:
         dictionary = {"message": str(e), "delay": 10}
         return render(request, "kiosk/acknowledgement.html", dictionary)
     dynamic_form.update_tool_counters(current_usage_event.run_data, tool.id)
 
     current_usage_event.save()
-    dictionary = {"message": "You are no longer using the {}".format(tool), "badge_number": customer.badge_number}
+    dictionary = {
+        "message": "You are no longer using the {}".format(tool),
+        "badge_number": customer.badge_number,
+        "delay": 1,
+    }
+    record = customer.area_access_record()
+    if record and tool.ask_to_leave_area_when_done_using:
+        dictionary["tool"] = tool
+        dictionary["area"] = record.area
+        dictionary["delay"] = 10
+        dictionary["ask_logout"] = True
+        return render(request, "kiosk/acknowledgement.html", dictionary)
     return render(request, "kiosk/acknowledgement.html", dictionary)
 
 
@@ -182,7 +210,6 @@ def reserve_tool(request):
     tool_config = tool.is_configurable()
     needs_extra_config = reservation_questions or tool_config
     if needs_extra_config and not request.POST.get("configured") == "true":
-
         dictionary.update(tool.get_configuration_information(user=customer, start=reservation.start))
         dictionary.update(
             {
@@ -342,6 +369,9 @@ def tool_information(request, tool_id, user_id, back):
         "customer": customer,
         "tool": tool,
         "rendered_configuration_html": tool.configuration_widget(customer),
+        "pre_usage_questions": DynamicForm(tool.pre_usage_questions).render(
+            "tool_usage_group_question", tool.id, virtual_inputs=True
+        ),
         "post_usage_questions": DynamicForm(tool.post_usage_questions).render(
             "tool_usage_group_question", tool.id, virtual_inputs=True
         ),
@@ -375,6 +405,27 @@ def kiosk(request, location=None):
     return render(request, "kiosk/kiosk.html", {"badge_reader": get_badge_reader(request)})
 
 
+@login_required
+@permission_required("NEMO.kiosk")
+@require_GET
+def logout_user(request, tool_id):
+    tool = Tool.objects.get(pk=tool_id)
+    if not tool.ask_to_leave_area_when_done_using:
+        dictionary = {"message": "You are not allowed to logout of the area from this page"}
+        return render(request, "kiosk/acknowledgement.html", dictionary)
+    customer = User.objects.get(badge_number=request.GET["badge_number"])
+    record = customer.area_access_record()
+    if record is None:
+        dictionary = {"message": "You are not logged into any areas"}
+        return render(request, "kiosk/acknowledgement.html", dictionary)
+    log_out_user(customer)
+    dictionary = {
+        "message": f"You have been successfully logged out of the {record.area}",
+        "delay": 1,
+    }
+    return render(request, "kiosk/acknowledgement.html", dictionary)
+
+
 def get_badge_reader(request) -> BadgeReader:
     reader_id = request.GET.get("reader_id") or ApplicationCustomization.get_int("default_badge_reader_id")
     try:
diff --git a/NEMO/apps/sensors/admin.py b/NEMO/apps/sensors/admin.py
index 1e79696cb..2620f4113 100644
--- a/NEMO/apps/sensors/admin.py
+++ b/NEMO/apps/sensors/admin.py
@@ -126,6 +126,7 @@ def clean(self):
 
 @register(SensorCard)
 class SensorCardAdmin(admin.ModelAdmin):
+    search_fields = ["name", "server"]
     form = SensorCardAdminForm
     list_display = ("name", "enabled", "server", "port", "category")
     actions = [disable_selected_cards, enable_selected_cards]
@@ -141,11 +142,7 @@ def clean(self):
             return
         cleaned_data = super().clean()
 
-        card = (
-            self.cleaned_data["sensor_card"]
-            if "sensor_card" in self.cleaned_data
-            else self.cleaned_data["interlock_card"]
-        )
+        card = self.cleaned_data.get("sensor_card", None) or self.cleaned_data.get("interlock_card")
         if card:
             category = card.category
             from NEMO.apps.sensors import sensors
@@ -163,7 +160,10 @@ def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         if self.instance.pk:
             children_ids = [child.id for child in self.instance.all_children()]
-            self.fields["parent"].queryset = SensorCategory.objects.exclude(id__in=[self.instance.pk, *children_ids])
+            if "parent" in self.fields:
+                self.fields["parent"].queryset = SensorCategory.objects.exclude(
+                    id__in=[self.instance.pk, *children_ids]
+                )
 
 
 @register(SensorCategory)
@@ -199,6 +199,7 @@ def formfield_for_foreignkey(self, db_field, request, **kwargs):
 
 @register(Sensor)
 class SensorAdmin(admin.ModelAdmin):
+    search_fields = ["name"]
     form = SensorAdminForm
     list_display = (
         "id",
@@ -221,6 +222,7 @@ class SensorAdmin(admin.ModelAdmin):
         ("sensor_category", admin.RelatedOnlyFieldListFilter),
     )
     actions = [duplicate_sensor_configuration, read_selected_sensors, hide_selected_sensors, show_selected_sensors]
+    autocomplete_fields = ["sensor_card", "interlock_card"]
 
     @display(boolean=True, ordering="sensor_card__enabled", description="Card Enabled")
     def get_card_enabled(self, obj: Sensor):
@@ -261,6 +263,7 @@ class SensorDataAdmin(admin.ModelAdmin):
         ("sensor", admin.RelatedOnlyFieldListFilter),
         ("sensor__sensor_category", admin.RelatedOnlyFieldListFilter),
     )
+    autocomplete_fields = ["sensor"]
 
     @display(ordering="sensor__data_prefix", description="Display value")
     def get_display_value(self, obj: SensorData):
@@ -271,6 +274,7 @@ def get_display_value(self, obj: SensorData):
 class SensorAlertEmailAdmin(admin.ModelAdmin):
     list_display = ("sensor", "enabled", "trigger_condition", "trigger_no_data", "additional_emails", "triggered_on")
     actions = [disable_selected_alerts, enable_selected_alerts]
+    autocomplete_fields = ["sensor"]
 
 
 @register(SensorAlertLog)
diff --git a/NEMO/apps/sensors/customizations.py b/NEMO/apps/sensors/customizations.py
index afcd40be2..9c9500141 100644
--- a/NEMO/apps/sensors/customizations.py
+++ b/NEMO/apps/sensors/customizations.py
@@ -11,6 +11,15 @@ class SensorCustomization(CustomizationBase):
         "sensor_default_refresh_rate": "0",
         "sensor_alert_emails": "",
         "sensor_read_retries": "1",
+        "sensor_format_millisecond": "",
+        "sensor_format_second": "",
+        "sensor_format_minute": "",
+        "sensor_format_hour": "",
+        "sensor_format_day": "",
+        "sensor_format_week": "",
+        "sensor_format_month": "",
+        "sensor_format_quarter": "",
+        "sensor_format_year": "",
     }
 
     def validate(self, name, value):
diff --git a/NEMO/apps/sensors/evaluators.py b/NEMO/apps/sensors/evaluators.py
index 3d1de3667..c1ea81c63 100644
--- a/NEMO/apps/sensors/evaluators.py
+++ b/NEMO/apps/sensors/evaluators.py
@@ -1,108 +1,12 @@
 import ast
-import operator
-from math import ceil, floor, sqrt, trunc
+from typing import List
 
+from pymodbus.client import ModbusTcpClient
 from pymodbus.constants import Endian
 from pymodbus.payload import BinaryPayloadDecoder
 
-# supported operators
-base_operators = {
-    ast.Add: operator.add,
-    ast.Sub: operator.sub,
-    ast.Mult: operator.mul,
-    ast.Div: operator.truediv,
-    ast.Pow: operator.pow,
-    ast.BitXor: operator.xor,
-    ast.USub: operator.neg,
-    ast.mod: operator.mod,
-}
-
-# supported functions
-base_functions = {
-    "round": round,
-    "floor": floor,
-    "ceil": ceil,
-    "abs": abs,
-    "trunc": trunc,
-    "sqrt": sqrt,
-    "sum": sum,
-}
-
-
-# noinspection PyTypeChecker
-class BasicEvaluatorVisitor(ast.NodeVisitor):
-    operators = base_operators
-    functions = base_functions
-
-    def __init__(self, **kwargs):
-        self._variables = kwargs
-
-    def visit_Name(self, node: ast.Name):
-        if node.id in self._variables:
-            return self._variables[node.id]
-        else:
-            raise AttributeError(f"Variable not found: {node.id}")
-
-    def visit_Num(self, node: ast.Num):
-        return node.n
-
-    def visit_NameConstant(self, node: ast.NameConstant):
-        return node.value
-
-    def visit_UnaryOp(self, node: ast.UnaryOp):
-        val = self.visit(node.operand)
-        op = type(node.op)
-        if op in self.operators:
-            return self.operators[op](val)
-        else:
-            raise TypeError(f"Unsupported operation: {op.__name__}")
-
-    def visit_BinOp(self, node: ast.BinOp):
-        lhs = self.visit(node.left)
-        rhs = self.visit(node.right)
-        op = type(node.op)
-        if op in self.operators:
-            return self.operators[op](lhs, rhs)
-        else:
-            raise TypeError(f"Unsupported operation: {op.__name__}")
-
-    def visit_Subscript(self, node: ast.Subscript):
-        val = self.visit(node.value)
-        index = self.visit(node.slice)
-        try:
-            return val[index]
-        except AttributeError:
-            return self.generic_visit(node)
-
-    def visit_Index(self, node: ast.Index, **kwargs):
-        """df.index[4]"""
-        return self.visit(node.value)
-
-    def visit_Slice(self, node: ast.Slice):
-        lower = node.lower
-        if lower is not None:
-            lower = self.visit(lower)
-        upper = node.upper
-        if upper is not None:
-            upper = self.visit(upper)
-        step = node.step
-        if step is not None:
-            step = self.visit(step)
-
-        return slice(lower, upper, step)
-
-    def visit_Call(self, node: ast.Call):
-        if node.func.id in self.functions:
-            new_args = [self.visit(arg) for arg in node.args]
-            return self.functions[node.func.id](*new_args)
-        else:
-            self.generic_visit(node)
-
-    def generic_visit(self, node):
-        if isinstance(node, ast.Call):
-            raise TypeError(f"Unsupported operation: {getattr(node.func,'id')}")
-        raise ValueError("malformed node or string: " + repr(node))
-
+from NEMO.apps.sensors.models import Sensor
+from NEMO.evaluators import BasicEvaluatorVisitor
 
 # Special modbus functions
 modbus_functions = [
@@ -121,6 +25,9 @@ def generic_visit(self, node):
     "decode_string",
 ]
 
+# Special functions that are executed on the modbus client itself and require a complete connection
+modbus_client_functions = ["read_coils"]
+
 
 def get_modbus_function(name):
     # This will return the corresponding modbus function
@@ -131,71 +38,43 @@ def modbus_function(registers):
     return modbus_function
 
 
+def evaluate_modbus_client_function(sensor: Sensor, name: str, args: List):
+    # This will evaluate and return the value of the client function
+    client = ModbusTcpClient(sensor.card.server, port=sensor.card.port)
+    try:
+        valid_connection = client.connect()
+        if not valid_connection:
+            raise Exception(f"Connection to server {sensor.card.server}:{sensor.card.port} could not be established")
+        read_reply = getattr(client, name)(*args)
+        if read_reply.isError():
+            raise Exception(str(read_reply))
+        if name == "read_coils":
+            return read_reply.bits[0]
+        return read_reply
+    finally:
+        client.close()
+
+
+# Extension of the basic evaluator with additional modbus specific functions
 # noinspection PyTypeChecker
 class ModbusEvaluatorVisitor(BasicEvaluatorVisitor):
-    # Extension of the basic evaluator with additional modbus specific functions
+    def __init__(self, sensor, **kwargs):
+        self.sensor = sensor
+        super().__init__(**kwargs)
+
     def visit_Call(self, node: ast.Call):
         if node.func.id in self.functions:
             return super().visit_Call(node)
         elif node.func.id in modbus_functions:
-            new_args = [self.visit(arg) for arg in node.args]
-            return get_modbus_function(node.func.id)(*new_args)()
+            function_args = [self.visit(arg) for arg in node.args]
+            return get_modbus_function(node.func.id)(*function_args)()
+        elif node.func.id in modbus_client_functions:
+            function_args = [self.visit(arg) for arg in node.args]
+            return evaluate_modbus_client_function(self.sensor, node.func.id, function_args)
         else:
             self.generic_visit(node)
 
 
-boolean_operators = {
-    **base_operators,
-    ast.Gt: operator.gt,
-    ast.GtE: operator.ge,
-    ast.Lt: operator.lt,
-    ast.LtE: operator.le,
-    ast.Eq: operator.eq,
-    ast.NotEq: operator.ne,
-    ast.Not: operator.not_,
-}
-
-
-# noinspection PyTypeChecker
-class BooleanEvaluatorVisitor(BasicEvaluatorVisitor):
-    operators = boolean_operators
-
-    def visit_bool(self, node: bool):
-        return node
-
-    def visit_BoolOp(self, node: ast.BoolOp):
-        if isinstance(node.op, (ast.And, ast.Or)):
-            values = map(self.visit, node.values)
-            return all(values) if isinstance(node.op, ast.And) else any(values)
-        else:
-            return self.generic_visit(self, node)
-
-    def visit_Compare(self, node: ast.Compare, **kwargs):
-        # base case: we have something like a CMP b
-        if len(node.comparators) == 1:
-            bin_op = ast.BinOp(op=node.ops[0], left=node.left, right=node.comparators[0])
-            return self.visit(bin_op)
-
-        # recursive case: we have a chained comparison, a CMP b CMP c, etc.
-        left = node.left
-        values = []
-        for op, comp in zip(node.ops, node.comparators):
-            new_node = self.visit(ast.Compare(comparators=[comp], left=left, ops=[op]))
-            left = comp
-            values.append(new_node)
-        return self.visit(ast.BoolOp(op=ast.And(), values=values))
-
-
-def evaluate_expression(expr, **kwargs):
-    v = BasicEvaluatorVisitor(**kwargs)
-    return v.visit(ast.parse(expr, mode="eval").body)
-
-
-def evaluate_modbus_expression(expr, **kwargs):
-    v = ModbusEvaluatorVisitor(**kwargs)
-    return v.visit(ast.parse(expr, mode="eval").body)
-
-
-def evaluate_boolean_expression(expr, **kwargs):
-    v = BooleanEvaluatorVisitor(**kwargs)
-    return v.visit(ast.parse(expr, mode="eval").body)
+def evaluate_modbus_expression(sensor, **kwargs):
+    v = ModbusEvaluatorVisitor(sensor, **kwargs)
+    return v.visit(ast.parse(sensor.formula, mode="eval").body)
diff --git a/NEMO/apps/sensors/migrations/0001_initial.py b/NEMO/apps/sensors/migrations/0001_initial.py
index 099a87812..001be5fc3 100644
--- a/NEMO/apps/sensors/migrations/0001_initial.py
+++ b/NEMO/apps/sensors/migrations/0001_initial.py
@@ -128,7 +128,7 @@ def add_modbus_tcp_sensor_category(apps, schema_editor):
                 ),
             ],
             options={
-                "ordering": ["server"],
+                "ordering": ["name"],
             },
         ),
         migrations.AddField(
diff --git a/NEMO/apps/sensors/models.py b/NEMO/apps/sensors/models.py
index 349212d19..aacd8f841 100644
--- a/NEMO/apps/sensors/models.py
+++ b/NEMO/apps/sensors/models.py
@@ -13,8 +13,8 @@
 from django.utils.safestring import mark_safe
 
 from NEMO.apps.sensors.customizations import SensorCustomization
-from NEMO.apps.sensors.evaluators import evaluate_boolean_expression
 from NEMO.decorators import postpone
+from NEMO.evaluators import evaluate_boolean_expression
 from NEMO.fields import MultiEmailField
 from NEMO.models import BaseModel, InterlockCard
 from NEMO.typing import QuerySetType
@@ -45,7 +45,7 @@ class SensorCard(BaseModel):
     enabled = models.BooleanField(blank=False, null=False, default=True)
 
     class Meta:
-        ordering = ["server"]
+        ordering = ["name"]
 
     def __str__(self):
         card_name = self.name + ": " if self.name else ""
@@ -156,18 +156,6 @@ def clean(self):
             and self.number_of_values > 1
         ):
             raise ValidationError({"formula": "This field is required when reading multiple values"})
-        if self.formula:
-            # Use random values to test the formula
-            registers = []
-            if self.read_address is not None and self.number_of_values:
-                for i in range(self.number_of_values):
-                    registers.append(random.randint(0, 1000))
-            else:
-                registers = [random.randint(0, 1000)]
-            try:
-                sensors.get(self.card.category, raise_exception=True).evaluate_expression(self.formula, registers)
-            except Exception as e:
-                raise ValidationError({"formula": str(e)})
 
     def alert_triggered(self) -> bool:
         for alert_qs in SensorAlert.sensor_alert_filter(sensor=self):
diff --git a/NEMO/apps/sensors/sensors.py b/NEMO/apps/sensors/sensors.py
index 77e98d31f..26e18c36b 100644
--- a/NEMO/apps/sensors/sensors.py
+++ b/NEMO/apps/sensors/sensors.py
@@ -1,15 +1,20 @@
+import random
 from abc import ABC, abstractmethod
 from logging import getLogger
 from typing import Dict, List
+from unittest import mock
 
 from django.core.exceptions import ValidationError
 from django.utils.translation import gettext_lazy as _
+from pymodbus.bit_read_message import ReadCoilsRequest, ReadCoilsResponse
 from pymodbus.client import ModbusTcpClient
+from pymodbus.pdu import ModbusRequest, ModbusResponse
 
 from NEMO.apps.sensors.admin import SensorAdminForm, SensorCardAdminForm
 from NEMO.apps.sensors.customizations import SensorCustomization
-from NEMO.apps.sensors.evaluators import evaluate_expression, evaluate_modbus_expression
+from NEMO.apps.sensors.evaluators import evaluate_modbus_expression
 from NEMO.apps.sensors.models import Sensor as Sensor_model, SensorAlert, SensorCardCategory, SensorData
+from NEMO.evaluators import evaluate_expression, get_expression_variables
 
 sensors_logger = getLogger(__name__)
 
@@ -28,7 +33,28 @@ def clean_sensor_card(self, sensor_card_form: SensorCardAdminForm):
         pass
 
     def clean_sensor(self, sensor_form: SensorAdminForm):
-        pass
+        # Validating formula
+        formula = sensor_form.cleaned_data["formula"]
+        read_address = sensor_form.cleaned_data["read_address"]
+        number_of_values = sensor_form.cleaned_data["number_of_values"]
+        sensor_card = sensor_form.cleaned_data["sensor_card"]
+        interlock_card = sensor_form.cleaned_data["interlock_card"]
+        if formula:
+            # Use random values to test the formula
+            registers = []
+            if read_address is not None and number_of_values:
+                for i in range(number_of_values):
+                    registers.append(random.randint(0, 1000))
+            else:
+                registers = [random.randint(0, 1000)]
+            try:
+                sensor = Sensor_model()
+                sensor.sensor_card = sensor_card
+                sensor.interlock_card = interlock_card
+                sensor.formula = formula
+                get(sensor.card.category, raise_exception=True).evaluate_expression(sensor, registers)
+            except Exception as e:
+                raise ValidationError({"formula": str(e)})
 
     def read_values(self, sensor: Sensor_model, raise_exception=False):
         if not sensor.card.enabled:
@@ -66,7 +92,7 @@ def read_values(self, sensor: Sensor_model, raise_exception=False):
     def evaluate_sensor(self, sensor, registers, raise_exception=True):
         try:
             if sensor.formula:
-                return self.evaluate_expression(sensor.formula, registers)
+                return self.evaluate_expression(sensor, registers)
             else:
                 return next(iter(registers or []), None)
         except Exception as e:
@@ -74,8 +100,8 @@ def evaluate_sensor(self, sensor, registers, raise_exception=True):
             if raise_exception:
                 raise
 
-    def evaluate_expression(self, formula, registers):
-        return evaluate_expression(formula, registers=registers)
+    def evaluate_expression(self, sensor, registers):
+        return evaluate_expression(sensor.formula, registers=registers)
 
     @abstractmethod
     def do_read_values(self, sensor: Sensor_model) -> List:
@@ -93,17 +119,51 @@ def process_alerts(sensor: Sensor, sensor_data: SensorData = None):
         sensors_logger.error(e)
 
 
+# Mocked client to use when validating modbus formula
+def mocked_modbus_client(*args, **kwargs):
+    class MockModbusClient(ModbusTcpClient):
+        def connect(self):
+            return True
+
+        def execute(self, request: ModbusRequest = None) -> ModbusResponse:
+            if isinstance(request, ReadCoilsRequest):
+                return ReadCoilsResponse(values=[random.randint(0, 1)])
+            return ModbusResponse()
+
+    return MockModbusClient(*args, **kwargs)
+
+
 class ModbusTcpSensor(Sensor):
     def clean_sensor(self, sensor_form: SensorAdminForm):
+        formula = sensor_form.cleaned_data["formula"]
         read_address = sensor_form.cleaned_data["read_address"]
         number_of_values = sensor_form.cleaned_data["number_of_values"]
         error = {}
-        if read_address is None:
-            error["read_address"] = _("This field is required.")
-        if not number_of_values:
-            error["number_of_values"] = _("This field is required.")
+        # Only require read_address and number_of_values if we have no formula
+        # or if the formula uses the registers variable
+        if not formula or "registers" in get_expression_variables(formula):
+            if read_address is None:
+                error["read_address"] = _("This field is required.")
+            if not number_of_values:
+                error["number_of_values"] = _("This field is required.")
         if error:
             raise ValidationError(error)
+        sensor_card = sensor_form.cleaned_data["sensor_card"]
+        interlock_card = sensor_form.cleaned_data["interlock_card"]
+        if formula:
+            # Use random values to test the formula
+            registers = []
+            if read_address is not None and number_of_values:
+                for i in range(number_of_values):
+                    registers.append(random.randint(0, 1000))
+            else:
+                registers = [random.randint(0, 1000)]
+            try:
+                sensor = Sensor_model(sensor_card=sensor_card, interlock_card=interlock_card, formula=formula)
+                with mock.patch("NEMO.apps.sensors.evaluators.ModbusTcpClient", side_effect=mocked_modbus_client):
+                    get(sensor.card.category, raise_exception=True).evaluate_expression(sensor, registers)
+            except Exception as e:
+                raise ValidationError({"formula": str(e)})
 
     def do_read_values(self, sensor: Sensor_model) -> List:
         client = ModbusTcpClient(sensor.card.server, port=sensor.card.port)
@@ -121,9 +181,9 @@ def do_read_values(self, sensor: Sensor_model) -> List:
         finally:
             client.close()
 
-    def evaluate_expression(self, formula, registers):
+    def evaluate_expression(self, sensor, registers):
         # Here we are using an expanded evaluator which includes modbus specific functions
-        return evaluate_modbus_expression(formula, registers=registers)
+        return evaluate_modbus_expression(sensor, registers=registers)
 
 
 class NoOpSensor(Sensor):
diff --git a/NEMO/apps/sensors/templates/customizations/customizations_sensors.html b/NEMO/apps/sensors/templates/customizations/customizations_sensors.html
index 4a560f451..f45423302 100644
--- a/NEMO/apps/sensors/templates/customizations/customizations_sensors.html
+++ b/NEMO/apps/sensors/templates/customizations/customizations_sensors.html
@@ -1,4 +1,10 @@
+{% load static %}
 {% load custom_tags_and_filters %}
+{% block extrahead %}
+    {# Chart.js #}
+    <script type="text/javascript" src="{% static "sensors/chart.min.js" %}"></script>
+    <script type="text/javascript" src="{% static "sensors/chartjs-adapter-moment.js" %}"></script>
+{% endblock %}
 <div class="panel-body">
     <h3 class="customization-section-title">Sensor data settings</h3>
     <form method="POST" action="{% url 'customize' 'sensors' %}" class="form-horizontal">
@@ -58,8 +64,41 @@ <h3 class="customization-section-title">Sensor data settings</h3>
             </div>
         </div>
         <div class="customization-separation" style="margin-bottom: 15px"></div>
+        <h3 class="customization-section-title">Chart date formats</h3>
+        <div class="col-sm-12 form-group help-block light-grey">
+            These formats are used in charts to display the date/time on the x-axis. See <a
+                href="https://momentjs.com/docs/#/displaying/format/">moment.js format</a> for more details.
+        </div>
+        {% with date_format_list="millisecond second minute hour day week month quarter year" %}
+            {% for date_format in date_format_list.split %}
+                <div class="form-group">
+                    {% with date_format_variable="sensor_format_"|concat:date_format %}
+                        <label class="control-label col-md-3"
+                               for="{{ date_format_variable }}">{{ date_format|capfirst }}:</label>
+                        <div class="col-md-4">
+                            <input type="text" id="{{ date_format_variable }}" name="{{ date_format_variable }}"
+                                   class="form-control"
+                                   value="{{ "sensors"|customization:date_format_variable|default_if_none:"" }}"
+                                   placeholder=""/>
+                        </div>
+                    {% endwith %}
+                </div>
+            {% endfor %}
+        {% endwith %}
+        <div class="customization-separation" style="margin-bottom: 15px"></div>
         <div class="text-center">
             {% button type="save" value="Save settings" %}
         </div>
+        {# add a "fake" chart to get the adapter's formats and use them as placeholders for the inputs #}
+        <canvas class="hidden" id="customization_chart"></canvas>
+        <script>
+            const customization_chart = new Chart(document.getElementById('customization_chart').getContext('2d'),
+                {
+                    options: {scales: {x: {type: 'time'}}}
+                });
+            for (let [key, value] of Object.entries(customization_chart.scales['x']._adapter.formats())) {
+                $('#sensor_format_' + key).attr('placeholder', value);
+            }
+        </script>
     </form>
 </div>
\ No newline at end of file
diff --git a/NEMO/apps/sensors/templates/sensors/sensor_data.html b/NEMO/apps/sensors/templates/sensors/sensor_data.html
index 8aa7b4773..612a6e204 100644
--- a/NEMO/apps/sensors/templates/sensors/sensor_data.html
+++ b/NEMO/apps/sensors/templates/sensors/sensor_data.html
@@ -209,7 +209,18 @@
                     {
                         x:
                         {
-                            type: 'time'
+                            type: 'time',
+                            time:
+                                {
+                                    displayFormats:
+                                        {
+                                            {% for key, value in sensor_date_formats.items %}
+                                                {% if value %}
+                                                    {{ key }}: "{{ value }}",
+                                                {% endif %}
+                                            {% endfor %}
+                                        }
+                                }
                         },
                         y:
                         {
diff --git a/NEMO/apps/sensors/views.py b/NEMO/apps/sensors/views.py
index 4bccb1fd0..1a8059795 100644
--- a/NEMO/apps/sensors/views.py
+++ b/NEMO/apps/sensors/views.py
@@ -55,6 +55,11 @@ def sensor_details(request, sensor_id, tab: str = None):
         "end": end,
         "refresh_rate": refresh_rate,
         "chart_step": chart_step,
+        "sensor_date_formats": {
+            variable.replace("sensor_format_", ""): SensorCustomization.get(variable)
+            for variable in SensorCustomization.variables.keys()
+            if variable.startswith("sensor_format_")
+        },
     }
     return render(request, "sensors/sensor_data.html", dictionary)
 
diff --git a/NEMO/context_processors.py b/NEMO/context_processors.py
index 9889bb9be..7282715cf 100644
--- a/NEMO/context_processors.py
+++ b/NEMO/context_processors.py
@@ -1,11 +1,5 @@
-from NEMO.models import Area, Notification, PhysicalAccessLevel, Tool, User
+from NEMO.models import Area, Customization, Notification, PhysicalAccessLevel, Tool, User
 from NEMO.utilities import date_input_js_format, datetime_input_js_format, time_input_js_format
-from NEMO.views.customization import (
-    ApplicationCustomization,
-    RecurringChargesCustomization,
-    SafetyCustomization,
-    UserRequestsCustomization,
-)
 from NEMO.views.notifications import get_notification_counts
 
 
@@ -26,18 +20,6 @@ def base_context(request):
                 request.session["no_header"] = False
     except:
         request.session["no_header"] = False
-    try:
-        facility_name = ApplicationCustomization.get("facility_name")
-    except:
-        facility_name = "Facility"
-    try:
-        recurring_charges_name = RecurringChargesCustomization.get("recurring_charges_name")
-    except:
-        recurring_charges_name = "Recurring charges"
-    try:
-        site_title = ApplicationCustomization.get("site_title")
-    except:
-        site_title = ""
     try:
         tools_exist = Tool.objects.filter(visible=True).exists()
     except:
@@ -54,10 +36,6 @@ def base_context(request):
         access_user_request_allowed_exist = PhysicalAccessLevel.objects.filter(allow_user_request=True).exists()
     except:
         access_user_request_allowed_exist = False
-    try:
-        adjustment_request_allowed = UserRequestsCustomization.get_bool("adjustment_requests_enabled")
-    except:
-        adjustment_request_allowed = False
     try:
         notification_counts = get_notification_counts(request.user)
     except:
@@ -84,40 +62,17 @@ def base_context(request):
         facility_managers_exist = User.objects.filter(is_active=True, is_facility_manager=True).exists()
     except:
         facility_managers_exist = False
-    try:
-        safety_menu_item = SafetyCustomization.get_bool("safety_main_menu")
-    except:
-        safety_menu_item = True
-    try:
-        calendar_page_title = ApplicationCustomization.get("calendar_page_title")
-    except:
-        calendar_page_title = "Calendar"
-    try:
-        tool_control_page_title = ApplicationCustomization.get("tool_control_page_title")
-    except:
-        tool_control_page_title = "Tool control"
-    try:
-        status_dashboard_page_title = ApplicationCustomization.get("status_dashboard_page_title")
-    except:
-        status_dashboard_page_title = "Status dashboard"
-    try:
-        requests_page_title = ApplicationCustomization.get("requests_page_title")
-    except:
-        requests_page_title = "Requests"
-    try:
-        safety_page_title = ApplicationCustomization.get("safety_page_title")
-    except:
-        safety_page_title = "Safety"
+    customization_values = {customization.name: customization.value for customization in Customization.objects.all()}
     return {
-        "facility_name": facility_name,
-        "recurring_charges_name": recurring_charges_name,
-        "site_title": site_title,
+        "facility_name": customization_values.get("facility_name", "Facility"),
+        "recurring_charges_name": customization_values.get("recurring_charges_name", "Recurring charges"),
+        "site_title": customization_values.get("site_title", ""),
         "device": getattr(request, "device", "desktop"),
         "tools_exist": tools_exist,
         "areas_exist": areas_exist,
         "buddy_system_areas_exist": buddy_system_areas_exist,
         "access_user_request_allowed_exist": access_user_request_allowed_exist,
-        "adjustment_request_allowed": adjustment_request_allowed,
+        "adjustment_request_allowed": customization_values.get("adjustment_requests_enabled", "") == "enabled",
         "notification_counts": notification_counts,
         "buddy_notification_count": buddy_notification_count,
         "temporary_access_notification_count": temporary_access_notification_count,
@@ -128,10 +83,10 @@ def base_context(request):
         "date_input_js_format": date_input_js_format,
         "datetime_input_js_format": datetime_input_js_format,
         "no_header": request.session.get("no_header", False),
-        "safety_menu_item": safety_menu_item,
-        "calendar_page_title": calendar_page_title,
-        "tool_control_page_title": tool_control_page_title,
-        "status_dashboard_page_title": status_dashboard_page_title,
-        "requests_page_title": requests_page_title,
-        "safety_page_title": safety_page_title,
+        "safety_menu_item": customization_values.get("safety_main_menu", "enabled") == "enabled",
+        "calendar_page_title": customization_values.get("calendar_page_title", "Calendar"),
+        "tool_control_page_title": customization_values.get("tool_control_page_title", "Tool control"),
+        "status_dashboard_page_title": customization_values.get("status_dashboard_page_title", "Status dashboard"),
+        "requests_page_title": customization_values.get("requests_page_title", "Requests"),
+        "safety_page_title": customization_values.get("safety_page_title", "Safety"),
     }
diff --git a/NEMO/decorators.py b/NEMO/decorators.py
index 98730d1ab..d131146bc 100644
--- a/NEMO/decorators.py
+++ b/NEMO/decorators.py
@@ -2,6 +2,7 @@
 import inspect
 import sys
 from functools import wraps
+from logging import getLogger
 from threading import Lock, RLock, Thread
 
 from django.contrib.auth import REDIRECT_FIELD_NAME
@@ -9,6 +10,8 @@
 
 from NEMO.utilities import slugify_underscore
 
+decorators_logger = getLogger(__name__)
+
 
 def disable_session_expiry_refresh(f):
     """
@@ -130,17 +133,25 @@ def decorator(view_func=None, redirect_field_name=REDIRECT_FIELD_NAME, login_url
 # For example, to replace NEMO.views.policy.check_policy_to_save_reservation(arg1, arg2)
 # @replace_function("NEMO.views.policy.check_policy_to_save_reservation")
 # def new_function(old_function, arg1, arg2)
-def replace_function(old_function_name):
-    pkg, fun_name = old_function_name.rsplit(".", 1)
-    pkg_mod = importlib.import_module(pkg)
-    old_function = getattr(pkg_mod, fun_name)
+def replace_function(old_function_name, raise_exception=True):
+    try:
+        pkg, fun_name = old_function_name.rsplit(".", 1)
+        pkg_mod = importlib.import_module(pkg)
+        old_function = getattr(pkg_mod, fun_name)
+    except:
+        old_function = None
+        if raise_exception:
+            raise
+        else:
+            decorators_logger.warning(f"Could not replace function: {old_function_name}", exc_info=True)
 
     def decorator(function):
         @wraps(function)
         def wrapper(*args, **kwargs):
             return function(old_function, *args, **kwargs)
 
-        setattr(pkg_mod, fun_name, wrapper)
+        if old_function:
+            setattr(pkg_mod, fun_name, wrapper)
         return wrapper
 
     return decorator
diff --git a/NEMO/evaluators.py b/NEMO/evaluators.py
new file mode 100644
index 000000000..69fac4ef2
--- /dev/null
+++ b/NEMO/evaluators.py
@@ -0,0 +1,159 @@
+import ast
+import operator
+from math import ceil, floor, sqrt, trunc
+from typing import Set
+
+# supported operators
+base_operators = {
+    ast.Add: operator.add,
+    ast.Sub: operator.sub,
+    ast.Mult: operator.mul,
+    ast.Div: operator.truediv,
+    ast.Pow: operator.pow,
+    ast.BitXor: operator.xor,
+    ast.USub: operator.neg,
+    ast.mod: operator.mod,
+}
+
+# supported functions
+base_functions = {
+    "round": round,
+    "floor": floor,
+    "ceil": ceil,
+    "abs": abs,
+    "trunc": trunc,
+    "sqrt": sqrt,
+    "sum": sum,
+}
+
+
+# noinspection PyTypeChecker
+class BasicEvaluatorVisitor(ast.NodeVisitor):
+    operators = base_operators
+    functions = base_functions
+
+    def __init__(self, **kwargs):
+        self._variables = kwargs
+
+    def visit_Name(self, node: ast.Name):
+        if node.id in self._variables:
+            return self._variables[node.id]
+        else:
+            raise AttributeError(f"Variable not found: {node.id}")
+
+    def visit_Constant(self, node: ast.Constant):
+        return node.value
+
+    def visit_UnaryOp(self, node: ast.UnaryOp):
+        val = self.visit(node.operand)
+        op = type(node.op)
+        if op in self.operators:
+            return self.operators[op](val)
+        else:
+            raise TypeError(f"Unsupported operation: {op.__name__}")
+
+    def visit_BinOp(self, node: ast.BinOp):
+        lhs = self.visit(node.left)
+        rhs = self.visit(node.right)
+        op = type(node.op)
+        if op in self.operators:
+            return self.operators[op](lhs, rhs)
+        else:
+            raise TypeError(f"Unsupported operation: {op.__name__}")
+
+    def visit_Subscript(self, node: ast.Subscript):
+        val = self.visit(node.value)
+        index = self.visit(node.slice)
+        try:
+            return val[index]
+        except AttributeError:
+            return self.generic_visit(node)
+
+    def visit_Index(self, node: ast.Index, **kwargs):
+        """df.index[4]"""
+        return self.visit(node.value)
+
+    def visit_Slice(self, node: ast.Slice):
+        lower = node.lower
+        if lower is not None:
+            lower = self.visit(lower)
+        upper = node.upper
+        if upper is not None:
+            upper = self.visit(upper)
+        step = node.step
+        if step is not None:
+            step = self.visit(step)
+
+        return slice(lower, upper, step)
+
+    def visit_Call(self, node: ast.Call):
+        if node.func.id in self.functions:
+            new_args = [self.visit(arg) for arg in node.args]
+            return self.functions[node.func.id](*new_args)
+        else:
+            self.generic_visit(node)
+
+    def generic_visit(self, node):
+        if isinstance(node, ast.Call):
+            raise TypeError(f"Unsupported operation: {getattr(node.func,'id')}")
+        raise ValueError("malformed node or string: " + repr(node))
+
+
+boolean_operators = {
+    **base_operators,
+    ast.Gt: operator.gt,
+    ast.GtE: operator.ge,
+    ast.Lt: operator.lt,
+    ast.LtE: operator.le,
+    ast.Eq: operator.eq,
+    ast.NotEq: operator.ne,
+    ast.Not: operator.not_,
+}
+
+
+# noinspection PyTypeChecker
+class BooleanEvaluatorVisitor(BasicEvaluatorVisitor):
+    operators = boolean_operators
+
+    def visit_bool(self, node: bool):
+        return node
+
+    def visit_BoolOp(self, node: ast.BoolOp):
+        if isinstance(node.op, (ast.And, ast.Or)):
+            values = map(self.visit, node.values)
+            return all(values) if isinstance(node.op, ast.And) else any(values)
+        else:
+            return self.generic_visit(self, node)
+
+    def visit_Compare(self, node: ast.Compare, **kwargs):
+        # base case: we have something like a CMP b
+        if len(node.comparators) == 1:
+            bin_op = ast.BinOp(op=node.ops[0], left=node.left, right=node.comparators[0])
+            return self.visit(bin_op)
+
+        # recursive case: we have a chained comparison, a CMP b CMP c, etc.
+        left = node.left
+        values = []
+        for op, comp in zip(node.ops, node.comparators):
+            new_node = self.visit(ast.Compare(comparators=[comp], left=left, ops=[op]))
+            left = comp
+            values.append(new_node)
+        return self.visit(ast.BoolOp(op=ast.And(), values=values))
+
+
+def evaluate_expression(expr, **kwargs):
+    v = BasicEvaluatorVisitor(**kwargs)
+    return v.visit(ast.parse(expr, mode="eval").body)
+
+
+def evaluate_boolean_expression(expr, **kwargs):
+    v = BooleanEvaluatorVisitor(**kwargs)
+    return v.visit(ast.parse(expr, mode="eval").body)
+
+
+def get_expression_variables(source) -> Set:
+    variables = set()
+    for node in ast.walk(ast.parse(source)):
+        if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load) and node.id not in base_functions:
+            variables.add(node.id)
+    return variables
diff --git a/NEMO/fields.py b/NEMO/fields.py
index 9b061bb87..4065ff989 100644
--- a/NEMO/fields.py
+++ b/NEMO/fields.py
@@ -117,7 +117,7 @@ def to_python(self, value):
             return value
         if value is None:
             return []
-        return [address.strip() for address in value.split(self.separator)]
+        return [address.strip() for address in value.split(self.separator) if address]
 
     def value_to_string(self, obj):
         value = self.value_from_object(obj)
diff --git a/NEMO/forms.py b/NEMO/forms.py
index ceaafdcbf..8d491504c 100644
--- a/NEMO/forms.py
+++ b/NEMO/forms.py
@@ -50,6 +50,8 @@ class Meta:
         model = User
         exclude = [
             "is_staff",
+            "is_user_office",
+            "is_accounting_officer",
             "is_technician",
             "is_facility_manager",
             "is_superuser",
diff --git a/NEMO/middleware.py b/NEMO/middleware.py
index 690f0e4a6..a571283b4 100644
--- a/NEMO/middleware.py
+++ b/NEMO/middleware.py
@@ -103,7 +103,7 @@ def __call__(self, request):
 
 class ImpersonateMiddleware(MiddlewareMixin):
     def process_request(self, request):
-        if request.user.is_superuser and "impersonate_id" in request.session:
+        if request.user.has_perm("NEMO.can_impersonate_user") and "impersonate_id" in request.session:
             request.user = User.objects.get(pk=request.session["impersonate_id"])
 
 
diff --git a/NEMO/migrations/0057_version_5_3_0.py b/NEMO/migrations/0057_version_5_3_0.py
new file mode 100644
index 000000000..f9d4d4900
--- /dev/null
+++ b/NEMO/migrations/0057_version_5_3_0.py
@@ -0,0 +1,18 @@
+# Generated by Django 3.2.20 on 2023-09-14 01:54
+
+from django.db import migrations
+
+from NEMO.migrations_utils import create_news_for_version
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("NEMO", "0056_version_5_2_0"),
+    ]
+
+    def new_version_news(apps, schema_editor):
+        create_news_for_version(apps, "5.3.0")
+
+    operations = [
+        migrations.RunPython(new_version_news),
+    ]
diff --git a/NEMO/migrations/0058_reservation_confirmation_override_prefs.py b/NEMO/migrations/0058_reservation_confirmation_override_prefs.py
new file mode 100644
index 000000000..037adbff0
--- /dev/null
+++ b/NEMO/migrations/0058_reservation_confirmation_override_prefs.py
@@ -0,0 +1,28 @@
+# Generated by Django 3.2.22 on 2024-01-10 19:19
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("NEMO", "0057_version_5_3_0"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="userpreferences",
+            name="change_reservation_confirmation_override",
+            field=models.BooleanField(
+                default=False,
+                help_text="Override default move/resize reservation confirmation setting",
+            ),
+        ),
+        migrations.AddField(
+            model_name="userpreferences",
+            name="create_reservation_confirmation_override",
+            field=models.BooleanField(
+                default=False,
+                help_text="Override default create reservation confirmation setting",
+            ),
+        ),
+    ]
diff --git a/NEMO/migrations/0059_tool_pre_usage_questions.py b/NEMO/migrations/0059_tool_pre_usage_questions.py
new file mode 100644
index 000000000..b6a9a995d
--- /dev/null
+++ b/NEMO/migrations/0059_tool_pre_usage_questions.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.2.22 on 2024-01-19 13:47
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("NEMO", "0058_reservation_confirmation_override_prefs"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="tool",
+            name="_pre_usage_questions",
+            field=models.TextField(
+                blank=True,
+                db_column="pre_usage_questions",
+                help_text="Before using a tool, questions can be asked. This field will only accept JSON format",
+                null=True,
+            ),
+        ),
+        migrations.AddField(
+            model_name="usageevent",
+            name="pre_run_data",
+            field=models.TextField(blank=True, null=True),
+        ),
+    ]
diff --git a/NEMO/migrations/0060_add_impersonate_permission.py b/NEMO/migrations/0060_add_impersonate_permission.py
new file mode 100644
index 000000000..d19196d87
--- /dev/null
+++ b/NEMO/migrations/0060_add_impersonate_permission.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.2.23 on 2024-02-23 13:59
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("NEMO", "0059_tool_pre_usage_questions"),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name="user",
+            options={
+                "ordering": ["first_name"],
+                "permissions": (
+                    ("trigger_timed_services", "Can trigger timed services"),
+                    ("use_billing_api", "Can use billing API"),
+                    ("kiosk", "Kiosk services"),
+                    ("can_impersonate_users", "Can impersonate other users"),
+                ),
+            },
+        ),
+    ]
diff --git a/NEMO/migrations/0061_alter_interlock_unique_together.py b/NEMO/migrations/0061_alter_interlock_unique_together.py
new file mode 100644
index 000000000..a695cc135
--- /dev/null
+++ b/NEMO/migrations/0061_alter_interlock_unique_together.py
@@ -0,0 +1,16 @@
+# Generated by Django 3.2.23 on 2024-02-24 05:27
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("NEMO", "0060_add_impersonate_permission"),
+    ]
+
+    operations = [
+        migrations.AlterUniqueTogether(
+            name="interlock",
+            unique_together=set(),
+        ),
+    ]
diff --git a/NEMO/migrations/0062_migrate_email_upcoming_reservation.py b/NEMO/migrations/0062_migrate_email_upcoming_reservation.py
new file mode 100644
index 000000000..375d84b94
--- /dev/null
+++ b/NEMO/migrations/0062_migrate_email_upcoming_reservation.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.2.23 on 2024-02-25 03:14
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("NEMO", "0061_alter_interlock_unique_together"),
+    ]
+
+    def migrate_customization_forward(apps, schema_editor):
+        Customization = apps.get_model("NEMO", "Customization")
+        qualified = Customization.objects.filter(name="tool_control_broadcast_upcoming_reservation").first()
+        if qualified and qualified.value == "enabled":
+            qualified.value = "qualified"
+            qualified.save()
+
+    def migrate_customization_reverse(apps, schema_editor):
+        Customization = apps.get_model("NEMO", "Customization")
+        qualified = Customization.objects.filter(name="tool_control_broadcast_upcoming_reservation").first()
+        if qualified and qualified.value == "qualified":
+            qualified.value = "enabled"
+            qualified.save()
+
+    operations = [
+        migrations.RunPython(migrate_customization_forward, migrate_customization_reverse),
+    ]
diff --git a/NEMO/migrations/0063_knowledge_base.py b/NEMO/migrations/0063_knowledge_base.py
new file mode 100644
index 000000000..1c95f6cb2
--- /dev/null
+++ b/NEMO/migrations/0063_knowledge_base.py
@@ -0,0 +1,175 @@
+# Generated by Django 3.2.23 on 2024-03-03 03:41
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+import NEMO.utilities
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ("NEMO", "0062_migrate_email_upcoming_reservation"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="StaffKnowledgeBaseCategory",
+            fields=[
+                ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                ("name", models.CharField(help_text="The unique name for this item", max_length=200, unique=True)),
+                (
+                    "display_order",
+                    models.IntegerField(
+                        help_text="The display order is used to sort these items. The lowest value category is displayed first."
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name_plural": "Staff knowledge base categories",
+                "ordering": ["display_order", "name"],
+                "abstract": False,
+            },
+        ),
+        migrations.CreateModel(
+            name="StaffKnowledgeBaseItem",
+            fields=[
+                ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                ("name", models.CharField(help_text="The item name.", max_length=200)),
+                (
+                    "description",
+                    models.TextField(
+                        blank=True, help_text="The description for this item. HTML can be used.", null=True
+                    ),
+                ),
+                (
+                    "category",
+                    models.ForeignKey(
+                        blank=True,
+                        help_text="The category for this item.",
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        to="NEMO.staffknowledgebasecategory",
+                    ),
+                ),
+            ],
+            options={
+                "abstract": False,
+            },
+        ),
+        migrations.CreateModel(
+            name="UserKnowledgeBaseCategory",
+            fields=[
+                ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                ("name", models.CharField(help_text="The unique name for this item", max_length=200, unique=True)),
+                (
+                    "display_order",
+                    models.IntegerField(
+                        help_text="The display order is used to sort these items. The lowest value category is displayed first."
+                    ),
+                ),
+            ],
+            options={
+                "verbose_name_plural": "User knowledge base categories",
+                "ordering": ["display_order", "name"],
+                "abstract": False,
+            },
+        ),
+        migrations.CreateModel(
+            name="UserKnowledgeBaseItem",
+            fields=[
+                ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                ("name", models.CharField(help_text="The item name.", max_length=200)),
+                (
+                    "description",
+                    models.TextField(
+                        blank=True, help_text="The description for this item. HTML can be used.", null=True
+                    ),
+                ),
+                (
+                    "category",
+                    models.ForeignKey(
+                        blank=True,
+                        help_text="The category for this item.",
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        to="NEMO.userknowledgebasecategory",
+                    ),
+                ),
+            ],
+            options={
+                "abstract": False,
+            },
+        ),
+        migrations.CreateModel(
+            name="UserKnowledgeBaseItemDocuments",
+            fields=[
+                ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                (
+                    "document",
+                    models.FileField(
+                        blank=True,
+                        max_length=255,
+                        null=True,
+                        upload_to=NEMO.utilities.document_filename_upload,
+                        verbose_name="Document",
+                    ),
+                ),
+                ("url", models.CharField(blank=True, max_length=200, null=True, verbose_name="URL")),
+                (
+                    "name",
+                    models.CharField(
+                        blank=True,
+                        help_text="The optional name to display for this document",
+                        max_length=200,
+                        null=True,
+                    ),
+                ),
+                ("uploaded_at", models.DateTimeField(auto_now_add=True)),
+                (
+                    "item",
+                    models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="NEMO.userknowledgebaseitem"),
+                ),
+            ],
+            options={
+                "verbose_name_plural": "User knowledge base item documents",
+                "ordering": ["-uploaded_at"],
+                "abstract": False,
+            },
+        ),
+        migrations.CreateModel(
+            name="StaffKnowledgeBaseItemDocuments",
+            fields=[
+                ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+                (
+                    "document",
+                    models.FileField(
+                        blank=True,
+                        max_length=255,
+                        null=True,
+                        upload_to=NEMO.utilities.document_filename_upload,
+                        verbose_name="Document",
+                    ),
+                ),
+                ("url", models.CharField(blank=True, max_length=200, null=True, verbose_name="URL")),
+                (
+                    "name",
+                    models.CharField(
+                        blank=True,
+                        help_text="The optional name to display for this document",
+                        max_length=200,
+                        null=True,
+                    ),
+                ),
+                ("uploaded_at", models.DateTimeField(auto_now_add=True)),
+                (
+                    "item",
+                    models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="NEMO.staffknowledgebaseitem"),
+                ),
+            ],
+            options={
+                "verbose_name_plural": "Staff knowledge base item documents",
+                "ordering": ["-uploaded_at"],
+                "abstract": False,
+            },
+        ),
+    ]
diff --git a/NEMO/migrations/0064_consumable_self_checkout_only_users.py b/NEMO/migrations/0064_consumable_self_checkout_only_users.py
new file mode 100644
index 000000000..2f5f6e17e
--- /dev/null
+++ b/NEMO/migrations/0064_consumable_self_checkout_only_users.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.23 on 2024-03-07 14:11
+
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("NEMO", "0063_knowledge_base"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="consumable",
+            name="self_checkout_only_users",
+            field=models.ManyToManyField(
+                blank=True,
+                help_text="Selected users will be the only ones allowed to self checkout this consumable. Leave blank for all.",
+                to=settings.AUTH_USER_MODEL,
+            ),
+        ),
+    ]
diff --git a/NEMO/migrations/0065_door_adjacent_area.py b/NEMO/migrations/0065_door_adjacent_area.py
new file mode 100644
index 000000000..414c1ea39
--- /dev/null
+++ b/NEMO/migrations/0065_door_adjacent_area.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.2.23 on 2024-03-08 20:58
+
+import django.db.models.deletion
+import mptt.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("NEMO", "0064_consumable_self_checkout_only_users"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="door",
+            name="adjacent_area",
+            field=mptt.fields.TreeForeignKey(
+                blank=True,
+                help_text="When logging out, the user will be asked if they want to log in to this adjacent area",
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="adjacent_doors",
+                to="NEMO.area",
+            ),
+        ),
+    ]
diff --git a/NEMO/migrations/0066_tool_ask_to_leave_area_when_done_using.py b/NEMO/migrations/0066_tool_ask_to_leave_area_when_done_using.py
new file mode 100644
index 000000000..0c0888a45
--- /dev/null
+++ b/NEMO/migrations/0066_tool_ask_to_leave_area_when_done_using.py
@@ -0,0 +1,22 @@
+# Generated by Django 3.2.23 on 2024-03-13 20:19
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("NEMO", "0065_door_adjacent_area"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="tool",
+            name="_ask_to_leave_area_when_done_using",
+            field=models.BooleanField(
+                db_column="ask_to_leave_area_when_done_using",
+                default=False,
+                help_text="Check this box to ask the user if they want to log out of the area when they are done using the tool.",
+            ),
+        ),
+    ]
diff --git a/NEMO/migrations/0067_project_allow_staff_charges.py b/NEMO/migrations/0067_project_allow_staff_charges.py
new file mode 100644
index 000000000..33a5695e3
--- /dev/null
+++ b/NEMO/migrations/0067_project_allow_staff_charges.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.2.23 on 2024-03-13 21:04
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("NEMO", "0066_tool_ask_to_leave_area_when_done_using"),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name="project",
+            name="allow_staff_charges",
+            field=models.BooleanField(
+                default=True, help_text="Uncheck this box if staff charges are forbidden for this project"
+            ),
+        ),
+    ]
diff --git a/NEMO/migrations/0068_door_multiple_areas.py b/NEMO/migrations/0068_door_multiple_areas.py
new file mode 100644
index 000000000..57181d12b
--- /dev/null
+++ b/NEMO/migrations/0068_door_multiple_areas.py
@@ -0,0 +1,64 @@
+# Generated by Django 3.2.23 on 2024-03-18 23:10
+
+import django.db.models
+import mptt.fields
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("NEMO", "0067_project_allow_staff_charges"),
+    ]
+
+    def migrate_door_areas_forward(apps, schema_editor):
+        Door = apps.get_model("NEMO", "Door")
+        for door in Door.objects.all():
+            door.welcome_message = door.area.welcome_message
+            door.save()
+            door.areas.set([door.area])
+
+    def migrate_door_areas_reverse(apps, schema_editor):
+        Door = apps.get_model("NEMO", "Door")
+        for door in Door.objects.all():
+            door.area = door.areas.first()
+            door.save()
+            door.area.welcome_message = door.welcome_message
+            door.area.save()
+
+    operations = [
+        migrations.RemoveField(
+            model_name="door",
+            name="adjacent_area",
+        ),
+        migrations.AddField(
+            model_name="door",
+            name="areas",
+            field=mptt.fields.TreeManyToManyField(related_name="doors", to="NEMO.Area"),
+        ),
+        migrations.AddField(
+            model_name="door",
+            name="welcome_message",
+            field=models.TextField(
+                blank=True,
+                help_text="The welcome message will be displayed on the tablet login page. You can use HTML and JavaScript.",
+                null=True,
+            ),
+        ),
+        migrations.RunPython(migrate_door_areas_forward, migrate_door_areas_reverse),
+        migrations.AlterField(
+            model_name="door",
+            name="area",
+            field=mptt.fields.TreeForeignKey(
+                to="NEMO.Area", related_name="doors", on_delete=django.db.models.PROTECT, default=0
+            ),
+        ),
+        migrations.RemoveField(
+            model_name="door",
+            name="area",
+        ),
+        migrations.RemoveField(
+            model_name="area",
+            name="welcome_message",
+        ),
+    ]
diff --git a/NEMO/models.py b/NEMO/models.py
index 296b58023..594938e79 100644
--- a/NEMO/models.py
+++ b/NEMO/models.py
@@ -29,7 +29,7 @@
 from django.utils import timezone
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
-from mptt.fields import TreeForeignKey
+from mptt.fields import TreeForeignKey, TreeManyToManyField
 from mptt.models import MPTTModel
 
 from NEMO import fields
@@ -54,7 +54,7 @@
     supported_embedded_extensions,
 )
 from NEMO.validators import color_hex_list_validator, color_hex_validator
-from NEMO.views.constants import ADDITIONAL_INFORMATION_MAXIMUM_LENGTH, CHAR_FIELD_MAXIMUM_LENGTH
+from NEMO.views.constants import ADDITIONAL_INFORMATION_MAXIMUM_LENGTH, CHAR_FIELD_MAXIMUM_LENGTH, MEDIA_PROTECTED
 from NEMO.widgets.configuration_editor import ConfigurationEditor
 
 models_logger = getLogger(__name__)
@@ -199,7 +199,7 @@ class Meta:
 def auto_delete_file_on_document_delete(sender, instance: BaseDocumentModel, **kwargs):
     if not issubclass(sender, BaseDocumentModel):
         return
-    """	Deletes file from filesystem when corresponding `SafetyItemDocuments` object is deleted.	"""
+    """	Deletes file from filesystem when corresponding object is deleted.	"""
     if instance.document:
         if os.path.isfile(instance.document.path):
             os.remove(instance.document.path)
@@ -209,7 +209,7 @@ def auto_delete_file_on_document_delete(sender, instance: BaseDocumentModel, **k
 def auto_delete_file_on_document_change(sender, instance: BaseDocumentModel, **kwargs):
     if not issubclass(sender, BaseDocumentModel):
         return
-    """	Deletes old file from filesystem when corresponding `SafetyItemDocuments` object is updated with new file. """
+    """	Deletes old file from filesystem when corresponding object is updated with new file. """
     if not instance.pk:
         return False
 
@@ -382,6 +382,14 @@ class UserPreferences(BaseModel):
         max_length=200,
         help_text="The number of days to send a reminder before a recurring charge is due. A comma-separated list can be used for multiple reminders.",
     )
+    create_reservation_confirmation_override = models.BooleanField(
+        default=False,
+        help_text="Override default create reservation confirmation setting",
+    )
+    change_reservation_confirmation_override = models.BooleanField(
+        default=False,
+        help_text="Override default move/resize reservation confirmation setting",
+    )
     email_send_recurring_charges_reminder_emails = models.PositiveIntegerField(
         default=EmailNotificationType.BOTH_EMAILS,
         choices=EmailNotificationType.Choices,
@@ -875,10 +883,11 @@ def get_emails(self, email_notification=EmailNotificationType.MAIN_EMAIL) -> Lis
         emails = []
         if email_notification in [EmailNotificationType.BOTH_EMAILS, EmailNotificationType.MAIN_EMAIL]:
             emails.append(self.email)
-        if self.get_preferences().email_alternate and email_notification in [
-            EmailNotificationType.BOTH_EMAILS,
-            EmailNotificationType.ALTERNATE_EMAIL,
-        ]:
+        if (
+            self.get_preferences().email_alternate
+            and email_notification in [EmailNotificationType.BOTH_EMAILS, EmailNotificationType.ALTERNATE_EMAIL]
+            and self.preferences.email_alternate not in emails
+        ):
             emails.append(self.preferences.email_alternate)
         return emails
 
@@ -1063,6 +1072,7 @@ class Meta:
             ("trigger_timed_services", "Can trigger timed services"),
             ("use_billing_api", "Can use billing API"),
             ("kiosk", "Kiosk services"),
+            ("can_impersonate_users", "Can impersonate other users"),
         )
 
     def __str__(self):
@@ -1179,6 +1189,11 @@ class Tool(SerializationByNameModel):
         help_text="Indicates that this tool is physically located in a billable area and requires an active area access record in order to be operated.",
         on_delete=models.PROTECT,
     )
+    _ask_to_leave_area_when_done_using = models.BooleanField(
+        default=False,
+        db_column="ask_to_leave_area_when_done_using",
+        help_text="Check this box to ask the user if they want to log out of the area when they are done using the tool.",
+    )
     _grant_physical_access_level_upon_qualification = models.ForeignKey(
         "PhysicalAccessLevel",
         db_column="grant_physical_access_level_upon_qualification_id",
@@ -1242,6 +1257,12 @@ class Tool(SerializationByNameModel):
         default=False,
         help_text='Upon logging off users may enter a delay before another user may use the tool. Some tools require "spin-down" or cleaning time after use.',
     )
+    _pre_usage_questions = models.TextField(
+        db_column="pre_usage_questions",
+        null=True,
+        blank=True,
+        help_text="Before using a tool, questions can be asked. This field will only accept JSON format",
+    )
     _post_usage_questions = models.TextField(
         db_column="post_usage_questions",
         null=True,
@@ -1415,6 +1436,19 @@ def requires_area_access(self, value):
         self.raise_setter_error_if_child_tool("requires_area_access")
         self._requires_area_access = value
 
+    @property
+    def ask_to_leave_area_when_done_using(self):
+        return (
+            self.parent_tool.ask_to_leave_area_when_done_using
+            if self.is_child_tool()
+            else self._ask_to_leave_area_when_done_using
+        )
+
+    @ask_to_leave_area_when_done_using.setter
+    def ask_to_leave_area_when_done_using(self, value):
+        self.raise_setter_error_if_child_tool("ask_to_leave_area_when_done_using")
+        self.ask_to_leave_area_when_done_using = value
+
     @property
     def grant_physical_access_level_upon_qualification(self):
         return (
@@ -1529,6 +1563,15 @@ def allow_delayed_logoff(self, value):
         self.raise_setter_error_if_child_tool("allow_delayed_logoff")
         self._allow_delayed_logoff = value
 
+    @property
+    def pre_usage_questions(self):
+        return self.parent_tool.pre_usage_questions if self.is_child_tool() else self._pre_usage_questions
+
+    @pre_usage_questions.setter
+    def pre_usage_questions(self, value):
+        self.raise_setter_error_if_child_tool("pre_usage_questions")
+        self._pre_usage_questions = value
+
     @property
     def post_usage_questions(self):
         return self.parent_tool.post_usage_questions if self.is_child_tool() else self._post_usage_questions
@@ -1851,6 +1894,13 @@ def clean(self):
             if not self._primary_owner_id:
                 errors["_primary_owner"] = "This field is required."
 
+            # Validate _pre_usage_questions JSON format
+            if self._pre_usage_questions:
+                dynamic_form_errors = validate_dynamic_form_model(
+                    self._pre_usage_questions, "tool_usage_group_question", self.id
+                )
+                if dynamic_form_errors:
+                    errors["_pre_usage_questions"] = dynamic_form_errors
             # Validate _post_usage_questions JSON format
             if self._post_usage_questions:
                 dynamic_form_errors = validate_dynamic_form_model(
@@ -2163,11 +2213,6 @@ class Area(MPTTModel):
     )
 
     # Area access
-    welcome_message = models.TextField(
-        null=True,
-        blank=True,
-        help_text="The welcome message will be displayed on the tablet login page. You can use HTML and JavaScript.",
-    )
     requires_reservation = models.BooleanField(
         default=False, help_text="Check this box to require a reservation for this area before a user can login."
     )
@@ -2461,6 +2506,9 @@ class Project(SerializationByNameModel):
     allow_consumable_withdrawals = models.BooleanField(
         default=True, help_text="Uncheck this box if consumable withdrawals are forbidden under this project"
     )
+    allow_staff_charges = models.BooleanField(
+        default=True, help_text="Uncheck this box if staff charges are forbidden for this project"
+    )
 
     class Meta:
         ordering = ["name"]
@@ -2703,6 +2751,7 @@ class UsageEvent(BaseModel, CalendarDisplayMixin, BillableItemMixin):
         User, null=True, blank=True, related_name="usage_event_validated_set", on_delete=models.CASCADE
     )
     remote_work = models.BooleanField(default=False)
+    pre_run_data = models.TextField(null=True, blank=True)
     run_data = models.TextField(null=True, blank=True)
 
     def duration(self):
@@ -2728,6 +2777,11 @@ class Consumable(BaseModel):
         default=True,
         help_text="Allow users to self checkout this consumable, only applicable when self checkout customization is enabled.",
     )
+    self_checkout_only_users = models.ManyToManyField(
+        User,
+        blank=True,
+        help_text="Selected users will be the only ones allowed to self checkout this consumable. Leave blank for all.",
+    )
     notes = models.TextField(null=True, blank=True, help_text="Notes about the consumable.")
     reminder_threshold = models.IntegerField(
         null=True,
@@ -3078,7 +3132,6 @@ def lock(self) -> bool:
         return interlocks.get(self.card.category).lock(self)
 
     class Meta:
-        unique_together = ("card", "channel")
         ordering = ["card__server", "card__number", "channel"]
 
     def __str__(self):
@@ -3486,7 +3539,12 @@ def calculate_duration(start, end, unfinished_reason):
 
 class Door(BaseModel):
     name = models.CharField(max_length=100)
-    area = TreeForeignKey(Area, related_name="doors", on_delete=models.PROTECT)
+    welcome_message = models.TextField(
+        null=True,
+        blank=True,
+        help_text="The welcome message will be displayed on the tablet login page. You can use HTML and JavaScript.",
+    )
+    areas = TreeManyToManyField(Area, related_name="doors", blank=False)
     interlock = models.OneToOneField(Interlock, null=True, blank=True, on_delete=models.PROTECT)
 
     def __str__(self):
@@ -4063,6 +4121,18 @@ def reviewers(self) -> QuerySetType[User]:
             return area_reviewers or facility_managers
         return facility_managers
 
+    def delete(self, using=None, keep_parents=False):
+        adjustment_id = self.id
+        super().delete(using, keep_parents)
+        # If adjustment requests is being deleted, remove associated notifications
+        Notification.objects.filter(
+            object_id=adjustment_id,
+            notification_type__in=[
+                Notification.Types.ADJUSTMENT_REQUEST,
+                Notification.Types.ADJUSTMENT_REQUEST_REPLY,
+            ],
+        ).delete()
+
     def clean(self):
         if not self.description:
             raise ValidationError({"description": _("This field is required.")})
@@ -4283,6 +4353,72 @@ def auto_delete_file_on_chemical_change(sender, instance: Chemical, **kwargs):
                 os.remove(old_file.path)
 
 
+class StaffKnowledgeBaseCategory(BaseCategory):
+    class Meta(BaseCategory.Meta):
+        verbose_name_plural = "Staff knowledge base categories"
+
+
+class StaffKnowledgeBaseItem(BaseModel):
+    name = models.CharField(max_length=200, help_text="The item name.")
+    description = models.TextField(null=True, blank=True, help_text="The description for this item. HTML can be used.")
+    category = models.ForeignKey(
+        StaffKnowledgeBaseCategory,
+        null=True,
+        blank=True,
+        help_text="The category for this item.",
+        on_delete=models.SET_NULL,
+    )
+
+    def __str__(self):
+        return self.name
+
+
+class StaffKnowledgeBaseItemDocuments(BaseDocumentModel):
+    item = models.ForeignKey(StaffKnowledgeBaseItem, on_delete=models.CASCADE)
+
+    def get_filename_upload(self, filename):
+        from django.template.defaultfilters import slugify
+
+        item_name = slugify(self.item.name)
+        return f"{MEDIA_PROTECTED}/knowledge_base/{item_name}/{filename}"
+
+    class Meta(BaseDocumentModel.Meta):
+        verbose_name_plural = "Staff knowledge base item documents"
+
+
+class UserKnowledgeBaseCategory(BaseCategory):
+    class Meta(BaseCategory.Meta):
+        verbose_name_plural = "User knowledge base categories"
+
+
+class UserKnowledgeBaseItem(BaseModel):
+    name = models.CharField(max_length=200, help_text="The item name.")
+    description = models.TextField(null=True, blank=True, help_text="The description for this item. HTML can be used.")
+    category = models.ForeignKey(
+        UserKnowledgeBaseCategory,
+        null=True,
+        blank=True,
+        help_text="The category for this item.",
+        on_delete=models.SET_NULL,
+    )
+
+    def __str__(self):
+        return self.name
+
+
+class UserKnowledgeBaseItemDocuments(BaseDocumentModel):
+    item = models.ForeignKey(UserKnowledgeBaseItem, on_delete=models.CASCADE)
+
+    def get_filename_upload(self, filename):
+        from django.template.defaultfilters import slugify
+
+        item_name = slugify(self.item.name)
+        return f"knowledge_base/{item_name}/{filename}"
+
+    class Meta(BaseDocumentModel.Meta):
+        verbose_name_plural = "User knowledge base item documents"
+
+
 class EmailLog(BaseModel):
     category = models.IntegerField(choices=EmailCategory.Choices, default=EmailCategory.GENERAL)
     when = models.DateTimeField(null=False, auto_now_add=True)
diff --git a/NEMO/policy.py b/NEMO/policy.py
index 5b03170bd..b1bf18d7e 100644
--- a/NEMO/policy.py
+++ b/NEMO/policy.py
@@ -56,7 +56,7 @@
 class NEMOPolicy:
     def check_to_enable_tool(
         self, tool: Tool, operator: User, user: User, project: Project, staff_charge: bool, remote_work=False
-    ):
+    ) -> HttpResponse:
         """
         Check that the user is allowed to enable the tool. Enable the tool if the policy checks pass.
         """
@@ -893,7 +893,11 @@ def check_billing_to_project(
                 if isinstance(item, Consumable) and isinstance(charge, ConsumableWithdraw):
                     if not charge.tool_usage and not project.allow_consumable_withdrawals:
                         msg = f"Consumable withdrawals are not allowed for project {project.name}"
-                        raise ItemNotAllowedForProjectException(project, user, "Staff Charges", msg)
+                        raise ItemNotAllowedForProjectException(project, user, "Consumable withdrawals", msg)
+                # Check if staff charges are allowed
+                if isinstance(item, StaffCharge) and not project.allow_staff_charges:
+                    msg = f"Staff charges are not allowed for project {project.name}"
+                    raise ItemNotAllowedForProjectException(project, user, "Staff Charges", msg)
 
     def check_maximum_users_in_overlapping_reservations(self, reservations: List[Reservation]) -> (int, datetime):
         """
diff --git a/NEMO/serializers.py b/NEMO/serializers.py
index 0a826b48b..e497242be 100644
--- a/NEMO/serializers.py
+++ b/NEMO/serializers.py
@@ -5,7 +5,15 @@
 from django.core import validators
 from rest_flex_fields.serializers import FlexFieldsSerializerMixin
 from rest_framework import serializers
-from rest_framework.fields import BooleanField, CharField, ChoiceField, DateTimeField, DecimalField, IntegerField
+from rest_framework.fields import (
+    BooleanField,
+    CharField,
+    ChoiceField,
+    DateTimeField,
+    DecimalField,
+    IntegerField,
+    JSONField,
+)
 from rest_framework.relations import PrimaryKeyRelatedField
 from rest_framework.utils import model_meta
 
@@ -121,8 +129,12 @@ class Meta:
 
 
 class ProjectSerializer(FlexFieldsSerializerMixin, ModelSerializer):
-    principal_investigators = PrimaryKeyRelatedField(source="manager_set", many=True, queryset=User.objects.all())
-    users = PrimaryKeyRelatedField(source="user_set", many=True, queryset=User.objects.all())
+    principal_investigators = PrimaryKeyRelatedField(
+        source="manager_set", many=True, queryset=User.objects.all(), allow_null=True, required=False
+    )
+    users = PrimaryKeyRelatedField(
+        source="user_set", many=True, queryset=User.objects.all(), allow_null=True, required=False
+    )
 
     class Meta:
         model = Project
@@ -202,7 +214,7 @@ class Meta:
 
 
 class ReservationSerializer(FlexFieldsSerializerMixin, ModelSerializer):
-    question_data = serializers.JSONField(source="question_data_json", allow_null=True, required=False)
+    question_data = JSONField(source="question_data_json", allow_null=True, required=False)
     configuration_options = ConfigurationOptionSerializer(source="configurationoption_set", many=True, read_only=True)
 
     class Meta:
@@ -369,7 +381,9 @@ class Meta:
 
 
 class PermissionSerializer(FlexFieldsSerializerMixin, ModelSerializer):
-    users = PrimaryKeyRelatedField(source="user_set", many=True, queryset=User.objects.all())
+    users = PrimaryKeyRelatedField(
+        source="user_set", many=True, queryset=User.objects.all(), allow_null=True, required=False
+    )
 
     class Meta:
         model = Permission
@@ -381,7 +395,9 @@ class Meta:
 
 
 class GroupSerializer(FlexFieldsSerializerMixin, ModelSerializer):
-    users = PrimaryKeyRelatedField(source="user_set", many=True, queryset=User.objects.all())
+    users = PrimaryKeyRelatedField(
+        source="user_set", many=True, queryset=User.objects.all(), allow_null=True, required=False
+    )
 
     class Meta:
         model = Group
diff --git a/NEMO/static/admin/dynamic_form_preview/dynamic_form_preview.css b/NEMO/static/admin/dynamic_form_preview/dynamic_form_preview.css
index 11326e9e8..da3a5bc54 100644
--- a/NEMO/static/admin/dynamic_form_preview/dynamic_form_preview.css
+++ b/NEMO/static/admin/dynamic_form_preview/dynamic_form_preview.css
@@ -166,7 +166,7 @@ div.dynamic_form_preview:empty
     padding: 0;
 }
 
-#form_validity
+.form_validity
 {
     margin-top: 15px;
     padding: 5px;
@@ -175,7 +175,7 @@ div.dynamic_form_preview:empty
     background-color: #5cb85c;
 }
 
-#form_validity.invalid
+.form_validity.invalid
 {
     color: whitesmoke;
     background-color: #ac2925;
diff --git a/NEMO/static/admin/dynamic_form_preview/dynamic_form_preview.js b/NEMO/static/admin/dynamic_form_preview/dynamic_form_preview.js
index 7c0504859..9053513a8 100644
--- a/NEMO/static/admin/dynamic_form_preview/dynamic_form_preview.js
+++ b/NEMO/static/admin/dynamic_form_preview/dynamic_form_preview.js
@@ -2,21 +2,24 @@ window.addEventListener("load", function()
 {
     (function ($)
     {
-        /* set a separate form for testing post_usage data */
-        $('form').last().after('<form id="dynamic_form_preview_form"></form>');
-
-        function update_input_form()
+        /* add the form id to all the inputs of the preview */
+        function update_input_form(preview_id)
         {
-            $('.dynamic_form_preview input, .dynamic_form_preview textarea, .dynamic_form_preview select').each(function (index, element) {
-                $(element).attr('form', 'dynamic_form_preview_form')
+            let form_id = $("#" + preview_id).data("form-id");
+            $('#' + preview_id + ' input, #' + preview_id + ' textarea, #' + preview_id + ' select').each(function (index, element)
+            {
+                $(element).attr("form", form_id);
             })
         }
-        function update_validation_button()
+
+        /* update the button displaying whether the form is valid or not */
+        function update_validation_button(preview_id)
         {
-            let valid_message = $("#form_validity")
+            let form_id = $("#" + preview_id).data("form-id");
+            let valid_message = $("#" + preview_id + " .form_validity");
             if (valid_message)
             {
-                if(document.querySelector('#dynamic_form_preview_form').checkValidity())
+                if(document.querySelector('#' + form_id).checkValidity())
                 {
                     valid_message.removeClass("invalid");
                     valid_message.text("The form is valid!");
@@ -28,13 +31,35 @@ window.addEventListener("load", function()
                 }
             }
         }
-        $('.dynamic_form_preview').on('change keyup', "input[form='dynamic_form_preview_form'], textarea[form='dynamic_form_preview_form'], select[form='dynamic_form_preview_form']", update_validation_button);
-        $('body').on('question-group-changed', function()
+
+        /* apply for each preview */
+        $(".dynamic_form_preview").each(function (index, element)
+        {
+            let form_id = "dynamic_form_preview_form_" + index;
+            let preview_id = "dynamic_form_preview_" + index;
+            /* set the new id, so we can make sure to update the right preview */
+            $(element).attr("id", preview_id);
+            /* also set the form id, so we can easily retrieve it later */
+            $(element).data("form-id", form_id);
+
+            /* create a separate form for testing dynamic form data */
+            $("form").last().after('<form id="' + form_id + '"></form>');
+
+            $("#" + preview_id).on("change keyup", "input[form='" + form_id + "'], textarea[form='" + form_id + "'], select[form='" + form_id + "']", function()
+            {
+                update_validation_button(preview_id);
+            });
+
+            update_input_form(preview_id);
+            update_validation_button(preview_id);
+        });
+
+        /* bind event to the correct preview */
+        $("body").on("question-group-changed", function(event, group_name)
         {
-            update_input_form();
-            update_validation_button();
+            let preview_id = $("#"+ group_name+"_container").closest(".dynamic_form_preview").attr("id");
+            update_input_form(preview_id);
+            update_validation_button(preview_id);
         });
-        update_input_form();
-        update_validation_button();
     })(django.jQuery);
 });
\ No newline at end of file
diff --git a/NEMO/static/badge_reader.js b/NEMO/static/badge_reader.js
index d4a30929d..850227e68 100644
--- a/NEMO/static/badge_reader.js
+++ b/NEMO/static/badge_reader.js
@@ -1,7 +1,8 @@
-BadgeReader = function (on_success, send_key, record_key)
+BadgeReader = function (on_success, send_key, record_key, show_number)
 {
 
     $(window).keypress(on_keypress).keydown(on_keydown);
+    let display_number = show_number || false;
     let record_badge_number = false;
     let badge_number = "";
 
@@ -13,7 +14,7 @@ BadgeReader = function (on_success, send_key, record_key)
         if (event.key !== send_key && event.key !== record_key && (record_key && record_badge_number || !record_key))
         {
             badge_number += String.fromCharCode(event.which);
-            $("#badge_number").html(badge_number);
+            if (display_number) $("#badge_number").html(badge_number);
         }
     }
 
@@ -24,7 +25,7 @@ BadgeReader = function (on_success, send_key, record_key)
         {
             // Sending badge number
             on_success(badge_number);
-            $("#badge_number").html(badge_number + ", sent");
+            if (display_number) $("#badge_number").html(badge_number + ", sent");
             badge_number = "";
             record_badge_number = false;
         }
diff --git a/NEMO/static/nemo.css b/NEMO/static/nemo.css
index b10a6973f..5c5586589 100644
--- a/NEMO/static/nemo.css
+++ b/NEMO/static/nemo.css
@@ -144,6 +144,12 @@ body.impersonating .application-sidebar, body.impersonating .application-content
 	opacity: .4;
 }
 
+#calendar-selected-tool
+{
+	padding-bottom: 2px;
+	font-size: 13px;
+}
+
 .status-icon
 {
 	font-size: 1.5em;
@@ -412,91 +418,98 @@ Twitter Typeahead autocomplete dropdown menu isn't chopped off. */
 	margin-right: -170px;
 }
 
-/* Navbar customization, mainly size when switching */
-@media (max-width: 991px)
+/* Navbar customization, to force collapse and hamburger menu */
+@media (min-width: 768px)
 {
-	.navbar-header
-	{
-		float: none;
-	}
-	.navbar-left,.navbar-right
-	{
-		float: none !important;
-	}
 	.navbar-toggle
 	{
-		display: block;
-	}
-	.navbar-collapse
-	{
-		border-top: 1px solid transparent;
-		box-shadow: inset 0 1px 0 rgba(255,255,255,0.1);
-	}
-	.navbar-fixed-top
-	{
-		top: 0;
-		border-width: 0 0 1px;
-	}
-	.navbar-collapse.collapse
-	{
-		display: none!important;
-	}
-	.navbar-nav
-	{
-		float: none!important;
-		margin: 7.5px -15px;
-	}
-	.navbar-nav>li
-	{
-		float: none;
-	}
-	.navbar-nav>li>a
-	{
-		padding-top: 10px;
-		padding-bottom: 10px;
-	}
-	.collapse.in
-	{
-		display:block !important;
-	}
-	/* sub-menu */
-	.navbar-nav .open .dropdown-menu
-	{
-		position: static;
-		float: none;
-		width: auto;
-		margin-top: 0;
-		background-color: transparent;
-		border: 0;
-		-webkit-box-shadow: none;
-		box-shadow: none;
-	}
-	.navbar-nav .open .dropdown-menu > li > a,
-	.navbar-nav .open .dropdown-menu .dropdown-header
-	{
-		padding: 5px 15px 5px 25px;
-	}
-	.navbar-nav .open .dropdown-menu > li > a
-	{
-		line-height: 20px;
-	}
-	.navbar-nav .open .dropdown-menu > li > a:hover,
-	.navbar-nav .open .dropdown-menu > li > a:focus
-	{
-		background-image: none;
-	}
-	.navbar-default .navbar-nav .open .dropdown-menu > li > a
-	{
-		color: #777;
+		margin-right: 0;
 	}
 }
 
-@media (min-width: 768px)
+.force-navbar-collapse .navbar-header
 {
-	.navbar-toggle
-	{
-		margin-right: 0;
-	}
+	float: none;
+}
+.force-navbar-collapse .navbar-left,
+.force-navbar-collapse .navbar-right
+{
+	float: none !important;
+}
+.force-navbar-collapse .navbar-toggle
+{
+	display: block;
+}
+.force-navbar-collapse .navbar-collapse
+{
+	border-top: 1px solid transparent;
+	box-shadow: inset 0 1px 0 rgba(255,255,255,0.1);
+}
+.force-navbar-collapse .navbar-fixed-top
+{
+	top: 0;
+	border-width: 0 0 1px;
+}
+.force-navbar-collapse .navbar-collapse.collapse
+{
+	display: none!important;
+}
+.force-navbar-collapse .navbar-nav
+{
+	float: none!important;
+	margin: 7.5px -15px;
+}
+.force-navbar-collapse .navbar-nav>li
+{
+	float: none;
+}
+.force-navbar-collapse .navbar-nav>li>a
+{
+	padding-top: 10px;
+	padding-bottom: 10px;
+}
+.force-navbar-collapse .collapse.in
+{
+	display:block !important;
+	border-color: #e7e7e7;
+}
+/* sub-menu */
+.force-navbar-collapse .navbar-nav .open .dropdown-menu
+{
+	position: static;
+	float: none;
+	width: auto;
+	margin-top: 0;
+	background-color: transparent;
+	border: 0;
+	-webkit-box-shadow: none;
+	box-shadow: none;
+}
+.force-navbar-collapse .navbar-nav .open .dropdown-menu > li > a,
+.force-navbar-collapse .navbar-nav .open .dropdown-menu .dropdown-header
+{
+	padding: 5px 15px 5px 25px;
+}
+.force-navbar-collapse .navbar-nav .open .dropdown-menu > li > a
+{
+	line-height: 20px;
+}
+.force-navbar-collapse .navbar-nav .open .dropdown-menu > li > a:hover,
+.force-navbar-collapse .navbar-nav .open .dropdown-menu > li > a:focus
+{
+	background-image: none;
+}
+.force-navbar-collapse .navbar-default .navbar-nav .open .dropdown-menu > li > a
+{
+	color: #777;
+}
+.force-navbar-collapse .nav.navbar-nav.hidden-sm
+{
+	display: none;
+}
+.force-navbar-collapse .nav.navbar-nav .visible-sm
+{
+	display: block !important;
 }
 
 .navbar-preferences > a
@@ -1163,19 +1176,19 @@ li.area-tree-node a, li.area-tree-node a:hover, li.area-tree-node a:focus
 	text-decoration: line-through;
 }
 
-#safety-tabs
+#safety-tabs, #knowledge-base-tabs
 {
 	margin-top: 0;
 	margin-bottom: 15px;
 }
 
-.safety-title
+.safety-title, .knowledge-base-title
 {
 	margin-top: 0;
 	margin-right: 20px;
 }
 
-.safety-tabs-nav
+.safety-tabs-nav, .knowledge-base-tabs-nav
 {
 	display: inline-block;
 	float: right;
diff --git a/NEMO/static/nemo.js b/NEMO/static/nemo.js
index 620cad16b..337927535 100644
--- a/NEMO/static/nemo.js
+++ b/NEMO/static/nemo.js
@@ -88,10 +88,9 @@ function on_item_search_selection(jquery_event, search_selection, dataset_name)
 // This function toggles all parent categories of a tool/area and selects the tool.
 function expand_to_item(id, type)
 {
-	$("#sidebar a").removeClass('selected');
-	$("#"+type+"-"+id).addClass('selected').click().parents('ul.tree').show();
-	$("#sidebar").attr("aria-expanded", true);
-	save_sidebar_state();
+	let item = $("#"+type+"-"+id);
+	item.click().parents('ul.tree').show();
+	set_selected_item(item, true);
 }
 
 function toggle_categories()
@@ -278,11 +277,16 @@ function get_selected_item()
 }
 
 // This function visually highlights a clicked link with a gray background.
-function set_selected_item(element)
+function set_selected_item(element, save_state)
 {
+	save_state = save_state || false;
 	$("#sidebar a").removeClass('selected');
 	$(element).addClass('selected');
-	save_sidebar_state();
+	if (save_state)
+	{
+		save_sidebar_state();
+	}
+	$("#calendar-selected-tool").html($(element).data("item-name") || $(element).text());
 }
 
 function set_selected_item_by_id(item_id, item_type)
@@ -290,8 +294,7 @@ function set_selected_item_by_id(item_id, item_type)
 	let item = $("#" + item_type + "-" + item_id);
 	if(item.length === 1)
 	{
-		$("#sidebar a").removeClass('selected');
-		item.addClass('selected');
+		set_selected_item(item);
 	}
 }
 
@@ -300,8 +303,7 @@ function set_selected_item_by_class(item_class)
 	let item = $("."+item_class);
 	if(item.length === 1)
 	{
-		$("#sidebar a").removeClass('selected');
-		item.addClass('selected');
+		set_selected_item(item);
 	}
 }
 
@@ -849,3 +851,17 @@ function set_multiple_colors(element, colors) {
 		}
 	}
 }
+
+function collapse_navbar(max_width, max_height)
+{
+	let width = max_width || 768; /* Bootstrap  */
+	let height = max_height || 52;
+	if (window.matchMedia('(max-width: ' + width + 'px)').matches)
+	{
+		$("body").removeClass("force-navbar-collapse");
+	}
+	else if ($("#site-navigation").height() > height)
+	{
+		$("body").addClass("force-navbar-collapse");
+	}
+}
diff --git a/NEMO/templates/base.html b/NEMO/templates/base.html
index f5163cdf4..a479c7fb0 100644
--- a/NEMO/templates/base.html
+++ b/NEMO/templates/base.html
@@ -137,10 +137,12 @@
             }
 		});
 	}
-	{# Add an event listener on load for scripts. UseCapture is False so this is executed a bit later #}
+	$(window).on("resize", function() { collapse_navbar(); });
+    {# Add an event listener on load for scripts. UseCapture is False so this is executed a bit later #}
 	window.addEventListener("load", function ()
     {
         show_alerts();
+        collapse_navbar();
 
 	    {# Add a global AJAX completion callback if sessions are able to timeout. #}
         {# When an AJAX request receives an HTTP 403 (unauthorized) reply the user is #}
diff --git a/NEMO/templates/base/navbar_base.html b/NEMO/templates/base/navbar_base.html
index d1ba18b07..c19e9c28b 100644
--- a/NEMO/templates/base/navbar_base.html
+++ b/NEMO/templates/base/navbar_base.html
@@ -59,7 +59,6 @@
                                     {% endif %}
                                     {% navigation_url 'customization' 'Customization' user.is_superuser %}
                                     {% navigation_url 'email_broadcast' 'Email' %}
-                                    {% navigation_url 'impersonate' 'Impersonate' user.is_superuser %}
                                     {% if tools_exist %}{% navigation_url 'maintenance' 'Maintenance' user.is_staff  user.is_superuser %}{% endif %}
                                     {% navigation_url 'project_billing' 'Project billing' user.is_accounting_officer user.is_user_office user.is_superuser %}
                                     {% if tools_exist %}{% navigation_url 'qualifications' 'Qualifications' user.is_staff user.is_superuser %}{% endif %}
@@ -67,6 +66,11 @@
                                     {% navigation_url 'staff_charges' 'Remote work' user.is_staff user.is_superuser %}
                                     {% navigation_url 'resources' 'Resources' user.is_staff user.is_superuser %}
                                     {% navigation_url 'sensors' 'Sensor data' %}
+                                    {# we can't use args with navigation url so let's check that the regular one exists and then use staff in <li> #}
+                                    {% navigation_url 'knowledge_base' '' as knowledge_base_url %}
+                                    {% if knowledge_base_url %}
+                                        <li><a href="{% url 'knowledge_base' 'staff' %}">Staff knowledge base</a></li>
+                                    {% endif %}
                                     {% navigation_url 'consumables' 'Supplies' user.is_staff user.is_user_office user.is_superuser %}
                                     {% if tools_exist %}{% navigation_url 'training' 'Training' user.is_staff user.is_superuser %}{% endif %}
                                     {% navigation_url 'users' 'Users' %}
@@ -77,10 +81,23 @@
                                     {% navigation_url 'projects' 'Projects' user.is_project_pi %}
                                     {% if tools_exist %}{% navigation_url 'training' 'Training' user.is_tool_superuser %}{% endif %}
                                 {% endif %}
+                                {% if perms.NEMO.can_impersonate_users %}
+                                    {% navigation_url 'impersonate' 'Impersonate' %}
+                                {% endif %}
                             {% endblock %}
 						</ul>
 					</li>
 				{% endif %}
+                {% if user.is_staff and user.charging_staff_time %}
+					<li class="visible-xs visible-sm">
+                        <a href="{% url 'staff_charges' %}" style="color:red; text-decoration: none">
+                            <span id="staff_charge_link" data-container="body" data-toggle="tooltip" data-placement="right" title="{% with user.get_staff_charge as staff_charge %}You are charging staff time to {{ staff_charge.customer }} for the project named {{ staff_charge.project }} since {{ staff_charge.start }}.{% endwith %}">Charging staff time</span>
+                        </a>
+						<script>
+							$("#staff_charge_link").tooltip();
+						</script>
+					</li>
+				{% endif %}
 				<li class="visible-xs visible-sm"><a href="{%  url 'user_preferences' %}">Preferences</a></li>
 				{% if logout_allowed %}
 					<li class="visible-xs visible-sm"><a href="{% url 'logout' %}">Logout</a></li>
diff --git a/NEMO/templates/calendar/calendar.html b/NEMO/templates/calendar/calendar.html
index de680db72..e754ce1d4 100644
--- a/NEMO/templates/calendar/calendar.html
+++ b/NEMO/templates/calendar/calendar.html
@@ -239,9 +239,15 @@
 		element.attr('title', event.tooltip)
 	else if ('title' in event)
 		element.attr('title', event.title)
-    if ("colors" in event) {
+    if ("colors" in event)
+    {
         set_multiple_colors($(element), event["colors"]);
     }
+    {# Reset the background color so the current user's reservations look darker #}
+    if ("own-reservation" in event && event["own-reservation"])
+    {
+        $(element).find(".fc-bg").css("background-color", "inherit");
+    }
 }
 
 function event_after_all_render()
@@ -282,7 +288,8 @@
 	{# If the user is trying to create a scheduled outage, then put that in the event_properties. #}
 	{# Otherwise, create a reservation (which is the normal case). #}
 	let url;
-	if($('.fc-scheduledOutage-button').html() === "I've finished creating scheduled outages")
+    let is_outage = $('.fc-scheduledOutage-button').html() === "I've finished creating scheduled outages";
+	if(is_outage)
 	{
 		url = "{% url 'create_outage' %}";
 	}
@@ -300,7 +307,15 @@
 		"type": "POST",
 		"dataType": "html"
 	};
-	ajax_post(url, event_properties, function(response, status, xml_http_request) { return event_creation_success_callback(response, status, xml_http_request, post_data) }, [unselect_and_refresh, ajax_failure_callback("Oops! Something went wrong")])
+
+    if(confirm_reservation_create(item.data('item-name'), start, end, is_outage))
+    {
+	    ajax_post(url, event_properties, function(response, status, xml_http_request) { return event_creation_success_callback(response, status, xml_http_request, post_data) }, [unselect_and_refresh, ajax_failure_callback("Oops! Something went wrong")])
+    }
+    else
+    {
+        unselect_and_refresh();
+    }
 }
 
 {# This function is called when the user clicks and drags on the calendar to resize a reservation. #}
@@ -325,7 +340,13 @@
 			"type": "POST",
 			"dataType": "html"
 		};
-		ajax_post(url, event_changes, function(response, status, xml_http_request){return event_creation_success_callback(response, status, xml_http_request, post_data)}, [revertFunc, ajax_failure_callback("Reservation resize failed")], refresh_calendar_and_sidebar);
+        if (confirm_reservation_resize(delta)) {
+            ajax_post(url, event_changes, function (response, status, xml_http_request) {
+                return event_creation_success_callback(response, status, xml_http_request, post_data)
+            }, [revertFunc, ajax_failure_callback("Reservation resize failed")], refresh_calendar_and_sidebar);
+        } else {
+            refresh_calendar_and_sidebar();
+        }
 	}
 	else if(type === "Outage")
 	{
@@ -355,7 +376,14 @@
 			"type": "POST",
 			"dataType": "html"
 		};
-		ajax_post(url, event_changes, function(response, status, xml_http_request) { return event_creation_success_callback(response, status, xml_http_request, post_data) }, [revertFunc, ajax_failure_callback("Reservation move failed")], refresh_calendar_and_sidebar)
+        if (confirm_reservation_move(delta)) 
+        {
+            ajax_post(url, event_changes, function (response, status, xml_http_request) { return event_creation_success_callback(response, status, xml_http_request, post_data) }, [revertFunc, ajax_failure_callback("Reservation move failed")], refresh_calendar_and_sidebar)
+        }
+        else
+        {
+            refresh_calendar_and_sidebar();
+        }
 	}
 	else if(type === "Outage")
 	{
@@ -525,6 +553,7 @@
 		}
 	};
 	$("#calendar").fullCalendar(calendar_properties);
+    $(".fc-view-container").prepend('<div class="label label-primary" id="calendar-selected-tool"></div>');
 }
 
 function reserve_for_someone_else()
@@ -740,7 +769,7 @@
 function get_specific_user_activity(jquery_event, search_selection, dataset_name)
 {
 	$("#user_search").typeahead('val', search_selection.id).hide();
-	$("#chosen_user_button").text(search_selection.name).show();
+    set_selected_item($("#chosen_user_button").text(search_selection.name).show());
 	update_event_sources();
 }
 
@@ -803,6 +832,78 @@
     }
 }
 
+function confirm_reservation_create(name, start, end, is_outage)
+{
+    if (is_outage) {return true;}
+    {% if create_reservation_confirmation %}
+        let message = "Are you sure you want to book a reservation for " + name + " " + format_time_window(start, end) + "?";
+        return confirm(message);
+    {% else %}
+        return true;
+    {% endif %}
+}
+
+function confirm_reservation_move(duration)
+{
+    {% if change_reservation_confirmation %}
+        let direction = duration.asMinutes() > 0 ? "forward" : "back";
+        let message = "Are you sure you want to move this reservation " + direction + " by " + format_duration(duration) + "?";
+        return confirm(message);
+    {% else %}
+        return true;
+    {% endif %}
+}
+
+function confirm_reservation_resize(duration)
+{
+    {% if change_reservation_confirmation %}
+        let direction = duration.asMinutes() > 0 ? "increase" : "decrease";
+        let message = "Are you sure you want to " + direction + " the duration of this reservation by " + format_duration(duration) + "?";
+        return confirm(message);
+    {% else %}
+        return true;
+    {% endif %}
+}
+
+function format_time_window(start, end)
+{
+    let start_day = start.format("{{ reservation_confirmation_date_format }}");
+    let end_day = end.format("{{ reservation_confirmation_date_format }}");
+    let start_time = start.format("{{ reservation_confirmation_time_format }}");
+    let end_time = end.format("{{ reservation_confirmation_time_format }}");
+
+    if (start.day() === end.day())
+    {
+        return "on " + start_day + " from " + start_time + " to " + end_time;
+    }
+    return "from " + start_day + " at " + start_time + " to " + end_day + " at " + end_time;
+}
+
+function format_duration(duration)
+{
+    let days = Math.abs(duration.days());
+    let hours = Math.abs(duration.hours());
+    let minutes = Math.abs(duration.minutes());
+
+    let days_formatted = days + " day" + (days > 1 ? "s" : "");
+    let hours_formatted = hours + " hour" + (hours > 1 ? "s" : "");
+    let minutes_formatted = minutes + " minute" + (minutes > 1 ? "s" : "");
+
+    if (days >= 1) {
+        return days_formatted;
+    }
+
+    if (hours >= 1) {
+        let formatted = hours_formatted;
+        if (minutes > 0) {
+            formatted += " and " + minutes_formatted;
+        }
+        return formatted;
+    }
+
+    return minutes_formatted;
+}
+
 function on_load()
 {
     $("html").css("overflow", "hidden");
diff --git a/NEMO/templates/calendar/reservation_event_feed.html b/NEMO/templates/calendar/reservation_event_feed.html
index 314d86b4c..9515bccdb 100644
--- a/NEMO/templates/calendar/reservation_event_feed.html
+++ b/NEMO/templates/calendar/reservation_event_feed.html
@@ -18,9 +18,10 @@
 			    "title": "{{x.reservation_item.name|escapejs}} ({{x.user.username|escapejs}})\n{{ x.title|escapejs }}",
             {% else %}
                 "title": "{{ x.title|default:x.user|escapejs }}{% if display_configuration %}\n{{ x.get_configuration_options_display|escapejs }}{% endif %}",
-                {% if display_configuration %}"colors": ["{{ x.get_configuration_options_colors|join:'","' }}"],{% endif %}
+                {% if display_configuration and x.get_configuration_options_colors %}"colors": ["{{ x.get_configuration_options_colors|join:'","' }}"],{% endif %}
             {% endif %}
-			"color": {% if x.user.id == user.id %}"#3a87ad"{% else %}"#88B7CD"{% endif %},
+            "color": "{% if x.tool %}{% if x.reservation_item.tool_calendar_color|lower == "#33ad33" %}#88B7CD{% else %}{{ x.reservation_item.tool_calendar_color|default:"#88B7CD"|escapejs }}{% endif %}{% else %}{{ x.reservation_item.area_calendar_color|default:"#88B7CD"|escapejs }}{% endif %}",
+			{% if x.user.id == user.id %}"own-reservation": true,{% endif %}
 		{% endif %}
 		{% if x.creator.id == x.user.id %}
 			"tooltip": "{{ x.reservation_item.name }} reservation for {{ x.user }}",
diff --git a/NEMO/templates/customizations/customizations_application.html b/NEMO/templates/customizations/customizations_application.html
index a1b036430..d1f9187e0 100644
--- a/NEMO/templates/customizations/customizations_application.html
+++ b/NEMO/templates/customizations/customizations_application.html
@@ -60,21 +60,31 @@ <h3 class="customization-section-title">Application settings</h3>
                 Check this box to collapse consumable categories by default.
             </div>
         </div>
-		{% if "NEMO.apps.area_access"|app_installed or "NEMO.apps.kiosk"|app_installed and badge_readers %}
-			<div class="form-group">
-				<label class="control-label col-md-3" for="default_badge_reader_id">Default badge reader</label>
-				<div class="col-md-4">
-					<select id="default_badge_reader_id" name="default_badge_reader_id" class="form-control">
-						<option value="" label="Default badge reader"></option>
-						{% for badge_reader in badge_readers %}
-							<option value="{{ badge_reader.id }}" label="{{ badge_reader.name }}" {% if default_badge_reader_id|to_int == badge_reader.id %}selected{% endif %}></option>
-						{% endfor %}
-					</select>
-				</div>
-				<div class="col-md-5 help-block light-grey">
-					The default badge reader to use for the area access and kiosk tablets.
-				</div>
-			</div>
+		{% if "NEMO.apps.area_access"|app_installed or "NEMO.apps.kiosk"|app_installed %}
+            {% if badge_readers %}
+                <div class="form-group">
+                    <label class="control-label col-md-3" for="default_badge_reader_id">Default badge reader</label>
+                    <div class="col-md-4">
+                        <select id="default_badge_reader_id" name="default_badge_reader_id" class="form-control">
+                            <option value="" label="Default badge reader"></option>
+                            {% for badge_reader in badge_readers %}
+                                <option value="{{ badge_reader.id }}" label="{{ badge_reader.name }}" {% if default_badge_reader_id|to_int == badge_reader.id %}selected{% endif %}></option>
+                            {% endfor %}
+                        </select>
+                    </div>
+                    <div class="col-md-5 help-block light-grey">
+                        The default badge reader to use for the area access and kiosk tablets.
+                    </div>
+                </div>
+            {% endif %}
+            <div class="form-group">
+                <label class="control-label col-md-3" for="show_badge_number">Badge number scan</label>
+                <div class="col-md-9">
+                    <div class="checkbox">
+                        <label><input type="checkbox" id="show_badge_number" name="show_badge_number" {% if show_badge_number %}checked{% endif %} value="enabled"> Display badge number when badging in kiosk and area access</label>
+                    </div>
+                </div>
+            </div>
 		{% endif %}
         <div class="customization-separation" style="margin-bottom: 15px"></div>
         <h3 class="customization-section-title">Page title settings</h3>
diff --git a/NEMO/templates/customizations/customizations_calendar.html b/NEMO/templates/customizations/customizations_calendar.html
index dae0f9511..e32615675 100644
--- a/NEMO/templates/customizations/customizations_calendar.html
+++ b/NEMO/templates/customizations/customizations_calendar.html
@@ -122,6 +122,47 @@ <h3 class="customization-section-title">Calendar format</h3>
             </div>
         </div>
         <div class="customization-separation" style="margin-bottom: 15px"></div>
+        <h3 class="customization-section-title">Reservation confirmations</h3>
+        <div class="form-group">
+            <label class="control-label col-md-3">Create</label>
+            <div class="col-md-9">
+                <div class="checkbox">
+                    <label><input type="checkbox" name="create_reservation_confirmation" {% if create_reservation_confirmation %}checked{% endif %} value="enabled">Require users to confirm by clicking 'Ok' when creating a reservation (this setting can be overwritten by users).</label><br/>
+                </div>
+            </div>
+        </div>
+        <div class="form-group">
+            <label class="control-label col-md-3">Move/Resize</label>
+            <div class="col-md-9">
+                <div class="checkbox">
+                    <label><input type="checkbox" name="change_reservation_confirmation" {% if change_reservation_confirmation %}checked{% endif %} value="enabled">Require users to confirm by clicking 'Ok' when moving/resizing a reservation (this setting can be overwritten by users).</label><br/>
+                </div>
+            </div>
+        </div>
+        <div class="form-group">
+            <div class="col-md-12" style="margin-top: 15px">
+                The following settings allow to customize the date and time formats displayed on confirmations when <i>creating</i> a reservation.
+            </div>
+        </div>
+        <div class="form-group">
+            <label class="control-label col-md-3" for="reservation_confirmation_date_format">Date format</label>
+            <div class="col-md-3">
+                <input type="text" id="reservation_confirmation_date_format" name="reservation_confirmation_date_format" class="form-control" value="{{ reservation_confirmation_date_format }}"  placeholder="MMMM D, yyyy">
+            </div>
+            <div class="col-md-6 help-block light-grey">
+                The date format (not including time).
+            </div>
+        </div>
+        <div class="form-group">
+            <label class="control-label col-md-3" for="reservation_confirmation_time_format">Time format</label>
+            <div class="col-md-3">
+                <input type="text" id="reservation_confirmation_time_format" name="reservation_confirmation_time_format" class="form-control" value="{{ reservation_confirmation_time_format }}"  placeholder="h:mma">
+            </div>
+            <div class="col-md-6 help-block light-grey">
+                The time format.
+            </div>
+        </div>
+        <div class="customization-separation" style="margin-bottom: 15px"></div>
         <div class="text-center">
             {% button type="save" value="Save settings" %}
         </div>
diff --git a/NEMO/templates/customizations/customizations_knowledge_base.html b/NEMO/templates/customizations/customizations_knowledge_base.html
new file mode 100644
index 000000000..62848de8f
--- /dev/null
+++ b/NEMO/templates/customizations/customizations_knowledge_base.html
@@ -0,0 +1,39 @@
+{% load custom_tags_and_filters %}
+<div class="panel-body">
+    <h3 class="customization-section-title">Knowledge base settings</h3>
+    <form method="POST" action="{% url 'customize' 'knowledge_base' %}" class="form-horizontal">
+        {% csrf_token %}
+        <div class="form-group">
+            <label class="control-label col-sm-4 col-md-3">User view</label>
+            <div class="col-sm-8 col-md-9">
+                <div class="radio">
+                    <label><input type="radio" id="knowledge_base_user_expand_categories"
+                                  name="knowledge_base_user_expand_categories"
+                                  {% if not knowledge_base_user_expand_categories %}checked{% endif %} value=""> Side
+                        category panel</label>
+                    <label><input type="radio" name="knowledge_base_user_expand_categories"
+                                  {% if knowledge_base_user_expand_categories %}checked{% endif %} value="enabled"> All
+                        expanded categories</label>
+                </div>
+            </div>
+        </div>
+        <div class="form-group">
+            <label class="control-label col-sm-4 col-md-3">Staff view</label>
+            <div class="col-sm-8 col-md-9">
+                <div class="radio">
+                    <label><input type="radio" id="knowledge_base_staff_expand_categories"
+                                  name="knowledge_base_staff_expand_categories"
+                                  {% if not knowledge_base_staff_expand_categories %}checked{% endif %} value=""> Side
+                        category panel</label>
+                    <label><input type="radio" name="knowledge_base_staff_expand_categories"
+                                  {% if knowledge_base_staff_expand_categories %}checked{% endif %} value="enabled"> All
+                        expanded categories</label>
+                </div>
+            </div>
+        </div>
+        <div class="customization-separation" style="margin-bottom: 15px"></div>
+        <div class="text-center">
+            {% button type="save" value="Save settings" %}
+        </div>
+    </form>
+</div>
diff --git a/NEMO/templates/customizations/customizations_tool.html b/NEMO/templates/customizations/customizations_tool.html
index d3372528f..b53d8c1e9 100644
--- a/NEMO/templates/customizations/customizations_tool.html
+++ b/NEMO/templates/customizations/customizations_tool.html
@@ -100,10 +100,29 @@ <h3 class="customization-section-title">Tool control</h3>
             </div>
         </div>
         <div class="form-group">
-            <label class="control-label col-sm-4 col-md-3">Email users with upcoming reservation</label>
+            <label class="control-label col-sm-4 col-md-3">Qualified users list</label>
             <div class="col-sm-8 col-md-9">
                 <div class="checkbox">
-                    <label><input type="checkbox" id="tool_control_broadcast_upcoming_reservation" name="tool_control_broadcast_upcoming_reservation" {% if tool_control_broadcast_upcoming_reservation %}checked{% endif %} value="enabled"> Allow qualified users on a tool to send a broadcast email to users with an upcoming reservation</label>
+                    <label><input type="checkbox" id="tool_control_show_qualified_users_to_all" name="tool_control_show_qualified_users_to_all" {% if tool_control_show_qualified_users_to_all %}checked{% endif %} value="enabled"> check to allow everyone to see who is qualified on a tool</label>
+                </div>
+            </div>
+        </div>
+        <div class="form-group">
+            <label class="control-label col-sm-4 col-md-3">Show tool documents to</label>
+            <div class="col-sm-8 col-md-9">
+                <div class="radio">
+                    <label><input type="radio" id="tool_control_show_documents_only_qualified_users_2" name="tool_control_show_documents_only_qualified_users" {% if not tool_control_show_documents_only_qualified_users %}checked{% endif %} value=""> Everyone</label><br>
+                    <label><input type="radio" id="tool_control_show_documents_only_qualified_users_1" name="tool_control_show_documents_only_qualified_users" {% if tool_control_show_documents_only_qualified_users %}checked{% endif %} value="enabled"> Only qualified users</label>
+                </div>
+            </div>
+        </div>
+        <div class="form-group">
+            <label class="control-label col-sm-4 col-md-3">Email users with upcoming reservation</label>
+            <div class="col-sm-8 col-md-9">
+                <div class="radio">
+                    <label><input type="radio" id="tool_control_broadcast_upcoming_reservation_1" name="tool_control_broadcast_upcoming_reservation" {% if not tool_control_broadcast_upcoming_reservation %}checked{% endif %} value=""> Staff only</label><br>
+                    <label><input type="radio" id="tool_control_broadcast_upcoming_reservation_2" name="tool_control_broadcast_upcoming_reservation" {% if tool_control_broadcast_upcoming_reservation == "qualified" %}checked{% endif %} value="qualified"> Qualified users</label><br>
+                    <label><input type="radio" id="tool_control_broadcast_upcoming_reservation_3" name="tool_control_broadcast_upcoming_reservation" {% if tool_control_broadcast_upcoming_reservation == "all" %}checked{% endif %} value="all"> All</label>
                 </div>
             </div>
         </div>
diff --git a/NEMO/templates/customizations/customizations_training.html b/NEMO/templates/customizations/customizations_training.html
new file mode 100644
index 000000000..f58b76118
--- /dev/null
+++ b/NEMO/templates/customizations/customizations_training.html
@@ -0,0 +1,30 @@
+{% load custom_tags_and_filters %}
+<div class="panel-body">
+    <h3 class="customization-section-title">Training</h3>
+    <form method="POST" action="{% url 'customize' 'training' %}" class="form-horizontal">
+        {% csrf_token %}
+		<div class="form-group">
+            <label class="control-label col-lg-3 col-md-4 col-sm-5">Training types allowed</label>
+            <div class="col-lg-9 col-md-8 col-sm-7">
+				<div class="checkbox">
+                {% for val, name in training_types %}
+                    <label><input type="checkbox" name="training_type_list" {% if not training_only_type or training_only_type == val|stringformat:'i' %}checked{% endif %} value="{{ val }}">{{ name }}</label><br/>
+                {% endfor %}
+				</div>
+			</div>
+        </div>
+        <div class="form-group">
+            <label class="control-label col-lg-3 col-md-4 col-sm-5">Training date</label>
+            <div class="col-lg-9 col-md-8 col-sm-7">
+				<div class="checkbox">
+                    <label><input type="checkbox" id="training_allow_date" name="training_allow_date" {% if training_allow_date %}checked{% endif %} value="enabled">Allow trainer to pick the training date for each entry</label>
+				</div>
+                <div class="help-block light-grey" style="margin-bottom: 0">Qualifications will still happen in real time</div>
+			</div>
+        </div>
+        <div class="customization-separation" style="margin-bottom: 15px"></div>
+        <div class="text-center">
+            {% button type="save" value="Save settings" %}
+        </div>
+    </form>
+</div>
\ No newline at end of file
diff --git a/NEMO/templates/email/compose_email.html b/NEMO/templates/email/compose_email.html
index b3da1a811..f1f6a8346 100644
--- a/NEMO/templates/email/compose_email.html
+++ b/NEMO/templates/email/compose_email.html
@@ -83,7 +83,7 @@ <h3>Email content</h3>
 					<div class="form-group">
 						<input name="attachments" type="file" multiple>
 					</div>
-                    {% button type="view" onclick="update_preview()" value="Preview" %}
+                    {% button type="view" onclick="update_preview();document.getElementById('email_preview').scrollIntoView();" value="Preview" %}
 					{% button type="save" value="Send" icon="glyphicon-send" %}
                     {% if user.is_any_part_of_staff and user_emails %}
                         {% if active_user_emails|length > 2000 %}
diff --git a/NEMO/templates/knowledge_base/knowledge_base.html b/NEMO/templates/knowledge_base/knowledge_base.html
new file mode 100644
index 000000000..0237943e0
--- /dev/null
+++ b/NEMO/templates/knowledge_base/knowledge_base.html
@@ -0,0 +1,74 @@
+{% extends 'knowledge_base/knowledge_base_base.html' %}
+{% load custom_tags_and_filters %}
+{% block title %}{{ kind|capfirst }} knowledge base{% endblock %}
+{% block knowledge_base_title %}{{ kind|capfirst }} knowledge base{% endblock %}
+{% block tab_content %}
+    {% if categories or items %}
+        <div class="row" style="margin-bottom: 25px">
+            <div class="col-sm-6">
+                <input id="search" type="text" aria-label="Search" placeholder="Search" class="form-control">
+            </div>
+        </div>
+        <div class="row">
+            {% if not expand_categories %}
+                {% if categories %}
+                    <div class="col-sm-3">
+                        <ul class="nav nav-pills nav-stacked">
+                            {% if general %}
+                                <li class="{% if category_id is None %}active{% endif %}">
+                                    <a href="{% url 'knowledge_base_categories' kind %}">General</a>
+                                </li>
+                            {% endif %}
+                            {% for category in categories %}
+                                <li class="{% if category_id and category.id == category_id %}active{% endif %}">
+                                    <a href="{% url 'knowledge_base_categories' kind category.id %}">{{ category.name }}</a>
+                                </li>
+                            {% endfor %}
+                        </ul>
+                    </div>
+                {% endif %}
+                <div class="col-sm-{% if categories %}9{% else %}12{% endif %}">
+                    {% include 'knowledge_base/knowledge_base_items.html' %}
+                </div>
+            {% else %}
+                <div class="col-sm-12">
+                    {% if general %}
+                        {% include 'knowledge_base/knowledge_base_items.html' with items=general category="General" %}
+                    {% endif %}
+                    {% for category in categories %}
+                        {% if kind == "user" %}
+                            {% include 'knowledge_base/knowledge_base_items.html' with items=category.userknowledgebaseitem_set.all %}
+                        {% else %}
+                            {% include 'knowledge_base/knowledge_base_items.html' with items=category.staffknowledgebaseitem_set.all %}
+                        {% endif %}
+                    {% endfor %}
+                </div>
+            {% endif %}
+        </div>
+    {% else %}
+        <p>There are currently no knowledge base items.</p>
+        {% if user.is_superuser and "django.contrib.admin"|app_installed %}
+            {% with knowledge_base_url='admin:NEMO_'|concat:kind|concat:'knowledgebaseitem_changelist' %}
+                <p>
+                    Please go to <a href="{% url knowledge_base_url %}">Administration -> Detailed Administration
+                    -> {{ kind|capfirst }} knowledge base items</a>
+                </p>
+            {% endwith %}
+        {% endif %}
+    {% endif %}
+    <script>
+        function get_knowledge_base_item(jquery_event, search_selection, dataset_name) {
+            window.location.href = "{% url 'knowledge_base_item' kind 999 %}".replace('999', search_selection.id);
+        }
+
+        function on_load() {
+            $("#search").autocomplete('knowledge_base_items', get_knowledge_base_item, '{% url 'knowledge_base_items_search' kind %}', true);
+        }
+
+        function open_doc(url) {
+            ajax_get(url, undefined, ajax_success_callback, ajax_failure_callback("Unable to display document"));
+        }
+
+        window.addEventListener('load', on_load, true);
+    </script>
+{% endblock %}
\ No newline at end of file
diff --git a/NEMO/templates/knowledge_base/knowledge_base_base.html b/NEMO/templates/knowledge_base/knowledge_base_base.html
new file mode 100644
index 000000000..8685074b2
--- /dev/null
+++ b/NEMO/templates/knowledge_base/knowledge_base_base.html
@@ -0,0 +1,25 @@
+{% extends 'base.html' %}
+{% load custom_tags_and_filters %}
+{% block content %}
+    <div id="knowledge-base-tabs" class="clearfix">
+        <h1 class="knowledge-base-title pull-left">{% block knowledge_base_title %}Knowledge base{% endblock %}</h1>
+        {% if user.is_any_part_of_staff %}
+            <ul class="nav nav-pills nav-pills-spacer knowledge-base-tabs-nav" id="tabs">
+                <li class="{% if kind == 'user' %}active{% endif %}">
+                    <a href="#knowledge-base" onclick="location.href = '{% url "knowledge_base" "user" %}'">User</a>
+                </li>
+                <li class="{% if kind == 'staff' %}active{% endif %}">
+                    <a href="#knowledge-base" onclick="location.href = '{% url "knowledge_base" "staff" %}'">Staff</a>
+                </li>
+            </ul>
+        {% endif %}
+    </div>
+    <div class="tab-content">
+        <div class="tab-pane active" id="knowledge-base">
+            {% block tab_content %}{% endblock %}
+        </div>
+    </div>
+    <script>
+        $("#tabs").find("a").click(switch_tab);
+    </script>
+{% endblock %}
\ No newline at end of file
diff --git a/NEMO/templates/knowledge_base/knowledge_base_items.html b/NEMO/templates/knowledge_base/knowledge_base_items.html
new file mode 100644
index 000000000..5f51956dd
--- /dev/null
+++ b/NEMO/templates/knowledge_base/knowledge_base_items.html
@@ -0,0 +1,50 @@
+{% load custom_tags_and_filters %}
+<div class="tab-content panel panel-default">
+    {% if category %}
+        <div class="panel-heading pointer" data-toggle="collapse" data-target="#items-{{ category.id }}"
+             onclick="toggle_details($(this).children(':first'))">
+            <h3 class="panel-title pointer"><i class="glyphicon glyphicon-chevron-down chevron"></i> {{ category }}</h3>
+        </div>
+        <div class="collapse in" id="items-{{ category.id }}">
+        <div class="panel-body">
+    {% endif %}
+    <div class="tab-pane active" style="padding: 15px">
+        <ul style="padding-left: 15px">
+            {% for item in items %}
+                <li id="knowledge_base_item_{{ item.id }}" style="margin-bottom: 15px"><u>{{ item.name }}:</u>
+                    {% if item.description %}
+                        <div style="margin: 5px 0">{{ item.description|safe|linebreaksbr }}</div>
+                    {% endif %}
+                    <ul>
+                        {% if kind == "user" %}
+                            {% for doc in item.userknowledgebaseitemdocuments_set.all %}
+                                {% if doc.can_be_embedded %}
+                                    {% with content_type=doc|content_type %}
+                                        <li><a href="#"
+                                               onclick="open_doc('{% url 'media_view' 'true' content_type.id doc.id %}')">{{ doc.filename }}</a>
+                                        </li>
+                                    {% endwith %}
+                                {% else %}
+                                    <li><a href="{{ doc.link }}" target="_blank">{{ doc.filename }}</a></li>
+                                {% endif %}
+                            {% endfor %}
+                        {% else %}
+                            {% for doc in item.staffknowledgebaseitemdocuments_set.all %}
+                                {% if doc.can_be_embedded %}
+                                    {% with content_type=doc|content_type %}
+                                        <li><a href="#"
+                                               onclick="open_doc('{% url 'media_view' 'true' content_type.id doc.id %}')">{{ doc.filename }}</a>
+                                        </li>
+                                    {% endwith %}
+                                {% else %}
+                                    <li><a href="{{ doc.link }}" target="_blank">{{ doc.filename }}</a></li>
+                                {% endif %}
+                            {% endfor %}
+                        {% endif %}
+                    </ul>
+                </li>
+            {% endfor %}
+        </ul>
+    </div>
+    {% if category %}</div></div>{% endif %}
+</div>
\ No newline at end of file
diff --git a/NEMO/templates/requests/buddy_requests/buddy_requests.html b/NEMO/templates/requests/buddy_requests/buddy_requests.html
index e3bac0122..f15df2242 100644
--- a/NEMO/templates/requests/buddy_requests/buddy_requests.html
+++ b/NEMO/templates/requests/buddy_requests/buddy_requests.html
@@ -30,8 +30,10 @@ <h3>{{ date.grouper|date }}</h3>
                                         <div class="buddy-list-item-buttons">
                                             {% url 'edit_buddy_request' br.id as edit_buddy_request_url %}
                                             {% button type="edit" size="small" url=edit_buddy_request_url value="Edit" %}
-                                            {% url 'delete_buddy_request' br.id as delete_buddy_request_url %}
-                                            {% button type="delete" size="small" url=delete_buddy_request_url value="Delete" %}
+                                            <form style="display: inline-block" action="{% url 'delete_buddy_request' br.id %}" method="post">
+                                                {% csrf_token %}
+                                                {% button type="delete" size="small" submit=True value="Delete" %}
+                                            </form>
                                         </div>
                                     {% endif %}
                                 </div>
diff --git a/NEMO/templates/staff_charges/choose_project.html b/NEMO/templates/staff_charges/choose_project.html
index 661d178ed..d3e71a3d5 100644
--- a/NEMO/templates/staff_charges/choose_project.html
+++ b/NEMO/templates/staff_charges/choose_project.html
@@ -10,14 +10,16 @@
 				<input type="hidden" name="customer" value="{{ customer.id }}">
 			</div>
 			<div class="form-group">
-				{% if customer.active_project_count == 1 %}
+				{% if customer.active_project_count == 1 and customer.active_projects.0.allow_staff_charges %}
 					Staff time will be billed to the customer's only project, &quot;{{ customer.active_projects.0 }}&quot;
 					<input type="hidden" name="project" value="{{ customer.active_projects.0.id }}">
 				{% else %}
 					Which of the customer's projects would you like to bill?
 					<div class="radio">
 						{% for p in customer.active_projects %}
-							<label class="control-label"><input type="radio" name="project" value="{{ p.id }}" required> {% project_selection_display p %}</label><br>
+                            {% if p.allow_staff_charges %}
+							    <label class="control-label"><input type="radio" name="project" value="{{ p.id }}" required> {% project_selection_display p %}</label><br>
+                            {% endif %}
 						{% endfor %}
 					</div>
 				{% endif %}
diff --git a/NEMO/templates/status_dashboard/tools.html b/NEMO/templates/status_dashboard/tools.html
index e8366cbe4..848f4377b 100644
--- a/NEMO/templates/status_dashboard/tools.html
+++ b/NEMO/templates/status_dashboard/tools.html
@@ -1,10 +1,6 @@
 <p>
     Staff members are highlighted in <span class="bg-success">&nbsp;green&nbsp;</span><br>
     Service personnel are highlighted in <span class="bg-warning">&nbsp;orange&nbsp;</span><br>
-    {% if reservations_can_expire %}
-    Users with expired reservations are highlighted in <span class="bg-danger">&nbsp;red&nbsp;</span>
-    {% endif %}
-    Users logged in outside of their allowed schedule are highlighted in <span class="bg-danger">&nbsp;red&nbsp;</span>
 </p>
 <table class="table table-bordered table-condensed">
 	<thead>
diff --git a/NEMO/templates/tool_control/get_projects.html b/NEMO/templates/tool_control/get_projects.html
index 3811611a2..3cfae42da 100644
--- a/NEMO/templates/tool_control/get_projects.html
+++ b/NEMO/templates/tool_control/get_projects.html
@@ -6,7 +6,8 @@
 	<h4>Tool usage will be billed to the project named &quot;{{ active_projects.0 }}&quot;.</h4>
 	<input type="hidden" id="project_id" value="{{ active_projects.0.id }}">
 	<script>
-		$("#start").show();
+        $("#pre_usage_questions").show();
+        $("#start").show();
 	</script>
 {% else %}
 	<h4>Charge tool usage to which project?</h4>
diff --git a/NEMO/templates/tool_control/logout_user.html b/NEMO/templates/tool_control/logout_user.html
new file mode 100644
index 000000000..f6cb78935
--- /dev/null
+++ b/NEMO/templates/tool_control/logout_user.html
@@ -0,0 +1,19 @@
+{% load custom_tags_and_filters %}
+<div class="modal-header">
+	<button type="button" class="close" data-dismiss="modal" aria-label="Modal close button">&times;</button>
+	<h4 id="modal-description-label" class="modal-title">Would you like to log out of the {{ area.name }}?</h4>
+</div>
+
+<div class="modal-body">
+    You are currently logged in the {{ area.name }}.<br>
+    Now that you have finished using the {{ tool.name_or_child_in_use_name }}, would you
+    like to also log out of the {{ area.name }}?
+</div>
+
+<div class="modal-footer">
+    {% url 'self_log_out' user.id as area_logout_url %}
+    {% with button_name="Log out of the "|concat:area.name onclick_js="$('#dialog').one('hidden.bs.modal', ajax_get('"|concat:area_logout_url|concat:"'));" %}
+        {% button type="save" submit=False dismiss="modal" value="Continue without logging out" icon="glyphicon-time" %}
+        {% button type="delete" submit=False dismiss="modal" value=button_name icon="glyphicon-stop" onclick=onclick_js %}
+    {% endwith %}
+</div>
\ No newline at end of file
diff --git a/NEMO/templates/tool_control/tool_control.html b/NEMO/templates/tool_control/tool_control.html
index 32613ff92..d55aa40ae 100644
--- a/NEMO/templates/tool_control/tool_control.html
+++ b/NEMO/templates/tool_control/tool_control.html
@@ -89,7 +89,8 @@
 			$(".project-choice").removeClass('active');
 			$(element).addClass('active');
 			$("#project_id").val(project_id);
-			$("#start").show();
+            $("#pre_usage_questions").show();
+            $("#start").show();
 		}
 
 		function load_projects(jquery_event, search_selection, dataset_name)
@@ -180,7 +181,7 @@
 			else
 				success_callbacks = [refresh_tool_status, ajax_success_callback];
 			let enable_start_button = function() { $("#start button").prop("disabled", false); };
-            let data = "remote_work=" + $("#remote_work").val();
+            let data = $("#tool_control").serialize();
 			try_post_catch_interlock_error(url, data, success_callbacks, enable_start_button);
 		}
 
@@ -201,6 +202,7 @@
 			{
                 $("#remote_work").val(remote_work || "");
 				$("#start").hide();
+                $("#pre_usage_questions").hide();
 				$("#project_choice").html("Fetching user list...").show().load("{% url 'use_tool_for_other' %}", undefined, load_projects_completion_callback);
 			}
 
@@ -209,6 +211,7 @@
 				$("#customer").text("").hide();
 				$("#customer_project").html("").hide();
 				$("#start").hide();
+                $("#pre_usage_questions").hide();
 				$("#user_search").show().focus();
 			}
 
@@ -238,7 +241,7 @@
 				if (selected_item && selected_item !== 'personal_schedule')
 				{
 					let item = JSON.parse(selected_item)
-				    let url = "{% url 'get_qualified_users' %}?tool_id=" + item.id;
+				    let url = "{% url 'get_qualified_users' %}?tool_id=" + {% if device == 'mobile' %} selected_item {% else %} item.id {% endif %};
 					$("#qualified_users_container").load(url);
 				}
 			}
@@ -311,7 +314,7 @@
 			$("#usage_data").load("{% url 'usage_data_history' 999 %}".replace('999', tool_id), data, failure_dialog);
 			if (csv_export)
 			{
-				$('#hidden_data_csv').val('true');
+				$('#hidden_data_csv').val(csv_export);
 				$('#hidden_data_start').val(data.start);
 				$('#hidden_data_end').val(data.end);
 				$('#usage_data_form').submit();
diff --git a/NEMO/templates/tool_control/tool_status.html b/NEMO/templates/tool_control/tool_status.html
index 853b6f2ef..e23c9bd17 100644
--- a/NEMO/templates/tool_control/tool_status.html
+++ b/NEMO/templates/tool_control/tool_status.html
@@ -10,7 +10,7 @@ <h1>{{ tool.name_or_child_in_use_name }}</h1>
                     <li style="width:48%; text-align:center"><a href="#config" style="padding: 10px 5px"
                                                                 onclick="load_config_history('{{ tool.id }}');">Config
                         History</a></li>{% endif %}
-                {% if tool.post_usage_questions and not hide_data_history or tool.post_usage_questions and user.is_any_part_of_staff %}<li style="width: 48%; text-align: center"><a href="#usage_data" style="padding: 10px 5px;" onclick="load_usage_data('{{ tool.id }}');">Usage Data History</a></li>{% endif %}
+                {% if has_usage_questions and not hide_data_history or has_usage_questions and user.is_any_part_of_staff %}<li style="width: 48%; text-align: center"><a href="#usage_data" style="padding: 10px 5px;" onclick="load_usage_data('{{ tool.id }}');">Usage Data History</a></li>{% endif %}
                 <li style="width:48%; text-align:center"><a href="#problem" style="padding: 10px 5px">Report a problem</a></li>
                 <li style="width:48%; text-align:center"><a href="#comment" style="padding: 10px 5px">Post a comment</a></li>
             </ul>
@@ -22,7 +22,7 @@ <h1 class="pull-left" style="margin-right:20px; margin-top:0; margin-bottom:10px
                 {% if tool.configuration_set.exists %}
                     <li><a href="#config" onclick="load_config_history('{{ tool.id }}');">Config History</a>
                     </li>{% endif %}
-                {% if tool.post_usage_questions and not hide_data_history or tool.post_usage_questions and user.is_any_part_of_staff %}<li><a href="#usage_data" onclick="load_usage_data('{{ tool.id }}');">Usage Data History</a></li>{% endif %}
+                {% if has_usage_questions and not hide_data_history or has_usage_questions and user.is_any_part_of_staff %}<li><a href="#usage_data" onclick="load_usage_data('{{ tool.id }}');">Usage Data History</a></li>{% endif %}
                 <li><a href="#problem">Report a problem</a></li>
                 <li><a href="#comment">Post a comment</a></li>
             </ul>
@@ -346,6 +346,7 @@ <h4 id="what_to_do_label">What would you like to do?</h4>
 				{% 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 %}
@@ -369,17 +370,17 @@ <h4 id="what_to_do_label">What would you like to do?</h4>
             {% for message in messages %}
                 <div class="alert alert-{{ message.level_tag }} alert-dismissible show-on-load" {% if message.extra_tags %}{{ message.extra_tags }}{% endif %} style="display: none">
                     <button type="button" class="close" data-dismiss="alert" aria-label="Modal close button">&times;</button>
-                    {{message}}
+                    {{ message }}
                 </div>
             {% endfor %}
         {% endif %}
 		
-		{% if tool.description or tool.image or tool.tooldocuments_set.all %}
+		{% if tool.description or tool.image or tool.tooldocuments_set.all and user_can_see_documents %}
 			<div style="background-color: #f5f5f5;">
 				<div class="row" style="margin:0; padding: 15px 0">
 					{% if tool.description %}
 					<div class="col-md-8">
-						<p> {{tool.description| safe }} </p>
+						<p> {{ tool.description|safe }} </p>
 					</div>
 				{% endif %}
 				{% if tool.image %}
@@ -387,7 +388,7 @@ <h4 id="what_to_do_label">What would you like to do?</h4>
 						<img src="{{ tool.image.url }}" alt="Tool image" class="img-fluid img-thumbnail" style="max-width:300px ;max-height:300px" >
 					</div>
 				{% endif %}
-                {% if tool.tooldocuments_set.all %}
+                {% if tool.tooldocuments_set.all and user_can_see_documents %}
                     <div class="col-md-12">
                         Documents:
                         <ul>
@@ -455,6 +456,35 @@ <h4 id="what_to_do_label">What would you like to do?</h4>
 			<div id="qualified_users_container" class="media">
 				{% include 'tool_control/qualified_users.html' %}
 			</div>
+		{% elif user in tool.superusers.all or "tool"|customization:"tool_control_show_qualified_users_to_all" == "enabled" %}
+            {% with tool.user_set.all as qualified_users %}
+                {% if qualified_users %}
+                    <div id="qualified_users_container" class="media">
+                        <a onclick="toggle_details(this)" data-toggle="collapse" data-target="#qualified_users" class="pointer">
+                            <span class="glyphicon glyphicon-user pull-left notification-icon primary-highlight"></span>
+                            <span class="glyphicon {% if expanded %}glyphicon-chevron-down{% else %}glyphicon-chevron-right{% endif %} pull-left chevron"></span>
+                        </a>
+                        <div class="media-body">
+                            <h4 class="media-heading">Qualified users</h4>
+                            <div id="qualified_users" class="collapse {% if expanded %}in{% endif %}">
+                                <p>Only qualified users are able to operate the {{ tool.name }}.</p>
+                                <p>
+                                    Current qualified users are listed below. Inactive qualified users are <span class="strikethrough light-grey">crossed out</span>.<br>
+                                </p>
+                                    <ul>
+                                        {% for u in qualified_users %}
+                                            {% if u.is_active %}
+                                                <li class="">{{ u }}</li>
+                                            {% else %}
+                                                <li class="grey"><span class="strikethrough light-grey">{{ u }}</span></li>
+                                            {% endif %}
+                                        {% endfor %}
+                                    </ul>
+                            </div>
+                        </div>
+                    </div>
+                {% endif %}
+            {% endwith %}
 		{% endif %}
 		{% if tool.required_resource_set.exists %}
 			<div class="media">
@@ -685,9 +715,25 @@ <h4 class="media-heading">Task &amp; comment history for this tool</h4>
 		}
 	}
 
+    function update_start_button()
+	{
+        if ({{ tool.pre_usage_questions|yesno:"true,false" }}){
+            if(document.querySelector('#tool_control').checkValidity())
+            {
+                $("#start button").prop("disabled", false);
+            }
+            else
+            {
+                $("#start button").prop("disabled", true);
+            }
+        }
+	}
+
     show_alerts();
 	update_stop_button();
+    update_start_button();
 	$('body').on('question-group-changed', update_stop_button);
+    $('body').on('question-group-changed', update_start_button);
 	$('body').on('change keyup', '#tool_control input, #tool_control select, #tool_control textarea, #downtime', update_stop_button);
-
+    $('body').on('change keyup', '#tool_control input, #tool_control select, #tool_control textarea, #downtime', update_start_button);
 </script>
diff --git a/NEMO/templates/tool_control/usage_data.html b/NEMO/templates/tool_control/usage_data.html
index 09afd8f98..4aab4d9e2 100644
--- a/NEMO/templates/tool_control/usage_data.html
+++ b/NEMO/templates/tool_control/usage_data.html
@@ -5,6 +5,7 @@
 	<input type="hidden" name="start" id="hidden_data_start"/>
 	<input type="hidden" name="end" id="hidden_data_end"/>
     <div class="container-fluid">
+
         <div class="row">
             <div class="col-md-8">
                 <div class="row" style="margin-bottom: 15px">
@@ -38,7 +39,7 @@
                         </div>
                     </div>
                 </div>
-                <div class="row">
+                <div class="row" style="margin-bottom: 15px">
                     <div class="container">
                         <div class="form-group extra-side-padding">
                             <label for="user_search">Filter by user:</label>
@@ -55,40 +56,96 @@
                 </div>
             </div>
             <div class="col-md-4">
-                <div class="form-group pull-right extra-side-padding">
-                    {% button type="export" value="Export" onclick="load_usage_data('"|concat:tool_id|concat:"', true);" %}
-                </div>
                 <div class="form-group pull-right extra-side-padding">
                     {% button type="save" submit=False value="Update" onclick="load_usage_data('"|concat:tool_id|concat:"');" icon="glyphicon-refresh" %}
                 </div>
             </div>
         </div>
+
     </div>
 </form>
-
-{% if usage_data_table.rows %}
-	<table class="table table-bordered table-condensed" style="margin-top: 10px">
-		<thead>
-			<tr>
-				{% for header in usage_data_table.flat_headers %}
-					<th>{{ header }}</th>
-				{% endfor %}
-			</tr>
-		</thead>
-		<tbody>
-			{% for row in usage_data_table.flat_rows %}
-				<tr>
-					{% for item in row %}
-						<td>{% if item|class_name == "list" %}{{ item|join:", " }}{% else %}{{ item }}{% endif %}</td>
-					{% endfor %}
-				</tr>
-			{% endfor %}
-		</tbody>
-	</table>
+{% if run_data_table.rows or pre_run_data_table.rows %}
+<ul class="nav nav-tabs" id="usage-tabs">
+    {% if pre_run_data_table.rows  %}
+    <li class="{% if pre_run_data_table.rows %}active{% endif %}">
+        <a data-toggle="tab" href="#pre-run-data-tab">Pre-Run Data</a>
+    </li>
+    {% endif %}
+    {% if run_data_table.rows  %}
+    <li class="{% if not pre_run_data_table.rows %}active{% endif %}">
+        <a data-toggle="tab" href="#run-data-tab">Run Data</a>
+    </li>
+    {% endif %}
+</ul>
+<div class="tab-content" style="margin-bottom: 0;">
+    <div class="tab-pane {% if pre_run_data_table.rows %}active{% endif %}" id="pre-run-data-tab">
+        <div class="col-md-12">
+            <div class="form-group pull-right extra-side-padding" style="padding-top: 10px">
+                {% button type="export" value="Export" onclick="load_usage_data('"|concat:tool_id|concat:"', 'pre-run');" %}
+            </div>
+        </div>
+        <div class="panel-body">
+            {% if pre_run_data_table.rows %}
+                <table class="table table-bordered table-condensed" style="margin-top: 10px">
+                    <thead>
+                        <tr>
+                            {% for header in pre_run_data_table.flat_headers %}
+                                <th>{{ header }}</th>
+                            {% endfor %}
+                        </tr>
+                    </thead>
+                    <tbody>
+                        {% for row in pre_run_data_table.flat_rows %}
+                            <tr>
+                                {% for item in row %}
+                                    <td>{% if item|class_name == "list" %}{{ item|join:", " }}{% else %}{{ item|default_if_none:"" }}{% endif %}</td>
+                                {% endfor %}
+                            </tr>
+                        {% endfor %}
+                    </tbody>
+                </table>
+            {% else %}
+                <span class="italic">No pre-run data was found between these dates</span>
+            {% endif %}
+        </div>
+    </div>
+    <div class="tab-pane {% if not pre_run_data_table.rows %}active{% endif %}" id="run-data-tab">
+        <div class="col-md-12">
+            <div class="form-group pull-right extra-side-padding" style="padding-top: 10px">
+                {% button type="export" value="Export" onclick="load_usage_data('"|concat:tool_id|concat:"', 'run');" %}
+            </div>
+        </div>
+        <div class="panel-body">
+            {% if run_data_table.rows %}
+                <table class="table table-bordered table-condensed" style="margin-top: 10px">
+                    <thead>
+                        <tr>
+                            {% for header in run_data_table.flat_headers %}
+                                <th>{{ header }}</th>
+                            {% endfor %}
+                        </tr>
+                    </thead>
+                    <tbody>
+                        {% for row in run_data_table.flat_rows %}
+                            <tr>
+                                {% for item in row %}
+                                    <td>{% if item|class_name == "list" %}{{ item|join:", " }}{% else %}{{ item|default_if_none:"" }}{% endif %}</td>
+                                {% endfor %}
+                            </tr>
+                        {% endfor %}
+                    </tbody>
+                </table>
+            {% else %}
+                <span class="italic">No run data was found between these dates</span>
+            {% endif %}
+        </div>
+    </div>
+</div>
 {% else %}
-	<span class="italic">No usage data was found between these dates</span>
+    <span class="italic">No usage data was found between these dates</span>
 {% endif %}
 
+
 <script>
     function clear_selected_user()
     {
diff --git a/NEMO/templates/training/training.html b/NEMO/templates/training/training.html
index 75cac9039..306b7b800 100644
--- a/NEMO/templates/training/training.html
+++ b/NEMO/templates/training/training.html
@@ -1,5 +1,10 @@
 {% extends 'base.html' %}
 {% load custom_tags_and_filters %}
+{% block extrahead %}
+	{% load static %}
+	<script type="text/javascript" src="{% static "datetimepicker/bootstrap-datetimepicker.js" %}"></script>
+	<link rel="stylesheet" type="text/css" href="{% static "datetimepicker/bootstrap-datetimepicker.css" %}"/>
+{% endblock %}
 {% block title %}Training{% endblock %}
 {% block content %}
 	<h1>Training</h1>
@@ -8,42 +13,33 @@ <h1>Training</h1>
 	Remove a participant or blank row by clicking the circled X on that row.</p>
 	<p>When a user has successfully completed a training session you have the option to check the "Qualify" box, which qualifies the user for that tool.</p>
 	<br>
-	<form onsubmit="return on_submit()" id="training_session_form">
+	<form class="form-horizontal" onsubmit="return on_submit()" id="training_session_form">
 		{% csrf_token %}
-		<table id="training_entries" class="table">
-			<tr>
-				<th aria-label="Action">&nbsp;</th>
-				<th style="min-width:200px">Trainee</th>
-				<th style="min-width:200px">Tool</th>
-				<th style="min-width:200px">Project</th>
-				<th>Duration</th>
-				<th>Training type</th>
-				<th>Qualify</th>
-			</tr>
-			{# Rows are inserted here upon load & when "Add another participant" is clicked. #}
-			{% include 'training/training_entry.html' with entry_number=0 %}
-			<tr>
-				<td colspan="7" style="text-align:center"><a href="javascript:void(0)" onclick="add_participant()">Add another participant</a></td>
-			</tr>
-		</table>
-		<br>
+        {# Rows are inserted here upon load & when "Add another participant" is clicked. #}
+        {% include 'training/training_entry.html' with entry_number=0 %}
+        <div class="row text-center">
+            <a href="javascript:void(0)" onclick="add_participant()">Add another participant</a>
+        </div>
         {# Note: onclick need to stay blank so it will trigger the onsumbit of this form #}
         {% button id="record_training_sessions" type="save" value="Record training sessions" onclick="" %}
 	</form>
 
 	<script type="text/javascript">
 
+		let timepicker_properties = {format: '{{ datetime_input_js_format }}'};
 		let current_entry_number = 1;
-		function add_participant()
+		function add_participant(data)
 		{
-			let failure_dialog = ajax_failure_callback("Cannot add new entry", "The web browser was not able to communicate with the server.");
-			ajax_get("{% url 'training_entry' %}", { "entry_number": current_entry_number }, add_entry_success_callback, failure_dialog);
-			function add_entry_success_callback(response, status, xml_http_request)
-			{
-				$("#training_entries tr:last").before(response);
-				enable_autocompletion(current_entry_number);
-				current_entry_number++;
-			}
+			if (data === undefined) data = {}
+                data["entry_number"] = current_entry_number;
+                let failure_dialog = ajax_failure_callback("Cannot add new entry", "The web browser was not able to communicate with the server.");
+                ajax_get("{% url 'training_entry' %}", data, add_entry_success_callback, failure_dialog);
+                function add_entry_success_callback(response, status, xml_http_request)
+                {
+                    $("#training_session_form div.row:last").before(response);
+                    enable_autocompletion(current_entry_number);
+                    current_entry_number++;
+                }
 		}
 
 		function enable_autocompletion(entry_number)
@@ -52,6 +48,7 @@ <h1>Training</h1>
 			$(id).autocomplete('user', on_autocomplete_selection, {{ users|json_search_base }}).prop('required', true).focus();
 			id = "#tool_textbox__" + entry_number;
 			$(id).autocomplete('tool', on_autocomplete_selection, {% json_search_base_with_extra_fields tools|add:tool_groups %}, {% if tool_groups %}false{% else %}true{% endif %}).prop('required', true);
+		    $("#date__" + entry_number).datetimepicker(timepicker_properties);
 		}
 
 		function remove_row(number)
@@ -99,16 +96,23 @@ <h1>Training</h1>
 
 		function fetch_projects(row, user_id)
 		{
+			let target_element = "#project__" + row;
+            function project_success(response, status, xml_http_request)
+            {
+                $(target_element).html(response);
+                $(target_element + " select").addClass("input-sm");
+                if ($(target_element + " select").length === 0) $(target_element).css("padding-top", "7px");
+                else $(target_element).css("padding-top", "0");
+            }
 			let parameters =
 			{
 				"source_template": "training",
 				"user_id": user_id,
 				"entry_number": row
 			};
-			let target_element = "#project__" + row;
 			let url = "{% url 'get_projects_for_training' %}?" + jQuery.param(parameters);
-			let report_error = ajax_complete_callback("Could not fetch projects for user", "There was a problem obtaining the list of projects for the user.");
-			$(target_element).load(url, undefined, report_error);
+			let report_error = ajax_failure_callback("Could not fetch projects for user", "There was a problem obtaining the list of projects for the user.");
+			ajax_get(url, undefined, project_success, report_error);
 		}
 
 		function purge_trainee(row)
diff --git a/NEMO/templates/training/training_entry.html b/NEMO/templates/training/training_entry.html
index 1248945af..be7f78505 100644
--- a/NEMO/templates/training/training_entry.html
+++ b/NEMO/templates/training/training_entry.html
@@ -1,30 +1,55 @@
-<tr id="row_{{ entry_number }}">
-	<td style="vertical-align: middle"><span class="glyphicon glyphicon-remove-circle grey pointer" onclick="remove_row({{ entry_number }})"></span></td>
-	<td>
-		<input aria-label="training user #{{ entry_number }}"  type="text" class="form-control" id="user_textbox__{{ entry_number }}" name="chosen_user__{{ entry_number }}" data-row="{{ entry_number }}">
-		<input type="button" class="btn btn-default" style="display:none" id="user_button__{{ entry_number }}" onclick="purge_trainee({{ entry_number }})" value="user">
-	</td>
-	<td>
-		<input aria-label="training tool #{{ entry_number }}" type="text" class="form-control" id="tool_textbox__{{ entry_number }}" name="chosen_tool__{{ entry_number }}" data-row="{{ entry_number }}">
-		<input type="hidden" id="tool_textbox_type__{{ entry_number }}" name="chosen_type__{{ entry_number }}" data-row="{{ entry_number }}">
-		<input type="button" class="btn btn-default" style="display:none" id="tool_button__{{ entry_number }}" onclick="purge_tool({{ entry_number }})" value="tool">
-	</td>
-	<td id="project__{{ entry_number }}" style="vertical-align: middle">
-
-	</td>
-	<td>
-		<div class="input-group">
-			<input aria-label="training duration #{{ entry_number }}" type="number" class="form-control" name="duration__{{ entry_number }}" id="duration__{{ entry_number }}" min="1" inputmode="numeric" pattern="[0-9]*" required>
-			<div class="input-group-addon">min</div>
-		</div>
-    </td>
-	<td>
-		<select aria-label="training type #{{ entry_number }}" class="form-control" name="charge_type__{{ entry_number }}" required>
-			<option disabled selected value="">&nbsp;</option>
-			{% for charge_type in charge_types %}
-				<option value="{{ charge_type.0 }}">{{ charge_type.1 }}</option>
-			{% endfor %}
-		</select>
-	</td>
-	<td style="vertical-align: middle"><input aria-label="training qualification #{{ entry_number }}"  type="checkbox" name="qualify__{{ entry_number }}" checked></td>
-</tr>
\ No newline at end of file
+{% load custom_tags_and_filters %}
+<div id="row_{{ entry_number }}" class="well" style="display: flex; flex-flow: row wrap; padding: 15px 0">
+	<div class="text-center" style="width: 3%; margin-left: 5px">
+		<div class="glyphicon glyphicon-remove-circle grey pointer" style="padding: 3px; z-index: 10" onclick="remove_row({{ entry_number }})"></div>
+	</div>
+	<div class="row" style="width: 97%; padding-left: 5px">
+        {% with date_allowed="training"|customization:"training_allow_date" %}
+            <label class="control-label col-sm-2 col-md-1" for="user_textbox__{{ entry_number }}">Trainee</label>
+            <div class="col-sm-4 col-md-3">
+                <input type="text" class="form-control input-sm" id="user_textbox__{{ entry_number }}" name="chosen_user__{{ entry_number }}" data-row="{{ entry_number }}">
+                <input type="button" class="btn btn-sm btn-default" style="display:none" id="user_button__{{ entry_number }}" onclick="purge_trainee({{ entry_number }})" value="user">
+            </div>
+            <label class="control-label {% if not date_allowed %}col-sm-2{% else %}col-sm-1{% endif %} col-md-1" for="tool_textbox__{{ entry_number }}">Tool</label>
+            <div class="col-sm-4 col-md-3">
+                <input type="text" class="form-control input-sm" id="tool_textbox__{{ entry_number }}" name="chosen_tool__{{ entry_number }}" data-row="{{ entry_number }}">
+                <input type="hidden" id="tool_textbox_type__{{ entry_number }}" name="chosen_type__{{ entry_number }}" data-row="{{ entry_number }}">
+                <input type="button" class="btn btn-sm btn-default" style="display:none" id="tool_button__{{ entry_number }}" onclick="purge_tool({{ entry_number }})" value="tool">
+            </div>
+            <label class="control-label col-sm-2 col-md-1" for="project__{{ entry_number }}">Project</label>
+            <div class="col-sm-4 col-md-3" id="project__{{ entry_number }}" style="min-height: 30px; vertical-align: middle;"></div>
+            {% if date_allowed %}
+                <label class="control-label {% if not date_allowed %}col-sm-2{% else %}col-sm-1{% endif %} col-md-1" for="date__{{ entry_number }}">Date</label>
+                <div class="col-sm-4 col-md-3">
+                    <input type="text" class="form-control input-sm" name="date__{{ entry_number }}" id="date__{{ entry_number }}" value="{{ date|default_if_none:"" }}">
+                </div>
+            {% endif %}
+            <label class="control-label col-sm-2 col-md-1" for="duration__{{ entry_number }}">Duration</label>
+            <div class="{% if not date_allowed %}col-sm-4 col-md-3{% else %}col-sm-3 col-md-2{% endif %}">
+                <div class="input-group">
+                    <input type="number" class="text-right form-control input-sm" name="duration__{{ entry_number }}" id="duration__{{ entry_number }}" min="1" inputmode="numeric" pattern="[0-9]*" required value="{{ duration|default_if_none:"" }}">
+                    <div class="input-group-addon">min</div>
+                </div>
+            </div>
+            <label class="control-label col-sm-2 col-md-1" for="charge_type__{{ entry_number }}">Type</label>
+            <div class="{% if not date_allowed %}col-sm-4 col-md-3{% else %}col-sm-3 col-md-2{% endif %}">
+                <select class="form-control input-sm" id="charge_type__{{ entry_number }}" name="charge_type__{{ entry_number }}" required>
+                    {% if charge_types|length_is:1 %}
+                        <option value="{{ charge_types.0.0 }}" selected>{{ charge_types.0.1 }}</option>
+                    {% else %}
+                        <option disabled selected value="">&nbsp;</option>
+                        {% for charge_type in charge_types %}
+                            <option value="{{ charge_type.0 }}" {% if charge_type_id|to_int == charge_type.0 %}selected{% endif %}>{{ charge_type.1 }}</option>
+                        {% endfor %}
+                    {% endif %}
+                </select>
+            </div>
+            <label class="control-label {% if not date_allowed %}col-sm-2{% else %}col-sm-1{% endif %} col-md-1" for="qualify__{{ entry_number }}">Qualified</label>
+            <div class="col-sm-1 col-md-1">
+                <div class="checkbox">
+                    <label><input type="checkbox" id="qualify__{{ entry_number }}" name="qualify__{{ entry_number }}" checked></label>
+                </div>
+            </div>
+        {% endwith %}
+	</div>
+</div>
\ No newline at end of file
diff --git a/NEMO/templates/usage/usage_base.html b/NEMO/templates/usage/usage_base.html
index f29d4348f..baf800607 100644
--- a/NEMO/templates/usage/usage_base.html
+++ b/NEMO/templates/usage/usage_base.html
@@ -80,7 +80,14 @@ <h1>Usage{% if billing_service %} and billing information{% endif %}
 				<div class="col-sm-4 col-lg-3 {% if user.active_projects or pi_projects %}text-right{% else %}col-md-3{% endif %}">
                     {% button type="save" submit=False onclick="$('#csv_export').val('');this.form.submit();" value="Update" icon="glyphicon-refresh" %}
                     <input id="csv_export" type="hidden" name="csv" value="">
-                    {% button style="margin-left: 15px" type="export" onclick="$('#csv_export').val('true');this.form.submit();" value="Export" %}
+                    {% if not billing_service %}
+                        {% button style="margin-left: 15px" type="export" onclick="$('#csv_export').val('true');this.form.submit();" value="Export" %}
+                    {% else %}
+                        {% url "billing" as billing_url %}
+                        {% if billing_url not in request.path %}
+                            {% button style="margin-left: 15px" type="export" onclick="$('#csv_export').val('true');this.form.submit();" value="Export" %}
+                        {% endif %}
+                    {% endif %}
 				</div>
 			{% endif %}
 		</div>
diff --git a/NEMO/templates/users/create_or_modify_user.html b/NEMO/templates/users/create_or_modify_user.html
index 86070fde6..a99d227d7 100644
--- a/NEMO/templates/users/create_or_modify_user.html
+++ b/NEMO/templates/users/create_or_modify_user.html
@@ -183,7 +183,7 @@
 		</div>
 		<div class="form-group">
             {% endif %}
-			<div id="project-list" class="{% if not readonly %}col-sm-offset-2{% endif %} col-sm-4" {% if readonly %}style="padding-top: 7px"{% endif %}>
+			<div id="project-list" class="{% if not readonly %}col-sm-offset-2{% endif %} col-sm-8" {% if readonly %}style="padding-top: 7px"{% endif %}>
 				This user has no assigned projects.
 			</div>
 		</div>
@@ -195,7 +195,7 @@
 		</div>
 		<div class="form-group">
             {% endif %}
-			<div id="qualification-list" class="{% if not readonly %}col-sm-offset-2{% endif %} col-sm-4" {% if readonly %}style="padding-top: 7px"{% endif %}>
+			<div id="qualification-list" class="{% if not readonly %}col-sm-offset-2{% endif %} col-sm-8" {% if readonly %}style="padding-top: 7px"{% endif %}>
 				This user is not qualified to use any tools.
 			</div>
 		</div>
diff --git a/NEMO/templates/users/preferences.html b/NEMO/templates/users/preferences.html
index 33c635dc4..c794cdfec 100644
--- a/NEMO/templates/users/preferences.html
+++ b/NEMO/templates/users/preferences.html
@@ -12,6 +12,8 @@ <h3 id="reservations_label">Reservations</h3>
 						<div class="checkbox">
 							<label><input name="attach_created_reservation" type="checkbox" {% if user_preferences.attach_created_reservation %}checked{% endif %}>Check this box to receive ICS calendar invitation when creating reservation</label>
 							<br><label><input name="attach_cancelled_reservation" type="checkbox" {% if user_preferences.attach_cancelled_reservation %}checked{% endif %}>Check this box to receive ICS calendar invitation when cancelling a reservation</label>
+                            <br><label><input name="create_reservation_confirmation_override" type="checkbox" {% if user_preferences.create_reservation_confirmation_override %}checked{% endif %}>Check this box to {% if "calendar"|customization:"create_reservation_confirmation" == "enabled" %}not be asked to confirm before making a reservation{% else %}be asked to confirm before making a reservation{% endif %}</label>
+                            <br><label><input name="change_reservation_confirmation_override" type="checkbox" {% if user_preferences.change_reservation_confirmation_override %}checked{% endif %}>Check this box to {% if "calendar"|customization:"change_reservation_confirmation" == "enabled" %}not be asked to confirm before moving or resizing a reservation{% else %}be asked to confirm before moving or resizing a reservation{% endif %}</label>
 						</div>
 					</div>
 				</div>
@@ -140,6 +142,9 @@ <h3 id="email_notifications_label">Email notifications</h3>
                                 {{ form.email_alternate.errors|striptags }}
                             </div>
 			            {% endif %}
+                        <div class="col-sm-offset-3 col-sm-9">
+                            Main email: {{ user.email }}
+                        </div>
                     </div>
                     {% for field_name, field in form.fields.items %}
                         {% if field|class_name == "TypedChoiceField" and field_name != "staff_status_view" and field_name != "email_send_recurring_charges_reminder_emails" %}
diff --git a/NEMO/templates/users/users.html b/NEMO/templates/users/users.html
index 0a65b2c2e..bb333e583 100644
--- a/NEMO/templates/users/users.html
+++ b/NEMO/templates/users/users.html
@@ -21,6 +21,7 @@
 					<th>{% include 'pagination/pagination_column.html' with order_by='username' name='Username' %}</th>
 					{% if user_types %}<th>{% include 'pagination/pagination_column.html' with order_by='type' name='Type' %}</th>{% endif %}
 					<th>{% include 'pagination/pagination_column.html' with order_by='is_active' name='Active' %}</th>
+					<th>{% include 'pagination/pagination_column.html' with order_by='access_expiration' name='Expiration' %}</th>
 					<th>{% include 'pagination/pagination_column.html' with order_by='is_staff' name='Staff' %}</th>
 					<th>{% include 'pagination/pagination_column.html' with order_by='is_user_office' name='User Office' %}</th>
 					<th>{% include 'pagination/pagination_column.html' with order_by='is_accounting_officer' name='Accounting' %}</th>
@@ -29,17 +30,19 @@
 				</tr>
 			</thead>
 			<tbody>
+                {% now "Y-m-d" as today %}
 				{% for user in page %}
 					<tr>
 						<td>{{ user.first_name }}</td>
 						<td>{{ user.last_name }}</td>
 						<td>{{ user.username }}</td>
 						{% if user_types %}<td>{{ user.type|default_if_none:"" }}</td>{% endif %}
-						<td><span class="glyphicon {% if user.is_active %}glyphicon-ok success-highlight{% else %}glyphicon-remove danger-highlight{% endif %}"></span></td>
-						<td><span class="glyphicon {% if user.is_staff %}glyphicon-ok success-highlight{% endif %}"></span></td>
-						<td><span class="glyphicon {% if user.is_user_office %}glyphicon-ok success-highlight{% endif %}"></span></td>
-						<td><span class="glyphicon {% if user.is_accounting_officer %}glyphicon-ok success-highlight{% endif %}"></span></td>
-						<td><span class="glyphicon {% if user.is_superuser %}glyphicon-ok success-highlight{% endif %}"></span></td>
+						<td class="text-center"><span class="glyphicon {% if user.is_active %}glyphicon-ok success-highlight{% else %}glyphicon-remove danger-highlight{% endif %}"></span></td>
+						<td class="text-center"><span class="{% if user.access_expiration and user.access_expiration|date:"Y-m-d" < today %}danger-highlight{% endif %}">{{ user.access_expiration|date:"SHORT_DATE_FORMAT" }}</span></td>
+						<td class="text-center"><span class="glyphicon {% if user.is_staff %}glyphicon-ok success-highlight{% endif %}"></span></td>
+						<td class="text-center"><span class="glyphicon {% if user.is_user_office %}glyphicon-ok success-highlight{% endif %}"></span></td>
+						<td class="text-center"><span class="glyphicon {% if user.is_accounting_officer %}glyphicon-ok success-highlight{% endif %}"></span></td>
+						<td class="text-center"><span class="glyphicon {% if user.is_superuser %}glyphicon-ok success-highlight{% endif %}"></span></td>
 						<td class="text-center button-column-minimum">
                             {% url 'create_or_modify_user' user.id as edit_url %}
                             {% if not readonly %}
diff --git a/NEMO/urls.py b/NEMO/urls.py
index 77f5f15cd..65a68542c 100644
--- a/NEMO/urls.py
+++ b/NEMO/urls.py
@@ -41,6 +41,7 @@
     get_projects,
     history,
     jumbotron,
+    knowledge_base,
     landing,
     maintenance,
     mobile,
@@ -419,6 +420,36 @@ def sort_urls(url_path):
     path("safety/safety_data_sheets/export/", safety.export_safety_data_sheets, name="export_safety_data_sheets"),
     # For backwards compatibility
     path("safety_data_sheets/", RedirectView.as_view(pattern_name="safety_data_sheets", permanent=True)),
+    # Knowledge base
+    path("knowledge_base/", knowledge_base.knowledge_base, name="knowledge_base"),
+    re_path(
+        r"^knowledge_base/(?P<kind>user|staff)/",
+        include(
+            [
+                path("", knowledge_base.knowledge_base, name="knowledge_base"),
+                path("items/<int:item_id>/", knowledge_base.knowledge_base_item, name="knowledge_base_item"),
+                path("items/search/", knowledge_base.knowledge_base_items_search, name="knowledge_base_items_search"),
+                path(
+                    "items/categories/",
+                    include(
+                        [
+                            path("", knowledge_base.knowledge_base_categories, name="knowledge_base_categories"),
+                            path(
+                                "<int:category_id>/",
+                                knowledge_base.knowledge_base_categories,
+                                name="knowledge_base_categories",
+                            ),
+                            path(
+                                "all_in_one/",
+                                knowledge_base.knowledge_base_all_in_one,
+                                name="knowledge_base_all_in_one",
+                            ),
+                        ]
+                    ),
+                ),
+            ]
+        ),
+    ),
     # Mobile:
     re_path(r"^choose_item/then/(?P<next_page>view_calendar|tool_control)/$", mobile.choose_item, name="choose_item"),
     re_path(
diff --git a/NEMO/utilities.py b/NEMO/utilities.py
index b8e71749e..4a11f3fed 100644
--- a/NEMO/utilities.py
+++ b/NEMO/utilities.py
@@ -697,7 +697,16 @@ def get_class_from_settings(setting_name: str, default_value: str):
     return ret()
 
 
-def create_ics(identifier, event_name, start: datetime, end: datetime, user, organizer=None, cancelled: bool = False):
+def create_ics(
+    identifier,
+    event_name,
+    start: datetime,
+    end: datetime,
+    user,
+    organizer=None,
+    cancelled: bool = False,
+    description: str = None,
+) -> MIMEBase:
     from NEMO.views.customization import ApplicationCustomization
 
     site_title = ApplicationCustomization.get("site_title")
@@ -727,6 +736,7 @@ def create_ics(identifier, event_name, start: datetime, end: datetime, user, org
         f'ATTENDEE;CN="{user.get_name()}";RSVP=TRUE:mailto:{user.email}\n',
         f'ORGANIZER;CN="{organizer}":mailto:{organizer_email}\n',
         f"SUMMARY:[{site_title}] {event_name}\n",
+        f"DESCRIPTION:{description or ''}\n",
         f"STATUS:{'CANCELLED' if cancelled else 'CONFIRMED'}\n",
         "END:VEVENT\n",
         "END:VCALENDAR\n",
diff --git a/NEMO/views/api.py b/NEMO/views/api.py
index 934bdbd51..1a70902ef 100644
--- a/NEMO/views/api.py
+++ b/NEMO/views/api.py
@@ -206,6 +206,7 @@ class ToolViewSet(ModelViewSet):
         "_location": ["exact", "iexact"],
         "_requires_area_access": ["exact", "isnull"],
         "_post_usage_questions": ["isempty"],
+        "_pre_usage_questions": ["isempty"],
     }
 
 
@@ -246,9 +247,9 @@ class ResourceViewSet(ModelViewSet):
         "id": ["exact", "in"],
         "name": ["exact", "iexact"],
         "available": ["exact"],
-        "fully_dependent_tools": ["in"],
-        "partially_dependent_tools": ["in"],
-        "dependent_areas": ["in"],
+        "fully_dependent_tools": ["exact"],
+        "partially_dependent_tools": ["exact"],
+        "dependent_areas": ["exact"],
     }
 
 
@@ -263,7 +264,7 @@ class ConfigurationViewSet(ModelViewSet):
         "tool": ["exact", "in", "isnull"],
         "advance_notice_limit": ["exact", "in", "gte", "gt", "lte", "lt"],
         "display_order": ["exact", "in", "gte", "gt", "lte", "lt"],
-        "maintainers": ["exact", "in", "isnull"],
+        "maintainers": ["exact", "isnull"],
         "qualified_users_are_maintainers": ["exact"],
         "exclude_from_configuration_agenda": ["exact"],
         "enabled": ["exact"],
@@ -306,7 +307,7 @@ class ReservationViewSet(ModelViewSet):
         "missed": ["exact"],
         "validated": ["exact"],
         "validated_by": ["exact", "in", "isnull"],
-        "question_data": ["isempty"],
+        "question_data": ["isnull", "isempty"],
     }
 
 
@@ -592,7 +593,7 @@ def list(self, request, *args, **kwargs):
         return Response(serializer.data)
 
     def check_permissions(self, request):
-        if not request or not request.user.has_perm("NEMO.use_billing_api"):
+        if not request or not request.user.has_perm("NEMO.view_tool"):
             self.permission_denied(request)
 
     def get_queryset(self) -> QuerySetType[Tool]:
diff --git a/NEMO/views/authentication.py b/NEMO/views/authentication.py
index 5e88bc3c4..0d7e0d305 100644
--- a/NEMO/views/authentication.py
+++ b/NEMO/views/authentication.py
@@ -293,7 +293,7 @@ def impersonate(request):
         del request.session["impersonate_id"]
         del request.session["impersonated_user"]
         return redirect(reverse("landing"))
-    if not request.user.is_superuser:
+    if not request.user.has_perm("NEMO.can_impersonate_users"):
         return HttpResponseForbidden()
     if request.method == "POST":
         user_id = request.POST["user_id"]
diff --git a/NEMO/views/buddy_requests.py b/NEMO/views/buddy_requests.py
index 9cf9dfe46..f4ac539f7 100644
--- a/NEMO/views/buddy_requests.py
+++ b/NEMO/views/buddy_requests.py
@@ -82,7 +82,7 @@ def create_buddy_request(request, request_id=None):
 
 
 @login_required
-@require_GET
+@require_POST
 def delete_buddy_request(request, request_id):
     buddy_request = get_object_or_404(BuddyRequest, id=request_id)
 
diff --git a/NEMO/views/calendar.py b/NEMO/views/calendar.py
index 58fb97163..7359f6b6d 100644
--- a/NEMO/views/calendar.py
+++ b/NEMO/views/calendar.py
@@ -100,6 +100,20 @@ def calendar(request, item_type=None, item_id=None):
     calendar_all_areastools = CalendarCustomization.get("calendar_all_areastools")
     calendar_qualified_tools = CalendarCustomization.get("calendar_qualified_tools")
 
+    # Create reservation confirmation setting
+    create_reservation_confirmation_default = CalendarCustomization.get_bool("create_reservation_confirmation")
+    create_reservation_confirmation_override = user.get_preferences().create_reservation_confirmation_override
+    create_reservation_confirmation = create_reservation_confirmation_default ^ create_reservation_confirmation_override
+
+    # Change reservation confirmation setting
+    change_reservation_confirmation_default = CalendarCustomization.get_bool("change_reservation_confirmation")
+    change_reservation_confirmation_override = user.get_preferences().change_reservation_confirmation_override
+    change_reservation_confirmation = change_reservation_confirmation_default ^ change_reservation_confirmation_override
+
+    # Reservation confirmation date and time settings
+    reservation_confirmation_date_format = CalendarCustomization.get("reservation_confirmation_date_format")
+    reservation_confirmation_time_format = CalendarCustomization.get("reservation_confirmation_time_format")
+
     dictionary = {
         "rendered_item_tree_html": rendered_item_tree_html,
         "tools": list(tools),
@@ -118,6 +132,10 @@ def calendar(request, item_type=None, item_id=None):
         "calendar_all_areas": calendar_all_areas,
         "calendar_all_areastools": calendar_all_areastools,
         "calendar_qualified_tools": calendar_qualified_tools,
+        "create_reservation_confirmation": create_reservation_confirmation,
+        "change_reservation_confirmation": change_reservation_confirmation,
+        "reservation_confirmation_date_format": reservation_confirmation_date_format,
+        "reservation_confirmation_time_format": reservation_confirmation_time_format,
         "self_login": False,
         "self_logout": False,
     }
diff --git a/NEMO/views/consumables.py b/NEMO/views/consumables.py
index 2fbd457cb..1bafb61b8 100644
--- a/NEMO/views/consumables.py
+++ b/NEMO/views/consumables.py
@@ -4,6 +4,7 @@
 
 from django.contrib import messages
 from django.contrib.auth.decorators import login_required, user_passes_test
+from django.db.models import Q
 from django.http import HttpResponseBadRequest
 from django.shortcuts import get_object_or_404, redirect, render
 from django.views.decorators.http import require_GET, require_POST, require_http_methods
@@ -56,7 +57,9 @@ def consumables(request):
         rate_dict = rate_class.get_consumable_rates(Consumable.objects.all())
         consumable_list = Consumable.objects.filter(visible=True).order_by("category", "name")
         if is_self_checkout:
-            consumable_list = consumable_list.filter(allow_self_checkout=True)
+            consumable_list = consumable_list.filter(allow_self_checkout=True).filter(
+                Q(self_checkout_only_users__isnull=True) | Q(self_checkout_only_users__in=[user])
+            )
 
         dictionary = {
             "users": User.objects.filter(is_active=True),
@@ -65,7 +68,7 @@ def consumables(request):
             "self_checkout": is_self_checkout,
         }
         if is_self_checkout:
-            dictionary["projects"] = user.active_projects()
+            dictionary["projects"] = user.active_projects().filter(allow_consumable_withdrawals=True)
         return render(request, "consumables/consumables.html", dictionary)
     elif request.method == "POST":
         updated_post_data = request.POST.copy()
@@ -74,7 +77,11 @@ def consumables(request):
         form = ConsumableWithdrawForm(updated_post_data)
         if form.is_valid():
             withdraw = form.save(commit=False)
-            if is_self_checkout and not withdraw.consumable.allow_self_checkout:
+            customer_allowed = (
+                not withdraw.consumable.self_checkout_only_users.exists()
+                or withdraw.customer in withdraw.consumable.self_checkout_only_users.all()
+            )
+            if is_self_checkout and (not withdraw.consumable.allow_self_checkout or not customer_allowed):
                 return HttpResponseBadRequest("You can not self checkout this consumable")
             try:
                 policy.check_billing_to_project(withdraw.project, withdraw.customer, withdraw.consumable, withdraw)
diff --git a/NEMO/views/customization.py b/NEMO/views/customization.py
index 68bb7ac38..d66598748 100644
--- a/NEMO/views/customization.py
+++ b/NEMO/views/customization.py
@@ -20,7 +20,16 @@
 from NEMO import init_admin_site
 from NEMO.decorators import administrator_required, customization
 from NEMO.exceptions import InvalidCustomizationException
-from NEMO.models import BadgeReader, ConsumableCategory, Customization, Notification, Project, RecurringConsumableCharge
+from NEMO.models import (
+    BadgeReader,
+    ConsumableCategory,
+    Customization,
+    Notification,
+    Project,
+    RecurringConsumableCharge,
+    TrainingSession,
+    UserPreferences,
+)
 from NEMO.utilities import RecurrenceFrequency, date_input_format, datetime_input_format, quiet_int
 
 
@@ -165,6 +174,7 @@ class ApplicationCustomization(CustomizationBase):
         "self_log_out": "",
         "calendar_login_logout": "",
         "area_logout_already_logged_in": "",
+        "show_badge_number": "",
         "default_badge_reader_id": "",
         "consumable_user_self_checkout": "",
         "consumable_category_collapse": "",
@@ -259,8 +269,27 @@ class CalendarCustomization(CustomizationBase):
         "calendar_outage_recurrence_limit": "90",
         "calendar_qualified_tools": "",
         "calendar_configuration_in_reservations": "",
+        "create_reservation_confirmation": "",
+        "change_reservation_confirmation": "",
+        "reservation_confirmation_date_format": "MMMM D, yyyy",
+        "reservation_confirmation_time_format": "h:mma",
     }
 
+    @classmethod
+    def set(cls, name: str, value):
+        if name == "create_reservation_confirmation" or name == "change_reservation_confirmation":
+            value_changed = value != cls.get(name)
+            if value_changed:
+                if name == "create_reservation_confirmation":
+                    UserPreferences.objects.filter(create_reservation_confirmation_override=True).update(
+                        create_reservation_confirmation_override=False
+                    )
+                elif name == "change_reservation_confirmation":
+                    UserPreferences.objects.filter(change_reservation_confirmation_override=True).update(
+                        change_reservation_confirmation_override=False
+                    )
+        super().set(name, value)
+
 
 @customization(key="dashboard", title="Status dashboard")
 class StatusDashboardCustomization(CustomizationBase):
@@ -413,6 +442,8 @@ class ToolCustomization(CustomizationBase):
         "tool_control_configuration_setting_template": "{{ current_setting }}",
         "tool_control_broadcast_upcoming_reservation": "",
         "tool_control_show_task_details": "",
+        "tool_control_show_qualified_users_to_all": "",
+        "tool_control_show_documents_only_qualified_users": "",
         "tool_qualification_reminder_days": "",
         "tool_qualification_expiration_days": "",
         "tool_qualification_expiration_never_used_days": "",
@@ -460,6 +491,14 @@ class SafetyCustomization(CustomizationBase):
     }
 
 
+@customization(key="knowledge_base", title="Knowledge base")
+class KnowledgeBaseCustomization(CustomizationBase):
+    variables = {
+        "knowledge_base_user_expand_categories": "",
+        "knowledge_base_staff_expand_categories": "",
+    }
+
+
 @customization(key="remote_work", title="Remote work")
 class RemoteWorkCustomization(CustomizationBase):
     variables = {
@@ -469,6 +508,23 @@ class RemoteWorkCustomization(CustomizationBase):
     }
 
 
+@customization(key="training", title="Training")
+class TrainingCustomization(CustomizationBase):
+    variables = {"training_only_type": "", "training_allow_date": ""}
+
+    def context(self) -> Dict:
+        dictionary = super().context()
+        dictionary["training_types"] = TrainingSession.Type.Choices
+        return dictionary
+
+    def save(self, request, element=None) -> Dict[str, Dict[str, str]]:
+        errors = super().save(request, element)
+        training_types = request.POST.getlist("training_type_list", [])
+        if training_types and len(training_types) == 1:
+            type(self).set("training_only_type", training_types[0])
+        return errors
+
+
 @customization(key="templates", title="File & email templates")
 class TemplatesCustomization(CustomizationBase):
     files = [
diff --git a/NEMO/views/email.py b/NEMO/views/email.py
index cbbf81100..5661d26cb 100644
--- a/NEMO/views/email.py
+++ b/NEMO/views/email.py
@@ -339,10 +339,10 @@ def get_users_for_email(audience: str, selection: List, no_type: bool) -> (Query
 
 def check_user_allowed(user: User, audience: str, selection: str) -> Optional[str]:
     if not user.is_any_part_of_staff:
-        allow_broadcast_upcoming_reservation = ToolCustomization.get_bool("tool_control_broadcast_upcoming_reservation")
+        allow_broadcast_upcoming_reservation = ToolCustomization.get("tool_control_broadcast_upcoming_reservation")
         if not allow_broadcast_upcoming_reservation or audience != "tool-reservation":
             return "You may not broadcast email to this audience"
         else:
             tool = Tool.objects.filter(id__in=selection).first()
-            if not tool or user not in tool.user_set.all():
+            if not tool or (allow_broadcast_upcoming_reservation == "qualified" and user not in tool.user_set.all()):
                 return "You can only send a broadcast email to users of a tool you are qualified to use"
diff --git a/NEMO/views/get_projects.py b/NEMO/views/get_projects.py
index 65b99accc..355d543de 100644
--- a/NEMO/views/get_projects.py
+++ b/NEMO/views/get_projects.py
@@ -1,4 +1,5 @@
 from django.contrib.auth.decorators import login_required
+from django.db.models import Q
 from django.http import JsonResponse
 from django.shortcuts import get_object_or_404, render
 from django.views.decorators.http import require_GET
@@ -16,13 +17,14 @@ def get_projects_for_training(request):
 @any_staff_required
 @require_GET
 def get_projects_for_consumables(request):
-    return get_projects(request)
+    # Only return project for which consumable withdrawals are allowed
+    return get_projects(request, Q(allow_consumable_withdrawals=True))
 
 
-def get_projects(request):
+def get_projects(request, project_filter=Q()):
     """Gets a list of all active projects for a specific user. This is only accessible by staff members."""
     user = get_object_or_404(User, id=request.GET.get("user_id", None))
-    projects = user.active_projects()
+    projects = user.active_projects().filter(project_filter)
     source_template = request.GET.get("source_template")
     if source_template == "training":
         entry_number = int(request.GET["entry_number"])
diff --git a/NEMO/views/knowledge_base.py b/NEMO/views/knowledge_base.py
new file mode 100644
index 000000000..87a3b2165
--- /dev/null
+++ b/NEMO/views/knowledge_base.py
@@ -0,0 +1,100 @@
+from django.contrib.auth.decorators import login_required
+from django.shortcuts import redirect, render
+from django.urls import reverse
+from django.views.decorators.http import require_GET
+
+from NEMO.models import (
+    StaffKnowledgeBaseCategory,
+    StaffKnowledgeBaseItem,
+    User,
+    UserKnowledgeBaseCategory,
+    UserKnowledgeBaseItem,
+)
+from NEMO.utilities import distinct_qs_value_list, queryset_search_filter
+from NEMO.views.customization import KnowledgeBaseCustomization
+
+
+@login_required
+@require_GET
+def knowledge_base(request, kind="user"):
+    return (
+        redirect(f"knowledge_base_categories", kind=kind)
+        if not KnowledgeBaseCustomization.get_bool(f"knowledge_base_{kind}_expand_categories")
+        else redirect(f"knowledge_base_all_in_one", kind=kind)
+    )
+
+
+@login_required
+@require_GET
+def knowledge_base_all_in_one(request, kind="user"):
+    user: User = request.user
+    if kind == "staff" and not user.is_any_part_of_staff:
+        return redirect("landing")
+    item_model = StaffKnowledgeBaseItem if kind == "staff" else UserKnowledgeBaseItem
+    category_model = StaffKnowledgeBaseCategory if kind == "staff" else UserKnowledgeBaseCategory
+    dictionary = {
+        "kind": kind,
+        "categories": category_model.objects.filter(
+            id__in=distinct_qs_value_list(item_model.objects.all(), "category_id")
+        ),
+        "general": item_model.objects.filter(category__isnull=True),
+        "expand_categories": True,
+    }
+    return render(request, "knowledge_base/knowledge_base.html", dictionary)
+
+
+@login_required
+@require_GET
+def knowledge_base_categories(request, kind="user", category_id=None):
+    user: User = request.user
+    if kind == "staff" and not user.is_any_part_of_staff:
+        return redirect("landing")
+    item_model = StaffKnowledgeBaseItem if kind == "staff" else UserKnowledgeBaseItem
+    category_model = StaffKnowledgeBaseCategory if kind == "staff" else UserKnowledgeBaseCategory
+    try:
+        item_id = request.GET.get("item_id")
+        if item_id:
+            category_id = item_model.objects.get(pk=item_id).category_id
+        category_model.objects.get(pk=category_id)
+    except category_model.DoesNotExist:
+        pass
+    items_qs = item_model.objects.filter(category_id=category_id)
+    if not category_id and not items_qs.exists():
+        first_category = category_model.objects.first()
+        category_id = first_category.id if first_category else None
+    dictionary = {
+        "kind": kind,
+        "category_id": category_id,
+        "items": item_model.objects.filter(category_id=category_id),
+        "categories": category_model.objects.filter(
+            id__in=distinct_qs_value_list(item_model.objects.all(), "category_id")
+        ),
+        "general": item_model.objects.filter(category_id__isnull=True).exists(),
+    }
+    return render(request, "knowledge_base/knowledge_base.html", dictionary)
+
+
+@login_required
+@require_GET
+def knowledge_base_item(request, item_id: int, kind="user"):
+    # Redirect to the appropriate URL with hashtag included to scroll to the item
+    user: User = request.user
+    if kind == "staff" and not user.is_any_part_of_staff:
+        return redirect("landing")
+    url_params = f"?item_id={item_id}#item_{item_id}"
+    redirect_url = (
+        reverse(f"knowledge_base_categories", kwargs={"kind": kind})
+        if not KnowledgeBaseCustomization.get_bool(f"knowledge_base_{kind}_expand_categories")
+        else reverse(f"knowledge_base_all_in_one", kwargs={"kind": kind})
+    )
+    return redirect(redirect_url + url_params)
+
+
+@login_required
+@require_GET
+def knowledge_base_items_search(request, kind="user"):
+    user: User = request.user
+    if kind == "staff" and not user.is_any_part_of_staff:
+        return redirect("landing")
+    item_model = StaffKnowledgeBaseItem if kind == "staff" else UserKnowledgeBaseItem
+    return queryset_search_filter(item_model.objects.all(), ["name", "description"], request)
diff --git a/NEMO/views/notifications.py b/NEMO/views/notifications.py
index fb27f25b2..8b38cd761 100644
--- a/NEMO/views/notifications.py
+++ b/NEMO/views/notifications.py
@@ -2,6 +2,7 @@
 from typing import Iterable, List, Set
 
 from django.contrib.contenttypes.models import ContentType
+from django.db.models import Count
 from django.utils import timezone
 
 from NEMO.models import (
@@ -31,11 +32,9 @@ def get_notifications(user: User, notification_type: str, delete=True):
 
 
 def get_notification_counts(user: User):
-    counts = {}
-    for t in Notification.Types.Choices:
-        notification_type = t[0]
-        counts[notification_type] = Notification.objects.filter(user=user, notification_type=notification_type).count()
-    return counts
+    notifications = Notification.objects.filter(user=user)
+    counts = notifications.values("notification_type").annotate(total=Count("notification_type"))
+    return {item["notification_type"]: item["total"] for item in counts}
 
 
 def delete_notification(notification_type: str, instance_id, users: Iterable[User] = None):
diff --git a/NEMO/views/safety.py b/NEMO/views/safety.py
index 33f33f475..577c4e42c 100644
--- a/NEMO/views/safety.py
+++ b/NEMO/views/safety.py
@@ -56,7 +56,7 @@ def safety_categories(request, category_id=None):
     try:
         safety_item_id = request.GET.get("safety_item_id")
         if safety_item_id:
-            category_id = SafetyItem.objects.get(pk=safety_item_id).category.id
+            category_id = SafetyItem.objects.get(pk=safety_item_id).category_id
         SafetyCategory.objects.get(pk=category_id)
     except SafetyCategory.DoesNotExist:
         pass
diff --git a/NEMO/views/tool_control.py b/NEMO/views/tool_control.py
index fd02018db..458810832 100644
--- a/NEMO/views/tool_control.py
+++ b/NEMO/views/tool_control.py
@@ -16,7 +16,7 @@
 from django.views.decorators.http import require_GET, require_POST
 
 from NEMO.decorators import staff_member_required, synchronized
-from NEMO.exceptions import RequiredUnansweredQuestionsException
+from NEMO.exceptions import ProjectChargeException, RequiredUnansweredQuestionsException
 from NEMO.forms import CommentForm, nice_errors
 from NEMO.models import (
     AreaAccessRecord,
@@ -47,6 +47,7 @@
     render_email_template,
     send_mail,
 )
+from NEMO.views.area_access import able_to_self_log_out_of_area
 from NEMO.views.calendar import shorten_reservation
 from NEMO.views.customization import (
     ApplicationCustomization,
@@ -90,23 +91,27 @@ def tool_status(request, tool_id):
     """Gets the current status of the tool (that is, whether it is currently in use or not)."""
     from NEMO.rates import rate_class
 
+    user: User = request.user
     tool = get_object_or_404(Tool, id=tool_id, visible=True)
-    user_is_qualified = tool.user_set.filter(id=request.user.id).exists()
-    user_is_staff = request.user.is_staff
-    tool_control_broadcast_upcoming_reservation_enabled = ToolCustomization.get_bool(
-        "tool_control_broadcast_upcoming_reservation"
-    )
+    user_is_qualified = tool.user_set.filter(id=user.id).exists()
+    broadcast_upcoming_reservation = ToolCustomization.get("tool_control_broadcast_upcoming_reservation")
     dictionary = {
         "tool": tool,
-        "tool_rate": rate_class.get_tool_rate(tool, request.user),
+        "tool_rate": rate_class.get_tool_rate(tool, user),
         "task_categories": TaskCategory.objects.filter(stage=TaskCategory.Stage.INITIAL_ASSESSMENT),
-        "rendered_configuration_html": tool.configuration_widget(request.user),
+        "rendered_configuration_html": tool.configuration_widget(user),
         "mobile": request.device == "mobile",
         "task_statuses": TaskStatus.objects.all(),
+        "pre_usage_questions": DynamicForm(tool.pre_usage_questions).render("tool_usage_group_question", tool_id),
         "post_usage_questions": DynamicForm(tool.post_usage_questions).render("tool_usage_group_question", tool_id),
-        "show_broadcast_upcoming_reservation": user_is_staff
-        or (user_is_qualified and tool_control_broadcast_upcoming_reservation_enabled),
+        "show_broadcast_upcoming_reservation": user.is_any_part_of_staff
+        or (user_is_qualified and broadcast_upcoming_reservation == "qualified")
+        or broadcast_upcoming_reservation == "all",
         "tool_control_show_task_details": ToolCustomization.get_bool("tool_control_show_task_details"),
+        "has_usage_questions": True if tool.pre_usage_questions or tool.post_usage_questions else False,
+        "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,
     }
 
     try:
@@ -116,16 +121,16 @@ def tool_status(request, tool_id):
             cancelled=False,
             missed=False,
             shortened=False,
-            user=request.user,
+            user=user,
             tool=tool,
         )
-        if request.user == current_reservation.user:
+        if user == current_reservation.user:
             dictionary["time_left"] = current_reservation.end
     except Reservation.DoesNotExist:
         pass
 
     # Staff need the user list to be able to qualify users for the tool.
-    if request.user.is_staff:
+    if user.is_staff:
         dictionary["users"] = User.objects.filter(is_active=True)
 
     return render(request, "tool_control/tool_status.html", dictionary)
@@ -166,15 +171,17 @@ def tool_config_history(request, tool_id):
 @require_POST
 def usage_data_history(request, tool_id):
     """This method return a dictionary of headers and rows containing run_data information for Usage Events"""
-    csv_export = bool(request.POST.get("csv", False))
+    csv_export = request.POST.get("csv")
     start, end = extract_optional_beginning_and_end_times(request.POST)
     last = request.POST.get("data_history_last")
     user_id = request.POST.get("data_history_user_id")
     show_project_info = request.POST.get("show_project_info")
+
     if not last and not start and not end:
         # Default to last 25 records
         last = 25
-    usage_events = UsageEvent.objects.filter(tool_id=tool_id, end__isnull=False).order_by("-end")
+    usage_events = UsageEvent.objects.filter(tool_id=tool_id)
+
     if start:
         usage_events = usage_events.filter(end__gte=start)
     if end:
@@ -184,59 +191,42 @@ def usage_data_history(request, tool_id):
             usage_events = usage_events.filter(user_id=int(user_id))
         except ValueError:
             pass
+
+    pre_usage_events = usage_events.order_by("-start")
+    post_usage_events = usage_events.filter(end__isnull=False).order_by("-end")
     if last:
         try:
             last = int(last)
         except ValueError:
             last = 25
-        usage_events = usage_events[:last]
-    table_result = BasicDisplayTable()
-    table_result.add_header(("user", "User"))
+        pre_usage_events = pre_usage_events[:last]
+        post_usage_events = post_usage_events[:last]
+
+    table_pre_run_data = BasicDisplayTable()
+    table_pre_run_data.add_header(("user", "User"))
     if show_project_info:
-        table_result.add_header(("project", "Project"))
-    table_result.add_header(("date", "Date"))
-    for usage_event in usage_events:
+        table_pre_run_data.add_header(("project", "Project"))
+    table_pre_run_data.add_header(("date", "Date"))
+
+    table_run_data = BasicDisplayTable()
+    table_run_data.add_header(("user", "User"))
+    if show_project_info:
+        table_run_data.add_header(("project", "Project"))
+    table_run_data.add_header(("date", "Date"))
+
+    for usage_event in pre_usage_events:
+        if usage_event.pre_run_data:
+            format_usage_data(
+                table_pre_run_data, usage_event, usage_event.pre_run_data, usage_event.start, show_project_info
+            )
+
+    for usage_event in post_usage_events:
         if usage_event.run_data:
-            usage_data = {}
-            try:
-                user_data = f"{usage_event.user.first_name} {usage_event.user.last_name}"
-                date_data = format_datetime(usage_event.end, "SHORT_DATETIME_FORMAT")
-                run_data: Dict = loads(usage_event.run_data)
-                for question_key, question in run_data.items():
-                    if "user_input" in question:
-                        if question["type"] == "group":
-                            for sub_question in question["questions"]:
-                                table_result.add_header((sub_question["name"], sub_question["title"]))
-                            for index, user_inputs in question["user_input"].items():
-                                if index == "0":
-                                    # Special case here the "initial" group of user inputs will go along with the rest of the non-group user inputs
-                                    for name, user_input in user_inputs.items():
-                                        usage_data[name] = user_input
-                                else:
-                                    # For the other groups of user inputs, we have to add a whole new row
-                                    group_usage_data = {}
-                                    for name, user_input in user_inputs.items():
-                                        group_usage_data[name] = user_input
-                                    if group_usage_data:
-                                        group_usage_data["user"] = user_data
-                                        group_usage_data["date"] = date_data
-                                        if show_project_info:
-                                            group_usage_data["project"] = usage_event.project.name
-                                        table_result.add_row(group_usage_data)
-                        else:
-                            table_result.add_header((question_key, question["title"]))
-                            usage_data[question_key] = question["user_input"]
-                if usage_data:
-                    usage_data["user"] = user_data
-                    usage_data["date"] = date_data
-                    if show_project_info:
-                        usage_data["project"] = usage_event.project.name
-                    table_result.add_row(usage_data)
-            except JSONDecodeError:
-                tool_control_logger.debug("error decoding run_data: " + usage_event.run_data)
+            format_usage_data(table_run_data, usage_event, usage_event.run_data, usage_event.end, show_project_info)
+
     if csv_export:
-        response = table_result.to_csv()
-        filename = f"tool_usage_data_export_{export_format_datetime()}.csv"
+        response = table_run_data.to_csv() if csv_export == "run" else table_pre_run_data.to_csv()
+        filename = f"tool{'' if csv_export == 'run' else '_pre'}_usage_data_export_{export_format_datetime()}.csv"
         response["Content-Disposition"] = f'attachment; filename="{filename}"'
         return response
     else:
@@ -245,10 +235,11 @@ def usage_data_history(request, tool_id):
             "data_history_start": start.date() if start else None,
             "data_history_end": end.date() if end else None,
             "data_history_last": str(last),
-            "usage_data_table": table_result,
+            "run_data_table": table_run_data,
+            "pre_run_data_table": table_pre_run_data,
             "data_history_user": User.objects.get(id=user_id) if user_id else None,
             "show_project_info": show_project_info or False,
-            "users": User.objects.filter(is_active=True),
+            "users": User.objects.all(),
         }
         return render(request, "tool_control/usage_data.html", dictionary)
 
@@ -357,6 +348,21 @@ def enable_tool(request, tool_id, user_id, project_id, staff_charge):
     if response.status_code != HTTPStatus.OK:
         return response
 
+    # Create a new usage event to track how long the user uses the tool.
+    new_usage_event = UsageEvent()
+    new_usage_event.operator = operator
+    new_usage_event.user = user
+    new_usage_event.project = project
+    new_usage_event.tool = tool
+
+    # Collect pre-usage questions and validate them
+    dynamic_form = DynamicForm(tool.pre_usage_questions)
+
+    try:
+        new_usage_event.pre_run_data = dynamic_form.extract(request)
+    except RequiredUnansweredQuestionsException as e:
+        return HttpResponseBadRequest(str(e))
+
     # All policy checks passed so enable the tool for the user.
     if tool.interlock and not tool.interlock.unlock():
         if bypass_interlock and interlock_bypass_allowed(user):
@@ -372,6 +378,11 @@ def enable_tool(request, tool_id, user_id, project_id, staff_charge):
         new_staff_charge.staff_member = request.user
         new_staff_charge.customer = user
         new_staff_charge.project = project
+        try:
+            # Check that staff charge is actually allowed
+            policy.check_billing_to_project(project, user, new_staff_charge, new_staff_charge)
+        except ProjectChargeException as e:
+            return HttpResponseBadRequest(e.msg)
         new_staff_charge.save()
         # If the tool requires area access, start charging area access time
         if tool.requires_area_access and RemoteWorkCustomization.get_bool(
@@ -384,15 +395,16 @@ def enable_tool(request, tool_id, user_id, project_id, staff_charge):
             area_access.project = new_staff_charge.project
             area_access.save()
 
-    # Create a new usage event to track how long the user uses the tool.
-    new_usage_event = UsageEvent()
-    new_usage_event.operator = operator
-    new_usage_event.user = user
-    new_usage_event.project = project
-    new_usage_event.tool = tool
+    # Now we can safely save the usage event
     new_usage_event.remote_work = remote_work
     new_usage_event.save()
 
+    try:
+        dynamic_form.charge_for_consumables(new_usage_event, new_usage_event.pre_run_data, request)
+    except Exception as e:
+        return HttpResponseBadRequest(str(e))
+    dynamic_form.update_tool_counters(new_usage_event.pre_run_data, tool.id)
+
     return response
 
 
@@ -448,7 +460,7 @@ def disable_tool(request, tool_id):
             return HttpResponseBadRequest(str(e))
 
     try:
-        dynamic_form.charge_for_consumables(current_usage_event, request)
+        dynamic_form.charge_for_consumables(current_usage_event, current_usage_event.run_data, request)
     except Exception as e:
         return HttpResponseBadRequest(str(e))
     dynamic_form.update_tool_counters(current_usage_event.run_data, tool.id)
@@ -462,6 +474,10 @@ def disable_tool(request, tool_id):
         ):
             response = render(request, "staff_charges/reminder.html", {"tool": tool})
 
+    area_record = user.area_access_record()
+    if area_record and tool.ask_to_leave_area_when_done_using and able_to_self_log_out_of_area(user):
+        response = render(request, "tool_control/logout_user.html", {"area": area_record.area, "tool": tool})
+
     return response
 
 
@@ -665,3 +681,50 @@ def send_tool_usage_counter_email(counter: ToolUsageCounter):
             to=counter.warning_email,
             email_category=EmailCategory.SYSTEM,
         )
+
+
+def format_usage_data(
+    table_result: BasicDisplayTable,
+    usage_event: UsageEvent,
+    usage_run_data: str,
+    date_field: datetime,
+    show_project_info: str,
+):
+    usage_data = {}
+    date_data = format_datetime(date_field, "SHORT_DATETIME_FORMAT")
+
+    try:
+        user_data = f"{usage_event.user.first_name} {usage_event.user.last_name}"
+        run_data: Dict = loads(usage_run_data)
+        for question_key, question in run_data.items():
+            if "user_input" in question:
+                if question["type"] == "group":
+                    for sub_question in question["questions"]:
+                        table_result.add_header((sub_question["name"], sub_question["title"]))
+                    for index, user_inputs in question["user_input"].items():
+                        if index == "0":
+                            # Special case here the "initial" group of user inputs will go along with the rest of the non-group user inputs
+                            for name, user_input in user_inputs.items():
+                                usage_data[name] = user_input
+                        else:
+                            # For the other groups of user inputs, we have to add a whole new row
+                            group_usage_data = {}
+                            for name, user_input in user_inputs.items():
+                                group_usage_data[name] = user_input
+                            if group_usage_data:
+                                group_usage_data["user"] = user_data
+                                group_usage_data["date"] = date_data
+                                if show_project_info:
+                                    group_usage_data["project"] = usage_event.project.name
+                                table_result.add_row(group_usage_data)
+                else:
+                    table_result.add_header((question_key, question["title"]))
+                    usage_data[question_key] = question["user_input"]
+        if usage_data:
+            usage_data["user"] = user_data
+            usage_data["date"] = date_data
+            if show_project_info:
+                usage_data["project"] = usage_event.project.name
+            table_result.add_row(usage_data)
+    except JSONDecodeError:
+        tool_control_logger.debug("error decoding run_data: " + usage_run_data)
diff --git a/NEMO/views/training.py b/NEMO/views/training.py
index 997a6a980..43191f075 100644
--- a/NEMO/views/training.py
+++ b/NEMO/views/training.py
@@ -1,3 +1,4 @@
+import datetime
 from logging import getLogger
 from re import search
 from urllib.parse import urljoin
@@ -14,6 +15,8 @@
 from NEMO.exceptions import ProjectChargeException
 from NEMO.models import MembershipHistory, Project, Tool, ToolQualificationGroup, TrainingSession, User
 from NEMO.policy import policy_class as policy
+from NEMO.utilities import datetime_input_format
+from NEMO.views.customization import TrainingCustomization
 from NEMO.views.users import get_identity_service
 
 training_logger = getLogger(__name__)
@@ -33,6 +36,11 @@ def training(request):
         tool_groups = (
             tool_groups.annotate(num_tools=Count("tools")).filter(tools__in=tools).filter(num_tools=len(tools))
         )
+    training_types = TrainingSession.Type.Choices
+    training_only_type = TrainingCustomization.get_int("training_only_type")
+    if training_only_type is not None:
+        # only keep the one type
+        training_types = [training_type for training_type in training_types if training_type[0] == training_only_type]
     return render(
         request,
         "training/training.html",
@@ -40,7 +48,7 @@ def training(request):
             "users": users,
             "tools": list(tools),
             "tool_groups": list(tool_groups),
-            "charge_types": TrainingSession.Type.Choices,
+            "charge_types": training_types,
         },
     )
 
@@ -57,13 +65,17 @@ def training_entry(request):
 
 
 def is_valid_field(field):
-    return search("^(chosen_user|chosen_tool|chosen_project|duration|charge_type|qualify)__[0-9]+$", field) is not None
+    return (
+        search("^(chosen_user|chosen_tool|chosen_project|date|duration|charge_type|qualify)__[0-9]+$", field)
+        is not None
+    )
 
 
 @staff_member_or_tool_superuser_required
 @require_POST
 def charge_training(request):
     trainer: User = request.user
+    date_allowed = TrainingCustomization.get_bool("training_allow_date")
     try:
         charges = {}
         for key, value in request.POST.items():
@@ -94,6 +106,8 @@ def charge_training(request):
                     charges[index].project = Project.objects.get(id=to_int_or_negative(value))
                 if attribute == "duration":
                     charges[index].duration = int(value)
+                if value and attribute == "date" and date_allowed:
+                    charges[index].date = datetime.datetime.strptime(value, datetime_input_format).astimezone()
                 if attribute == "charge_type":
                     charges[index].type = int(value)
                 if attribute == "qualify":
diff --git a/NEMO/widgets/dynamic_form.py b/NEMO/widgets/dynamic_form.py
index b0b3a15b8..e75a7edd6 100644
--- a/NEMO/widgets/dynamic_form.py
+++ b/NEMO/widgets/dynamic_form.py
@@ -1,3 +1,7 @@
+from __future__ import annotations
+
+import random
+import re
 import sys
 from collections import Counter
 from copy import copy
@@ -6,12 +10,14 @@
 from logging import getLogger
 from typing import Any, Callable, Dict, List, Optional, Type
 
+from django.http import QueryDict
 from django.urls import NoReverseMatch, reverse
 from django.utils.safestring import mark_safe
 
+from NEMO.evaluators import evaluate_expression, get_expression_variables
 from NEMO.exceptions import RequiredUnansweredQuestionsException
 from NEMO.models import Consumable, ToolUsageCounter
-from NEMO.utilities import slugify_underscore
+from NEMO.utilities import EmptyHttpRequest, quiet_int, slugify_underscore
 from NEMO.views.consumables import make_withdrawal
 
 dynamic_form_logger = getLogger(__name__)
@@ -44,18 +50,21 @@ def __init__(self, properties: Dict, index: int = None):
         self.step = self._init_property("step")
         self.rows = self._init_property("rows")
         self.consumable = self._init_property("consumable")
+        self.consumable_id = self._init_property("consumable_id")
         self.required = self._init_property("required", True)
         # For backwards compatibility keep default choice
         self.default_value = self._init_property("default_value") or self._init_property("default_choice")
         self.choices = self._init_property("choices")
         self.labels = self._init_property("labels")
+        self.formula = self._init_property("formula")
         self.group_add_button_name = self._init_property("group_add_button_name") or "Add"
         self.index = index
         if index and not isinstance(self, PostUsageGroupQuestion):
             self.name = f"{self.name}_{index}"
         # form_name is used in forms and extraction to avoid potential conflicts with other data
-        self.form_name = f"df_{self.name}"
-        pass
+        self.form_name = f"df_{slugify_underscore(self.name)}"
+        self.all_questions: List[PostUsageQuestion] = []
+        self.is_sub_question: bool = False
 
     def _init_property(self, prop: str, boolean: bool = False) -> Any:
         if boolean:
@@ -63,6 +72,17 @@ def _init_property(self, prop: str, boolean: bool = False) -> Any:
         else:
             return self.properties[prop] if prop in self.properties else None
 
+    @property
+    def has_consumable(self):
+        return self.consumable_id or self.consumable
+
+    def get_consumable(self) -> Consumable:
+        return (
+            Consumable.objects.get(pk=self.consumable_id)
+            if self.consumable_id
+            else Consumable.objects.get(name=self.consumable)
+        )
+
     def validate(self):
         self.validate_property_exists("name")
         self.validate_property_exists("title")
@@ -93,6 +113,10 @@ def extract(self, request, index=None) -> Dict:
             answered_question["user_input"] = user_input
         return answered_question
 
+    def extract_for_formula(self, request, index=None) -> Any:
+        extracted = self.extract(request, index)
+        return extracted["user_input"] if "user_input" in extracted else None
+
     def validate_property_exists(self, prop: str):
         try:
             self.properties[prop]
@@ -290,6 +314,10 @@ def render_as_text(self) -> str:
             result += ")"
         return result
 
+    def extract_for_formula(self, request, index=None) -> Any:
+        value = super().extract_for_formula(request, index)
+        return int(value) if value is not None else None
+
 
 class PostUsageFloatFieldQuestion(PostUsageTextFieldQuestion):
     question_type = "Question of type float"
@@ -304,6 +332,107 @@ def render_script(self, virtual_inputs: bool, group_question_url: str, item_id:
             return f"<script>$('#{self.form_name}').numpad({{'readonly': false, 'hidePlusMinusButton': true, 'hideDecimalButton': false}});</script>"
         return super().render_script(virtual_inputs, group_question_url, item_id)
 
+    def extract_for_formula(self, request, index=None) -> Any:
+        value = super().extract_for_formula(request, index)
+        return float(value) if value is not None else None
+
+
+class PostUsageFormulaQuestion(PostUsageQuestion):
+    question_type = "Question of type formula"
+
+    @property
+    def all_questions_by_name(self):
+        return {question.name: question for question in self.all_questions}
+
+    @property
+    def all_questions_by_form_name(self):
+        return {question.form_name: question for question in self.all_questions}
+
+    def render_element(self, virtual_inputs: bool, group_question_url: str, group_item_id: int) -> str:
+        return f'<input type="hidden" id="{self.form_name}" name="{self.form_name}">'
+
+    def validate(self):
+        super().validate()
+        self.validate_property_exists("formula")
+        # Now validate variables
+        formula_variables = get_expression_variables(self.formula)
+        valid_question_names = {
+            q.form_name: q.name
+            for q in self.all_questions
+            if not isinstance(q, PostUsageGroupQuestion) and not q == self
+        }
+        diff = formula_variables.difference(set(valid_question_names.values()).union(valid_question_names.keys()))
+        if diff:
+            if len(diff) == 1:
+                raise Exception(
+                    f"formula variable {next(iter(diff))} is not a valid question name, valid choices are: {', '.join(valid_question_names.values())}"
+                )
+            else:
+                raise Exception(
+                    f"formula variables {list(diff)} are not valid question names, valid choices are: {', '.join(valid_question_names.values())}"
+                )
+        if self.has_consumable:
+            # The result of the formula must be an int, so let's add some random variables and check
+            formula_and_values = {}
+            for var in formula_variables:
+                if isinstance(self.all_questions_by_name[var], PostUsageNumberFieldQuestion):
+                    formula_and_values[var] = str(random.randint(1, 100))
+                elif isinstance(self.all_questions_by_name[var], PostUsageFloatFieldQuestion):
+                    formula_and_values[var] = str(random.uniform(1.0, 100.0))
+            http_request = EmptyHttpRequest()
+            http_request.POST = QueryDict(mutable=True)
+            http_request.POST.update(formula_and_values)
+            int(self.extract_for_formula(http_request))
+
+    def extract(self, request, index=None) -> Dict:
+        answered_question = copy(self.properties)
+        value = self.extract_for_formula(request, index)
+        if value is not None:
+            answered_question["user_input"] = str(value)
+        return answered_question
+
+    def extract_for_formula(self, request, index=None) -> Any:
+        formula_variables = get_expression_variables(self.formula)
+        # we need to substitute variables with their real form name
+        # i.e. name="test Variable" becomes "df_test_variable" in the form
+        formula = self.formula
+        form_name_variables = {}
+        for variable in formula_variables:
+            # we still want to allow using directly "df_test_variable" in formula
+            if variable in self.all_questions_by_form_name:
+                question_form_name = variable
+            else:
+                question_form_name = self.all_questions_by_name[variable].form_name
+            form_key_variable = question_form_name if index is None else f"{question_form_name}_{index}"
+            form_name_variables[question_form_name] = form_key_variable
+            formula = re.sub(r"\b" + re.escape(variable) + r"\b", form_key_variable, formula)
+        # extract values from questions and use them in formula
+        extracted_form_values = {}
+        for form_name, form_key in form_name_variables.items():
+            matching_question = self.all_questions_by_form_name[form_name]
+            # For formulas outside of groups using a group question,
+            # the variable should contain the list of values from the groups
+            formula_outside_group_using_group = not self.is_sub_question and matching_question.is_sub_question
+            if formula_outside_group_using_group:
+                pattern = match_group_index(form_key)
+                # let's grab indexes just like we do for group questions
+                indexes = [
+                    quiet_int(pattern.match(key).group(2), None) for key in request.POST.keys() if pattern.match(key)
+                ]
+                # now extract all the values from the group into a list
+                value = [
+                    matching_question.extract_for_formula(request, index)
+                    for index in indexes
+                    if matching_question.extract_for_formula(request, index) is not None
+                ]
+            else:
+                value = matching_question.extract_for_formula(request, index)
+            extracted_form_values[form_key] = value
+        # Allow everything other than None as valid value. If None is present
+        # we skip the whole formula evaluation
+        all_valid = all([val is not None for val in extracted_form_values.values()])
+        return evaluate_expression(formula, **extracted_form_values) if all_valid else None
+
 
 class PostUsageGroupQuestion(PostUsageQuestion):
     question_type = "Question of type group"
@@ -316,6 +445,8 @@ def __init__(self, properties: Dict, index: int = None):
         self.sub_questions: List[PostUsageQuestion] = PostUsageQuestion.load_questions(
             self._init_property("questions"), index
         )
+        for sub_question in self.sub_questions:
+            sub_question.is_sub_question = True
 
     def validate(self):
         super().validate()
@@ -364,7 +495,7 @@ def render_script(self, virtual_inputs: bool, group_question_url: str, item_id:
 			function remove_question_{self.group_name}(element)
 			{{
 				$(element).parents(".{self.group_name}_question").remove();
-				$("body").trigger("question-group-changed");
+				$("body").trigger("question-group-changed", ["{self.group_name}"]);
 				update_add_button_{self.group_name}();
 			}}
 			function add_question_{self.group_name}()
@@ -373,7 +504,7 @@ def render_script(self, virtual_inputs: bool, group_question_url: str, item_id:
 				{{
 					{self.group_name}_question_index ++;
 					$("#{self.group_name}_container").append(response);
-					$("body").trigger("question-group-changed");
+					$("body").trigger("question-group-changed", ["{self.group_name}"]);
 					update_add_button_{self.group_name}();
 				}}
 				}});
@@ -392,22 +523,16 @@ def extract(self, request, index=None) -> Dict:
         # The result of the extraction will be a dictionary, with the keys being the group number,
         # and the values the user inputs for all questions of the group.
         sub_results = copy(self.properties)
-        user_inputs = {}
-        question_form_names = [sub_question.form_name for sub_question in self.sub_questions]
+        inputs = {}
         for key in request.POST.keys():
-            if key in question_form_names:
-                for sub_question in self.sub_questions:
-                    if key == sub_question.form_name:
-                        user_inputs.setdefault(0, {})
-                        user_inputs[0][sub_question.name] = sub_question.extract(request).get("user_input")
-            else:
-                for sub_question in self.sub_questions:
-                    name = sub_question.form_name
-                    if key.startswith(name + "_"):
-                        index = int(key.rsplit("_", 1)[1])
-                        user_inputs.setdefault(index, {})
-                        user_inputs[index][sub_question.name] = sub_question.extract(request, index).get("user_input")
-        sub_results["user_input"] = user_inputs
+            for sub_question in self.sub_questions:
+                match = match_group_index(sub_question.form_name).match(key)
+                if match:
+                    index = quiet_int(match.group(2), None)  # extract index
+                    index_string = str(index or 0)
+                    inputs.setdefault(index_string, {})
+                    inputs[index_string][sub_question.name] = sub_question.extract(request, index).get("user_input")
+        sub_results["user_input"] = inputs
         return sub_results
 
 
@@ -418,6 +543,14 @@ def __init__(self, questions):
         if questions:
             self.untreated_questions = loads(questions)
             self.questions: List[PostUsageQuestion] = PostUsageQuestion.load_questions(self.untreated_questions)
+        # Add all the questions to each question, for extra processing if needed (in Formula for example)
+        subs = [sub_q for q in self.questions if isinstance(q, PostUsageGroupQuestion) for sub_q in q.sub_questions]
+        all_questions = self.questions + subs
+        for initialized_question in self.questions:
+            initialized_question.all_questions = all_questions
+            if isinstance(initialized_question, PostUsageGroupQuestion):
+                for sub_question in initialized_question.sub_questions:
+                    sub_question.all_questions = all_questions
 
     def render(self, group_question_url: str, group_item_id: int, virtual_inputs: bool = False):
         result = ""
@@ -431,11 +564,13 @@ def validate(self, group_question_url: str, group_item_id: int):
         # We need to validate the raw json for types
         for question in self.untreated_questions:
             if question["type"] not in question_types.keys():
-                raise Exception(f"type has to be one of {', '.join(question_types.keys())}")
+                raise Exception(f"type has to be one of {', '.join(question_types.keys())}, not {question['type']}")
             if question["type"] == GROUP_TYPE_FIELD_KEY and "questions" in question:
                 for sub_question in question["questions"]:
                     if sub_question["type"] not in question_types.keys():
-                        raise Exception(f"type has to be one of {', '.join(question_types.keys())}")
+                        raise Exception(
+                            f"type has to be one of {', '.join(question_types.keys())}, not {sub_question['type']}"
+                        )
         for question in self.questions:
             question.validate()
         # Test the rendering, but catch reverse exception if this the item doesn't have an id yet
@@ -446,22 +581,18 @@ def validate(self, group_question_url: str, group_item_id: int):
             if group_item_id:
                 raise
             pass
-        # Check for duplicate names
+        # Check for duplicate names, and that if consumable exists they are linked to a number question
         names = []
         for question in self.questions:
             names.append(question.name)
+            validate_consumable_for_question(question)
             if isinstance(question, PostUsageGroupQuestion):
                 for sub_question in question.sub_questions:
                     names.append(sub_question.name)
+                    validate_consumable_for_question(sub_question)
         duplicate_names = [k for k, v in Counter(names).items() if v > 1]
         if duplicate_names:
             raise Exception(f"Question names need to be unique. Duplicates were found: {duplicate_names}")
-        # Check that consumable exists and is linked to a number question
-        for question in self.questions:
-            validate_consumable_for_question(question)
-            if isinstance(question, PostUsageGroupQuestion):
-                for sub_question in question.sub_questions:
-                    validate_consumable_for_question(sub_question)
 
     def extract(self, request) -> str:
         results = {}
@@ -485,10 +616,10 @@ def _check_for_required_unanswered_questions(
             results[question.name]["user_input"] = ""
             required_unanswered_questions.append(question)
         elif isinstance(question, PostUsageGroupQuestion):
-            blank_user_input = {0: {}}
+            blank_user_input = {"0": {}}
             for sub_question in question.sub_questions:
-                if sub_question.required and (not user_input or not user_input.get(0, {}).get(sub_question.name)):
-                    blank_user_input[0][sub_question.name] = ""
+                if sub_question.required and (not user_input or not user_input.get("0", {}).get(sub_question.name)):
+                    blank_user_input["0"][sub_question.name] = ""
                     required_unanswered_questions.append(sub_question)
             if required_unanswered_questions:
                 results[question.name]["user_input"] = blank_user_input
@@ -505,11 +636,10 @@ def filter_questions(self, function: Callable[[PostUsageQuestion], bool]) -> Lis
                         results.append(sub_question)
         return results
 
-    def charge_for_consumables(self, usage_event, request=None):
+    def charge_for_consumables(self, usage_event, run_data: str, request=None):
         customer = usage_event.user
         merchant = usage_event.operator
         project = usage_event.project
-        run_data = usage_event.run_data
         try:
             run_data_json = loads(run_data)
         except Exception as error:
@@ -554,10 +684,11 @@ def get_submitted_user_inputs(user_data: str) -> Dict:
     user_input = {}
     user_data_json = loads(user_data)
     for field_name, data in user_data_json.items():
-        if data["type"] != "group":
-            user_input[field_name] = data["user_input"]
-        else:
-            user_input[field_name] = data["user_input"].values()
+        if "user_input" in data:
+            if data["type"] != "group":
+                user_input[field_name] = data["user_input"]
+            else:
+                user_input[field_name] = data["user_input"].values()
     return user_input
 
 
@@ -572,22 +703,25 @@ def render_group_questions(request, questions, group_question_url, group_item_id
 
 
 def validate_consumable_for_question(question: PostUsageQuestion):
-    if question.consumable:
-        if not isinstance(question, PostUsageNumberFieldQuestion):
-            raise Exception("Consumable withdrawals can only be used in questions of type number")
+    if question.has_consumable:
+        if not isinstance(question, (PostUsageNumberFieldQuestion, PostUsageFormulaQuestion)):
+            raise Exception("Consumable withdrawals can only be used in questions of type number or formula")
         else:
+            if question.consumable and question.consumable_id:
+                raise Exception("Use consumable or consumable_id but not both")
             try:
-                Consumable.objects.get(name=question.consumable)
-            except Consumable.DoesNotExist:
-                raise Exception(
-                    f"Consumable with name '{question.consumable}' could not be found. Make sure the names match."
+                Consumable.objects.get(pk=question.consumable_id) if question.consumable_id else Consumable.objects.get(
+                    name=question.consumable
                 )
+            except Consumable.DoesNotExist:
+                match = f"id '{question.consumable_id}'" if question.consumable_id else f"name '{question.consumable}'"
+                raise Exception(f"Consumable with {match} could not be found. Make sure the name/id matches.")
 
 
 def withdraw_consumable_for_question(question, input_data, customer, merchant, project, usage_event, request):
-    if isinstance(question, PostUsageNumberFieldQuestion):
-        if question.consumable:
-            consumable = Consumable.objects.get(name=question.consumable)
+    if isinstance(question, (PostUsageNumberFieldQuestion, PostUsageFormulaQuestion)):
+        if question.has_consumable:
+            consumable: Consumable = question.get_consumable()
             quantity = 0
             if input_data and "user_input" in input_data and input_data["user_input"]:
                 if isinstance(input_data["user_input"], dict):
@@ -610,7 +744,7 @@ def withdraw_consumable_for_question(question, input_data, customer, merchant, p
 
 def get_counter_increment_for_question(question, input_data, counter_question):
     additional_value = 0
-    if isinstance(question, PostUsageNumberFieldQuestion) or isinstance(question, PostUsageFloatFieldQuestion):
+    if isinstance(question, (PostUsageNumberFieldQuestion, PostUsageFloatFieldQuestion)):
         if question.name == counter_question and "user_input" in input_data and input_data["user_input"]:
             if isinstance(input_data["user_input"], dict):
                 for user_input in input_data["user_input"].values():
@@ -645,7 +779,7 @@ def admin_render_dynamic_form_preview(dynamic_form_json: str, group_url: str, it
     try:
         rendered_form = DynamicForm(dynamic_form_json).render(group_url, item_id)
         if dynamic_form_json:
-            form_validity_div = '<div id="form_validity"></div>'
+            form_validity_div = '<div class="form_validity"></div>'
     except:
         pass
     return mark_safe(
@@ -655,6 +789,11 @@ def admin_render_dynamic_form_preview(dynamic_form_json: str, group_url: str, it
     )
 
 
+def match_group_index(form_name: str) -> Optional[re.Pattern]:
+    # This will match form_name or any combination of form_name_1, form_name_2 etc.
+    return re.compile("^" + form_name + "(_(\d+))?$")
+
+
 question_types: Dict[str, Type[PostUsageQuestion]] = {
     "number": PostUsageNumberFieldQuestion,
     "float": PostUsageFloatFieldQuestion,
@@ -663,5 +802,6 @@ def admin_render_dynamic_form_preview(dynamic_form_json: str, group_url: str, it
     "radio": PostUsageRadioQuestion,
     "checkbox": PostUsageCheckboxQuestion,
     "dropdown": PostUsageDropdownQuestion,
+    "formula": PostUsageFormulaQuestion,
     "group": PostUsageGroupQuestion,
 }
diff --git a/NEMO/widgets/item_tree.py b/NEMO/widgets/item_tree.py
index f1e6a63da..b6bed024f 100644
--- a/NEMO/widgets/item_tree.py
+++ b/NEMO/widgets/item_tree.py
@@ -150,7 +150,7 @@ def __render_helper(self, node, result, category_items_lookup=None):
             result += '<li style="display: flex;">'
             css_class = "" if node.is_user_qualified else 'class="disabled"'
             result += f'<input type="checkbox" id="checkbox-{node.item_type.value}-{node.id}" name="item-checkbox" class="item-checkbox" data-item-id="{node.id}" data-item-type="{node.item_type.value}" style="display: none;">'
-            result += f'<a id="{node.item_type.value}-{node.id}" href="javascript:void(0);" onclick="set_selected_item(this)" data-item-id="{node.id}" data-item-type="{node.item_type.value}" data-item-name="{node.name}" {css_class}>{node.name}</a>'
+            result += f'<a id="{node.item_type.value}-{node.id}" href="javascript:void(0);" onclick="set_selected_item(this, true)" data-item-id="{node.id}" data-item-type="{node.item_type.value}" data-item-name="{node.name}" {css_class}>{node.name}</a>'
         if not node.__is_leaf():
             node_li_class = "area-category" if node.item_type == ReservationItemType.AREA else "tool-category"
             node_list_class = "area-list" if node.item_type == ReservationItemType.AREA else "tool-list"
diff --git a/README.md b/README.md
index e2bbdc4e0..73c6f0d2e 100644
--- a/README.md
+++ b/README.md
@@ -7,11 +7,11 @@
 The NEMO web application is laboratory logistics software that strives to be intuitive and easy to use, making life easier in the lab. NEMO manages tool reservations, control access to tools, and streamline logistics and communication. The code is open source and free so that other labs can benefit.
 
 # Online demo
-An online version of the splash pad is available at [https://nemo.nist.gov/demo](https://nemo.nist.gov/demo).
+An online version of the splash pad is available on a third party website at [https://nemo-demo.atlantislabs.io](https://nemo-demo.atlantislabs.io).
 
 ### User roles
 You will be automatically logged in as superadmin "captain".<br>
-Use the [impersonate](https://nemo.nist.gov/demo/impersonate) feature to switch between user roles:
+Use the [impersonate](https://nemo-demo.atlantislabs.io/impersonate) feature to switch between user roles:
 * `Ned Land`: regular user
 * `Pierre Aronnax`: staff member
 * `Assistant Conseil`: user office
@@ -19,13 +19,13 @@ Use the [impersonate](https://nemo.nist.gov/demo/impersonate) feature to switch
 * `Captain Nemo`: super admin
 
 ### Jumbotron
-The jumbotron is available at [https://nemo.nist.gov/demo/jumbotron/](https://nemo.nist.gov/demo/jumbotron/)
+The jumbotron is available at [https://nemo-demo.atlantislabs.io/jumbotron/](https://nemo-demo.atlantislabs.io/jumbotron/)
 
 ### Kiosk/Area access
 You can test the kiosk and area access features by going to the following URLs:
-* [entry door](https://nemo.nist.gov/demo/welcome_screen/1/?occupancy=Cleanroom)
-* [exit door](https://nemo.nist.gov/demo/farewell_screen/1/?occupancy=Cleanroom)
-* [kiosk](https://nemo.nist.gov/demo/kiosk/NanoFab/?occupancy=Cleanroom)
+* [entry door](https://nemo-demo.atlantislabs.io/welcome_screen/1/?occupancy=Cleanroom)
+* [exit door](https://nemo-demo.atlantislabs.io/farewell_screen/1/?occupancy=Cleanroom)
+* [kiosk](https://nemo-demo.atlantislabs.io/kiosk/NanoFab/?occupancy=Cleanroom)
 
 To simulate the badge reader, press `F2` then the badge number (`1` for captain, `2` for professor, `3` for ned) and press `F2` again.
 
diff --git a/gunicorn_configuration.py b/gunicorn_configuration.py
index bf0525fcf..0c7c6a905 100644
--- a/gunicorn_configuration.py
+++ b/gunicorn_configuration.py
@@ -8,7 +8,7 @@
 worker_class = "gthread"
 # The following value was decided based on the Gunicorn documentation and configuration example:
 # http://docs.gunicorn.org/en/stable/configure.html#configuration-file
-workers = cpu_count() * 2 + 1
+workers = min(cpu_count() * 2 + 1, 9)
 threads = 8
 keepalive = 300
 capture_output = True
diff --git a/resources/fixtures/splash_pad.json b/resources/fixtures/splash_pad.json
index 5f1324c18..ed0ab0f98 100644
--- a/resources/fixtures/splash_pad.json
+++ b/resources/fixtures/splash_pad.json
@@ -336,7 +336,6 @@
       "abuse_email": "",
       "reservation_email": "",
       "area_calendar_color": "#88B7CD",
-      "welcome_message": "<h2><center>Welcome to the cleanroom!</center></h2>",
       "requires_reservation": true,
       "logout_grace_period": null,
       "buddy_system_allowed": true,
@@ -371,7 +370,6 @@
       "abuse_email": "",
       "reservation_email": "",
       "area_calendar_color": "#88B7CD",
-      "welcome_message": "",
       "requires_reservation": false,
       "logout_grace_period": null,
       "buddy_system_allowed": false,
@@ -406,7 +404,6 @@
       "abuse_email": "",
       "reservation_email": "",
       "area_calendar_color": "#88b7cd",
-      "welcome_message": "",
       "requires_reservation": true,
       "logout_grace_period": null,
       "buddy_system_allowed": false,
@@ -673,7 +670,8 @@
     "pk": 1,
     "fields": {
       "name": "Cleanroom door",
-      "area": 1,
+      "welcome_message": "<h2><center>Welcome to the cleanroom!</center></h2>",
+      "areas": [1],
       "interlock": 1
     }
   },
@@ -923,8 +921,8 @@
     "pk": 9,
     "fields": {
       "image": "library.png",
-      "name": "Google",
-      "url": "https://www.google.com/",
+      "name": "Knowledge base",
+      "url": "/knowledge_base/user",
       "display_order": 8,
       "open_in_new_tab": true,
       "secure_referral": true,
@@ -1004,6 +1002,20 @@
       "value": "enabled"
     }
   },
+  {
+    "model": "NEMO.customization",
+    "pk": "calendar_configuration_in_reservations",
+    "fields": {
+      "value": "enabled"
+    }
+  },
+  {
+    "model": "NEMO.customization",
+    "pk": "calendar_qualified_tools",
+    "fields": {
+      "value": "enabled"
+    }
+  },
   {
     "model": "NEMO.customization",
     "pk": "project_allow_document_upload",
@@ -2224,7 +2236,9 @@
       "preferences": null,
       "onboarding_phases": [],
       "safety_trainings": [],
-      "physical_access_levels": [1],
+      "physical_access_levels": [
+        1
+      ],
       "groups": [],
       "user_permissions": [],
       "projects": [
@@ -5285,7 +5299,7 @@
       "_missed_reservation_threshold": 30,
       "_allow_delayed_logoff": false,
       "_post_usage_questions": "[\n   {\n      \"type\":\"group\",\n      \"name\":\"group1\",\n      \"title\":\"Add up to 3 processes:\",\n      \"max_number\":3,\n      \"group_add_button_name\": \"Add another process\",\n      \"questions\":[\n         {\n            \"type\":\"number\",\n            \"precision\": 5,\n            \"title\":\"How much gold was deposited?\",\n            \"max-width\":250,\n            \"suffix\":\"nl\",\n            \"required\":true,\n            \"placeholder\":\"0\",\n            \"name\":\"gold_used\",\n            \"consumable\": \"Sputter gold\"\n         },\n         {\n            \"type\": \"textarea\",\n            \"title\": \"Comments\",\n            \"name\": \"comments\",\n            \"required\": false,\n            \"max-width\":250,\n            \"rows\": 5\n         }\n      ]\n   }\n]",
-      "_policy_off_between_times": false,
+      "_pre_usage_questions": "[\r\n         {\r\n            \"type\":\"number\",\r\n            \"precision\": 5,\r\n            \"title\":\"How much gold do you plan on depositing?\",\r\n            \"max-width\":250,\r\n            \"suffix\":\"nl\",\r\n            \"required\":true,\r\n            \"placeholder\":\"0\",\r\n            \"name\":\"planned_gold_usage\"\r\n          },\r\n         {\r\n            \"type\": \"textarea\",\r\n            \"title\": \"Comments\",\r\n            \"name\": \"comments\",\r\n            \"required\": false,\r\n            \"max-width\":250,\r\n            \"rows\": 5\r\n         }\r\n]",
       "_policy_off_start_time": null,
       "_policy_off_end_time": null,
       "_policy_off_weekend": false,
@@ -9581,6 +9595,36 @@
       "resolution_category": null
     }
   },
+  {
+    "model": "NEMO.task",
+    "pk": 3,
+    "fields": {
+      "urgency": 1,
+      "tool": [
+        "PECVD"
+      ],
+      "force_shutdown": true,
+      "safety_hazard": false,
+      "creator": [
+        "ned"
+      ],
+      "creation_time": "2024-02-21T04:24:00.300Z",
+      "problem_category": null,
+      "problem_description": "Electrode temperature did not increase in the beginning of the 180 C cleaning recipe. Aborted job.",
+      "progress_description": "On Friday, December 29th, 2023 @ 8:57 AM Pierre Aronnax (professor) updated this task:\r\nTemperature will not go past 150C  with the setpoint being 180C. Chiller is showing \"Heater Alarm\" and will not rest. Tried to power cycle but the alarm returns. Currently troubleshooting\r\n\r\nOn Thursday, January 4th, 2024 @ 9:11 AM Pierre Aronnax (professor) updated this task:\r\nThe overtemp relay is faulty causing the temp to stop at 150 degrees when asking for 180 degrees.\r\nA new OT relay has been rush ordered.\r\n\r\nOn Thursday, January 11th, 2024 @ 11:38 AM Pierre Aronnax (professor) updated this task:\r\nThe new Relay has been installed and the alarm has stopped. However, now the relay contactor will not pull in to apply heat to the heaters. Continuing to troubleshoot control signals and voltages for the failure.\r\n\r\nOn Thursday, January 11th, 2024 @ 2:31 PM Pierre Aronnax (professor) updated this task:\r\nAfter further testing and troubleshooting it is determined that the Main heating element is bad and will need to be replaced. A call has been made to the vendor for replacement parts.\r\n\r\nOn Tuesday, February 6th, 2024 @ 1:43 PM Pierre Aronnax (professor) updated this task:\r\nExpecting the new heating element to arrive this Thursday, 2/8/24.\r\n\r\nOn Monday, February 12th, 2024 @ 2:33 PM Pierre Aronnax (professor) updated this task:\r\nThe new heater element has arrived. But in our attempts to remove the old heater it is apparent that this must be done by cutting and welding. We will be sending it to the vendor for major reworking of the heater element and its shroud. Package will be shipped to the vendor on tues 2/13/2024\r\n\r\nOn Thursday, February 15th, 2024 @ 2:46 PM Pierre Aronnax (professor) updated this task:\r\nThere was a short delay in shipping. The package is with shipping today.\r\nWe have news from the vendor that this type of repair requires one additional part which is a custom designed specification and the lead time is not until April before they receive the part and begin repairs. \" This repair is waiting on a part that currently has a delivery date of 4/4/24\" (from the vendor).",
+      "last_updated": "2024-02-05T09:46:00.200Z",
+      "last_updated_by": [
+        "professor"
+      ],
+      "estimated_resolution_time": null,
+      "cancelled": false,
+      "resolved": false,
+      "resolution_time": null,
+      "resolver": null,
+      "resolution_description": null,
+      "resolution_category": null
+    }
+  },
   {
     "model": "NEMO.resource",
     "pk": 1,
@@ -10021,5 +10065,41 @@
       "reminder_date": "2024-05-01",
       "notes": ""
     }
+  },
+  {
+    "model": "NEMO.staffknowledgebasecategory",
+    "fields": {
+      "name": "Staff procedures",
+      "display_order": 1
+    }
+  },
+  {
+    "model": "NEMO.userknowledgebasecategory",
+    "fields": {
+      "name": "User training",
+      "display_order": 1
+    }
+  },
+  {
+    "model": "NEMO.staffknowledgebaseitem",
+    "pk": 1,
+    "fields": {
+      "name": "Cleaning equipment",
+      "description": "Please refer to the SOP for equipment and safety rules",
+      "category": [
+        "Staff procedures"
+      ]
+    }
+  },
+  {
+    "model": "NEMO.userknowledgebaseitem",
+    "pk": 1,
+    "fields": {
+      "name": "Equipment training",
+      "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/settings.py b/resources/settings.py
index 9d631a82d..d68b54efd 100644
--- a/resources/settings.py
+++ b/resources/settings.py
@@ -76,7 +76,7 @@
 # See allowed formats at https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior
 DATETIME_INPUT_FORMATS = ["%m/%d/%Y %I:%M %p", *global_settings.DATE_INPUT_FORMATS]
 DATE_INPUT_FORMATS = ["%m/%d/%Y", *global_settings.DATE_INPUT_FORMATS]
-TIME_INPUT_FORMATS = ["%I:%M %p", *global_settings.TIME_INPUT_FORMATS]
+TIME_INPUT_FORMATS = ["%I:%M:%S %p", *global_settings.TIME_INPUT_FORMATS]
 
 # -------------------- Internationalization and localization --------------------
 # A boolean that specifies whether Django’s translation system should be enabled.
diff --git a/setup.py b/setup.py
index e1f3be50c..d63990c2e 100644
--- a/setup.py
+++ b/setup.py
@@ -2,9 +2,9 @@
 
 setup(
     name="NEMO",
-    version="5.2.0",
+    version="5.3.0",
     python_requires=">=3.8, <4",
-    packages=find_namespace_packages(exclude=["NEMO.tests", "NEMO.tests.*"]),
+    packages=find_namespace_packages(exclude=["resources", "resources.*", "build", "build.*"]),
     include_package_data=True,
     url="https://github.com/usnistgov/NEMO",
     license="Public domain",
@@ -28,8 +28,8 @@
         "Programming Language :: Python :: 3.12",
     ],
     install_requires=[
-        "cryptography==41.0.7",
-        "Django==3.2.23",
+        "cryptography==42.0.5",
+        "Django==3.2.25",
         "django-auditlog==2.3.0",
         "django-filter==23.5",
         "django-mptt==0.14.0",
@@ -39,8 +39,8 @@
         "ldap3==2.9.1",
         "Pillow==10.2.0",
         "pymodbus==3.3.2",
-        "python-dateutil==2.8.2",
-        "pytz==2023.3",
+        "python-dateutil==2.9.0",
+        "pytz==2024.1",
         "requests==2.31.0",
     ],
     entry_points={
diff --git a/start_NEMO_in_Docker.sh b/start_NEMO_in_Docker.sh
index 78479b3c8..285b59027 100755
--- a/start_NEMO_in_Docker.sh
+++ b/start_NEMO_in_Docker.sh
@@ -3,11 +3,11 @@
 # Exit if any of following commands fails
 set -e
 
-# Run migrations to create or update the database
-django-admin migrate
-
 # Collect static files
 django-admin collectstatic --no-input --clear
 
+# Run migrations to create or update the database
+django-admin migrate
+
 # Run NEMO
 exec gunicorn --config=/etc/gunicorn_configuration.py NEMO.wsgi:application


Test patch:
diff --git a/NEMO/tests/test_api/test_billing.py b/NEMO/tests/test_api/test_billing.py
index d1f28e1cb..4f29b2b51 100644
--- a/NEMO/tests/test_api/test_billing.py
+++ b/NEMO/tests/test_api/test_billing.py
@@ -43,7 +43,7 @@ def setUp(self):
         )
 
         # create a few area access records
-        area = Area.objects.create(name="Cleanroom", welcome_message="")
+        area = Area.objects.create(name="Cleanroom")
         AreaAccessRecord.objects.create(
             area=area, customer=owner2, project=project, end=datetime.now().astimezone(timezone.get_current_timezone())
         )
diff --git a/NEMO/tests/test_area_access.py b/NEMO/tests/test_area_access.py
index 066a387ef..88ad3f565 100644
--- a/NEMO/tests/test_area_access.py
+++ b/NEMO/tests/test_area_access.py
@@ -57,8 +57,9 @@ def setUp(self):
             server="server.com", port=80, number=1, even_port=1, odd_port=2, category=interlock_card_category
         )
         interlock = Interlock.objects.create(card=interlock_card, channel=1)
-        area = Area.objects.create(name="Cleanroom", welcome_message="Welcome to the cleanroom")
-        door = Door.objects.create(name="test_door", area=area, interlock=interlock)
+        area = Area.objects.create(name="Cleanroom")
+        door = Door.objects.create(name="test_door", interlock=interlock)
+        door.areas.set([area])
 
     def test_welcome_screen_fails(self):
         response = self.client.post(reverse("welcome_screen", kwargs={"door_id": door.id}), follow=True)
@@ -127,17 +128,18 @@ def test_login_to_area(self):
         self.assertContains(
             response=response, text="Physical access denied", status_code=200
         )  # user does not have access
+        area = door.areas.first()
         user.physical_access_levels.add(
             PhysicalAccessLevel.objects.create(
-                name="cleanroom access", area=door.area, schedule=PhysicalAccessLevel.Schedule.ALWAYS
+                name="cleanroom access", area=area, schedule=PhysicalAccessLevel.Schedule.ALWAYS
             )
         )
         user.save()
-        door.area.maximum_capacity = 1
-        door.area.save()
+        area.maximum_capacity = 1
+        area.save()
         # add a logged in person so capacity is reached
         AreaAccessRecord.objects.create(
-            area=door.area,
+            area=area,
             customer=User.objects.create(
                 username="test_staff2", first_name="Test", last_name="Staff", is_staff=True, badge_number=2222
             ),
@@ -154,7 +156,7 @@ def test_login_to_area(self):
         response = self.client.post(
             reverse("login_to_area", kwargs={"door_id": door.id}), data={"badge_number": user.badge_number}, follow=True
         )
-        self.assertContains(response, f"The {door.area} has reached its maximum capacity.")
+        self.assertContains(response, f"The {area} has reached its maximum capacity.")
         # staff can still login
         response = self.client.post(
             reverse("login_to_area", kwargs={"door_id": door.id}),
@@ -165,17 +167,17 @@ def test_login_to_area(self):
         self.assertContains(response=response, text="You're logged in to the ", status_code=200)
         self.assertTrue(
             AreaAccessRecord.objects.filter(
-                area=door.area, customer=User.objects.get(badge_number=staff.badge_number)
+                area=area, customer=User.objects.get(badge_number=staff.badge_number)
             ).exists()
         )
         # try again user, should fail
         response = self.client.post(
             reverse("login_to_area", kwargs={"door_id": door.id}), data={"badge_number": user.badge_number}, follow=True
         )
-        self.assertContains(response, f"The {door.area} has reached its maximum capacity.")
+        self.assertContains(response, f"The {area} has reached its maximum capacity.")
         # increase capacity so user can login
-        door.area.maximum_capacity = 5
-        door.area.save()
+        area.maximum_capacity = 5
+        area.save()
         response = self.client.post(
             reverse("login_to_area", kwargs={"door_id": door.id}), data={"badge_number": user.badge_number}, follow=True
         )
@@ -183,7 +185,7 @@ def test_login_to_area(self):
         self.assertContains(response=response, text="You're logged in to the ", status_code=200)
         self.assertTrue(
             AreaAccessRecord.objects.filter(
-                area=door.area, customer=User.objects.get(badge_number=user.badge_number)
+                area=area, customer=User.objects.get(badge_number=user.badge_number)
             ).exists()
         )
 
@@ -210,10 +212,11 @@ def test_staff_login_to_area(self):
         )
         self.assertTrue("Physical access denied" in str(response.content))
         # create an area an allow staff access without granting it to them
+        area = door.areas.first()
         access = PhysicalAccessLevel.objects.create(
             allow_staff_access=True,
             name="cleanroom access",
-            area=door.area,
+            area=area,
             schedule=PhysicalAccessLevel.Schedule.ALWAYS,
         )
         response = self.client.post(
@@ -230,7 +233,7 @@ def test_staff_login_to_area(self):
             data={"badge_number": staff.badge_number},
             follow=True,
         )
-        self.assertTrue("already logged into" in str(response.content))  # user already logged in
+        self.assertTrue("already logged in the" in str(response.content))  # user already logged in
         response = self.client.post(
             reverse("logout_of_area", kwargs={"door_id": door.id}),
             data={"badge_number": staff.badge_number},
@@ -268,7 +271,7 @@ def test_staff_login_to_area(self):
         self.assertTrue("You're logged in to the " in str(response.content))
         self.assertTrue(
             AreaAccessRecord.objects.filter(
-                area=door.area, customer=User.objects.get(badge_number=staff.badge_number), end__isnull=True
+                area=area, customer=User.objects.get(badge_number=staff.badge_number), end__isnull=True
             ).exists()
         )
 
@@ -283,8 +286,9 @@ def setUp(self):
             server="server.com", port=80, number=1, even_port=1, odd_port=2, category=interlock_card_category
         )
         interlock = Interlock.objects.create(card=interlock_card, channel=1)
-        area = Area.objects.create(name="Cleanroom", welcome_message="Welcome to the cleanroom")
-        door = Door.objects.create(name="test_door", area=area, interlock=interlock)
+        area = Area.objects.create(name="Cleanroom")
+        door = Door.objects.create(name="test_door", interlock=interlock)
+        door.areas.set([area])
 
     def test_login_to_area(self):
         self.client.post(reverse("self_log_in"), data={"area": area.id}, follow=True)
@@ -362,8 +366,9 @@ def setUp(self):
             server="server.com", port=80, number=1, even_port=1, odd_port=2, category=interlock_card_category
         )
         interlock = Interlock.objects.create(card=interlock_card, channel=1)
-        area = Area.objects.create(name="Cleanroom", welcome_message="Welcome to the cleanroom")
-        door = Door.objects.create(name="test_door", area=area, interlock=interlock)
+        area = Area.objects.create(name="Cleanroom")
+        door = Door.objects.create(name="test_door", interlock=interlock)
+        door.areas.set([area])
 
     def test_new_area_record_get(self):
         user = login_as_user(self.client)
@@ -487,7 +492,7 @@ def test_staff_login_to_area(self):
         access = PhysicalAccessLevel.objects.create(
             allow_staff_access=True,
             name="cleanroom access",
-            area=door.area,
+            area=area,
             schedule=PhysicalAccessLevel.Schedule.ALWAYS,
         )
         response = self.client.post(
@@ -504,7 +509,7 @@ def test_staff_login_to_area(self):
             data={"badge_number": staff.badge_number},
             follow=True,
         )
-        self.assertTrue("already logged into" in str(response.content))  # user already logged in
+        self.assertTrue("already logged in the" in str(response.content))  # user already logged in
         response = self.client.post(
             reverse("logout_of_area", kwargs={"door_id": door.id}),
             data={"badge_number": staff.badge_number},
@@ -542,7 +547,7 @@ def test_staff_login_to_area(self):
         self.assertTrue("You're logged in to the " in str(response.content))
         self.assertTrue(
             AreaAccessRecord.objects.filter(
-                area=door.area, customer=User.objects.get(badge_number=staff.badge_number)
+                area=area, customer=User.objects.get(badge_number=staff.badge_number)
             ).exists()
         )
 
@@ -558,8 +563,9 @@ def setUp(self):
             server="server.com", port=80, number=1, even_port=1, odd_port=2, category=interlock_card_category
         )
         interlock = Interlock.objects.create(card=interlock_card, channel=1)
-        area = Area.objects.create(name="Test Area", welcome_message="Welcome")
-        door = Door.objects.create(name="test_door", area=area, interlock=interlock)
+        area = Area.objects.create(name="Test Area")
+        door = Door.objects.create(name="test_door", interlock=interlock)
+        door.areas.set([area])
 
     def test_door(self):
         self.assertEqual(door.interlock.state, Interlock.State.UNKNOWN)
@@ -569,7 +575,7 @@ def test_door(self):
 class AreaAutoLogoutTestCase(TestCase):
     def testAutoLogout(self):
         user = login_as_user(self.client)
-        area = Area.objects.create(name="Cleanroom", welcome_message="Welcome to the cleanroom")
+        area = Area.objects.create(name="Cleanroom")
         project = Project.objects.create(name="Project1", account=Account.objects.create(name="Account1"))
         start = timezone.now() - datetime.timedelta(minutes=5)
         record = AreaAccessRecord.objects.create(area=area, customer=user, project=project, start=start)
diff --git a/NEMO/tests/test_calendar/test_outage.py b/NEMO/tests/test_calendar/test_outage.py
index 61ce0abce..75070822d 100644
--- a/NEMO/tests/test_calendar/test_outage.py
+++ b/NEMO/tests/test_calendar/test_outage.py
@@ -19,7 +19,7 @@ def setUp(self):
         global tool, area, owner
         owner = User.objects.create(username="mctest", first_name="Testy", last_name="McTester")
         tool = Tool.objects.create(name="test_tool", primary_owner=owner)
-        area = Area.objects.create(name="Cleanroom", welcome_message="")
+        area = Area.objects.create(name="Cleanroom")
 
     @staticmethod
     def get_outage_data(
diff --git a/NEMO/tests/test_consumables.py b/NEMO/tests/test_consumables.py
index 1d707f357..8bfee894b 100644
--- a/NEMO/tests/test_consumables.py
+++ b/NEMO/tests/test_consumables.py
@@ -119,6 +119,56 @@ def test_post_self_checkout_not_allowed(self):
             # restore consumable_user_self_checkout value
             ApplicationCustomization.set("consumable_user_self_checkout", consumable_user_self_checkout)
 
+    def test_post_self_checkout_user_not_allowed(self):
+        # save consumable_user_self_checkout value
+        consumable_user_self_checkout = ApplicationCustomization.get("consumable_user_self_checkout")
+        staff, staff_project = create_user_and_project(is_staff=True)
+        try:
+            ApplicationCustomization.set("consumable_user_self_checkout", "enabled")
+            consumable_self_checkout = Consumable.objects.create(
+                name="Consumable Self Checkout Only By User",
+                quantity=10,
+                reminder_threshold=5,
+                reminder_email="test@test.com",
+                allow_self_checkout=True,
+            )
+            consumable_self_checkout.self_checkout_only_users.set([staff])
+            customer, customer_project = create_user_and_project()
+            quantity = consumable_self_checkout.quantity
+            login_as(self.client, customer)
+            data = {
+                "customer": customer.id,
+                "project": customer_project.id,
+                "consumable": consumable_self_checkout.id,
+                "quantity": "1",
+            }
+            response = self.client.post(reverse("consumables"), data, follow=True)
+            # Should fail because consumable is only available to staff user
+            self.assertEqual(response.status_code, 400)
+            # Checkout, nothing should happen since the order did not go through
+            response = self.client.post(reverse("withdraw_consumables"), follow=True)
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(quantity, Consumable.objects.get(pk=consumable_self_checkout.id).quantity)
+            consumable_self_checkout.self_checkout_only_users.set([customer])
+            response = self.client.post(reverse("consumables"), data, follow=True)
+            # Should work now for customer
+            self.assertEqual(response.status_code, 200)
+            # Checkout, it should go through
+            response = self.client.post(reverse("withdraw_consumables"), follow=True)
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(quantity - 1, Consumable.objects.get(pk=consumable_self_checkout.id).quantity)
+            # Should also still work for staff
+            login_as(self.client, staff)
+            response = self.client.post(reverse("consumables"), data, follow=True)
+            self.assertEqual(response.status_code, 200)
+            # Checkout, it should go through
+            response = self.client.post(reverse("withdraw_consumables"), follow=True)
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(quantity - 2, Consumable.objects.get(pk=consumable_self_checkout.id).quantity)
+        finally:
+            # restore consumable_user_self_checkout value
+            ApplicationCustomization.set("consumable_user_self_checkout", consumable_user_self_checkout)
+
     def test_clean(self):
         test_consumable = Consumable()
         validate_model_error(self, test_consumable, ["name", "quantity"])
diff --git a/NEMO/tests/test_dynamic_form.py b/NEMO/tests/test_dynamic_form.py
new file mode 100644
index 000000000..be6f5b6b3
--- /dev/null
+++ b/NEMO/tests/test_dynamic_form.py
@@ -0,0 +1,356 @@
+import json
+
+from django.http import QueryDict
+from django.test import TestCase
+
+from NEMO.utilities import EmptyHttpRequest
+from NEMO.widgets.dynamic_form import DynamicForm
+
+
+class TestDynamicForm(TestCase):
+    def test_formula_number_field(self):
+        data = [
+            {"name": "test", "type": "number", "title": "Pair of wafer trays", "max-width": 250},
+            {
+                "name": "test_func",
+                "type": "formula",
+                "title": "test function",
+                "formula": "test*2",
+            },
+        ]
+        dynamic_form = DynamicForm(json.dumps(data))
+        dynamic_form.validate("tool_usage_group_question", 1)
+        http_request = EmptyHttpRequest()
+        http_request.POST = QueryDict(mutable=True)
+        http_request.POST["df_test"] = "2"
+        extracted_value = json.loads(dynamic_form.extract(http_request))
+        self.assertEqual(extracted_value["test_func"]["user_input"], str(2 * 2))
+
+    def test_formula_float_field(self):
+        data = [
+            {"name": "test", "type": "float", "title": "Pair of wafer trays", "max-width": 250},
+            {
+                "name": "test_func",
+                "type": "formula",
+                "title": "test function",
+                "formula": "test*2",
+            },
+        ]
+        dynamic_form = DynamicForm(json.dumps(data))
+        dynamic_form.validate("tool_usage_group_question", 1)
+        http_request = EmptyHttpRequest()
+        http_request.POST = QueryDict(mutable=True)
+        http_request.POST["df_test"] = "2.0"
+        extracted_value = json.loads(dynamic_form.extract(http_request))
+        self.assertEqual(extracted_value["test_func"]["user_input"], str(2.0 * 2))
+
+    def test_formula_zero(self):
+        data = [
+            {"name": "test", "type": "number", "title": "Pair of wafer trays", "max-width": 250},
+            {
+                "name": "test_func",
+                "type": "formula",
+                "title": "test function",
+                "formula": "test*0",
+            },
+        ]
+        dynamic_form = DynamicForm(json.dumps(data))
+        dynamic_form.validate("tool_usage_group_question", 1)
+        http_request = EmptyHttpRequest()
+        http_request.POST = QueryDict(mutable=True)
+        http_request.POST["df_test"] = "2"
+        extracted_value = json.loads(dynamic_form.extract(http_request))
+
+        self.assertEqual(extracted_value["test_func"]["user_input"], str(0))
+
+    def test_formula_zero_zero(self):
+        data = [
+            {"name": "test", "type": "number", "title": "Pair of wafer trays", "max-width": 250},
+            {
+                "name": "test_func",
+                "type": "formula",
+                "title": "test function",
+                "formula": "test*0",
+            },
+        ]
+        dynamic_form = DynamicForm(json.dumps(data))
+        dynamic_form.validate("tool_usage_group_question", 1)
+        http_request = EmptyHttpRequest()
+        http_request.POST = QueryDict(mutable=True)
+        http_request.POST["df_test"] = "0"
+        extracted_value = json.loads(dynamic_form.extract(http_request))
+
+        self.assertEqual(extracted_value["test_func"]["user_input"], str(0))
+
+    def test_formula_inside_group(self):
+        data = [
+            {
+                "name": "test_group",
+                "type": "group",
+                "title": "This is a test group",
+                "max_number": 2,
+                "questions": [
+                    {"name": "test", "type": "number", "title": "Pair of wafer trays", "max-width": 250},
+                    {
+                        "name": "test_func",
+                        "type": "formula",
+                        "title": "test function",
+                        "formula": "test*0",
+                    },
+                ],
+            }
+        ]
+        dynamic_form = DynamicForm(json.dumps(data))
+        dynamic_form.validate("tool_usage_group_question", 1)
+        http_request = EmptyHttpRequest()
+        http_request.POST = QueryDict(mutable=True)
+        # We are simulating what the request data would look like. Formula questions will render a hidden, no value input
+        http_request.POST["df_test"] = "2"
+        http_request.POST["df_test_func"] = ""
+        extracted_value = json.loads(dynamic_form.extract(http_request))
+        self.assertEqual(extracted_value["test_group"]["user_input"]["0"]["test_func"], str(0))
+
+    def test_formula_with_no_value(self):
+        data = [
+            {
+                "name": "test_group",
+                "type": "group",
+                "title": "This is a test group",
+                "max_number": 2,
+                "questions": [
+                    {"name": "test", "type": "number", "title": "Pair of wafer trays", "max-width": 250},
+                    {
+                        "name": "test_func",
+                        "type": "formula",
+                        "title": "test function",
+                        "formula": "test*2",
+                    },
+                ],
+            }
+        ]
+        dynamic_form = DynamicForm(json.dumps(data))
+        dynamic_form.validate("tool_usage_group_question", 1)
+        http_request = EmptyHttpRequest()
+        http_request.POST = QueryDict(mutable=True)
+        # We are simulating what the request data would look like. Formula questions will render a hidden, no value input
+        http_request.POST["df_test"] = ""
+        http_request.POST["df_test_func"] = ""
+        extracted_value = json.loads(dynamic_form.extract(http_request))
+        self.assertEqual(extracted_value["test_group"]["user_input"]["0"]["test_func"], None)
+
+    def test_formula_outside_group(self):
+        data = [
+            {
+                "name": "test_group",
+                "type": "group",
+                "title": "This is a test group",
+                "max_number": 2,
+                "questions": [
+                    {"name": "test", "type": "number", "title": "Pair of wafer trays", "max-width": 250},
+                    {
+                        "name": "test_func",
+                        "type": "formula",
+                        "title": "test function",
+                        "formula": "test*0",
+                    },
+                ],
+            },
+            {
+                "name": "test_group2",
+                "type": "group",
+                "title": "This is a test group2",
+                "max_number": 2,
+                "questions": [
+                    {"name": "test2", "type": "number", "title": "Pair of wafer trays2", "max-width": 250},
+                    {
+                        "name": "test_func2",
+                        "type": "formula",
+                        "title": "test function2",
+                        "formula": "test2*2",
+                    },
+                ],
+            },
+            {
+                "name": "test_sum",
+                "type": "formula",
+                "title": "test function",
+                "formula": "sum(test)",
+            },
+        ]
+        dynamic_form = DynamicForm(json.dumps(data))
+        dynamic_form.validate("tool_usage_group_question", 1)
+        http_request = EmptyHttpRequest()
+        http_request.POST = QueryDict(mutable=True)
+        # We are simulating what the request data would look like. Formula questions will render a hidden, no value input
+        http_request.POST["df_test"] = "2"
+        http_request.POST["df_test_2"] = "4"
+        http_request.POST["df_test_func"] = ""
+        extracted_value = json.loads(dynamic_form.extract(http_request))
+        self.assertEqual(extracted_value["test_group"]["user_input"]["0"]["test_func"], str(0))
+        self.assertEqual(extracted_value["test_sum"]["user_input"], str(6))
+
+    def test_formula_outside_group_with_spaces(self):
+        data = [
+            {
+                "name": "test_group",
+                "type": "group",
+                "title": "This is a test group",
+                "max_number": 2,
+                "questions": [
+                    {"name": "test unit", "type": "number", "title": "Pair of wafer trays", "max-width": 250},
+                    {
+                        "name": "test_func",
+                        "type": "formula",
+                        "title": "test function",
+                        "formula": "df_test_unit*0",
+                    },
+                ],
+            },
+            {
+                "name": "test_group2",
+                "type": "group",
+                "title": "This is a test group2",
+                "max_number": 2,
+                "questions": [
+                    {"name": "test2", "type": "number", "title": "Pair of wafer trays2", "max-width": 250},
+                    {
+                        "name": "test_func2",
+                        "type": "formula",
+                        "title": "test function2",
+                        "formula": "test2*0",
+                    },
+                ],
+            },
+            {
+                "name": "test_sum",
+                "type": "formula",
+                "title": "test function",
+                "formula": "sum(df_test_unit)",
+            },
+        ]
+        dynamic_form = DynamicForm(json.dumps(data))
+        dynamic_form.validate("tool_usage_group_question", 1)
+        http_request = EmptyHttpRequest()
+        http_request.POST = QueryDict(mutable=True)
+        # We are simulating what the request data would look like. Formula questions will render a hidden, no value input
+        http_request.POST["df_test_unit"] = "2"
+        http_request.POST["df_test_unit_2"] = "4"
+        http_request.POST["df_test_func"] = ""
+        extracted_value = json.loads(dynamic_form.extract(http_request))
+        self.assertEqual(extracted_value["test_group"]["user_input"]["0"]["test_func"], str(0))
+        self.assertEqual(extracted_value["test_sum"]["user_input"], str(6))
+
+    def test_formula_using_other_formula_with_missing_arg(self):
+        data = [
+            {
+                "name": "test_group",
+                "type": "group",
+                "title": "This is a test group",
+                "max_number": 2,
+                "questions": [
+                    {"name": "test", "type": "number", "title": "Pair of wafer trays", "max-width": 250},
+                    {
+                        "name": "test_func",
+                        "type": "formula",
+                        "title": "test function",
+                        "formula": "test*2",
+                    },
+                ],
+            },
+            {
+                "name": "test_group2",
+                "type": "group",
+                "title": "This is a test group2",
+                "max_number": 2,
+                "questions": [
+                    {"name": "test2", "type": "number", "title": "Pair of wafer trays2", "max-width": 250},
+                    {
+                        "name": "test_func2",
+                        "type": "formula",
+                        "title": "test function2",
+                        "formula": "test2*4",
+                    },
+                ],
+            },
+            {
+                "name": "test_sum",
+                "type": "formula",
+                "title": "test function",
+                "formula": "sum(test_func) + sum(test_func2)",
+            },
+        ]
+        dynamic_form = DynamicForm(json.dumps(data))
+        dynamic_form.validate("tool_usage_group_question", 1)
+        http_request = EmptyHttpRequest()
+        http_request.POST = QueryDict(mutable=True)
+        # We are simulating what the request data would look like. Formula questions will render a hidden, no value input
+        http_request.POST["df_test"] = "2"
+        http_request.POST["df_test_2"] = "4"
+        http_request.POST["df_test_func"] = ""
+        http_request.POST["df_test_func2"] = ""
+        http_request.POST["df_test_sum"] = ""
+        extracted_value = json.loads(dynamic_form.extract(http_request))
+        self.assertEqual(extracted_value["test_group"]["user_input"]["0"]["test_func"], str(4))
+        self.assertEqual(extracted_value["test_sum"]["user_input"], str(4))
+
+    def test_formula_using_other_formula(self):
+        data = [
+            {
+                "name": "test_group",
+                "type": "group",
+                "title": "This is a test group",
+                "max_number": 2,
+                "questions": [
+                    {"name": "test", "type": "number", "title": "Pair of wafer trays", "max-width": 250},
+                    {
+                        "name": "test_func",
+                        "type": "formula",
+                        "title": "test function",
+                        "formula": "test*2",
+                    },
+                ],
+            },
+            {
+                "name": "test_group2",
+                "type": "group",
+                "title": "This is a test group2",
+                "max_number": 2,
+                "questions": [
+                    {"name": "test2", "type": "number", "title": "Pair of wafer trays2", "max-width": 250},
+                    {
+                        "name": "test_func2",
+                        "type": "formula",
+                        "title": "test function2",
+                        "formula": "test2*4",
+                    },
+                ],
+            },
+            {
+                "name": "test_sum",
+                "type": "formula",
+                "title": "test function",
+                "formula": "sum(test_func) + sum(test_func2)",
+            },
+        ]
+        dynamic_form = DynamicForm(json.dumps(data))
+        dynamic_form.validate("tool_usage_group_question", 1)
+        http_request = EmptyHttpRequest()
+        http_request.POST = QueryDict(mutable=True)
+        # We are simulating what the request data would look like. Formula questions will render a hidden, no value input
+        http_request.POST["df_test"] = "2"
+        http_request.POST["df_test_2"] = "4"
+        http_request.POST["df_test2"] = "3"
+        http_request.POST["df_test2_2"] = "6"
+        http_request.POST["df_test_func"] = ""
+        http_request.POST["df_test_func_2"] = ""
+        http_request.POST["df_test_func2"] = ""
+        http_request.POST["df_test_func2_2"] = ""
+        http_request.POST["df_test_sum"] = ""
+        # Total = sum(test_func) + sum(test_func2) = sum(tests[]*2) + sum(test2[]*4)
+        # = sum(2*2, 4*2) + sum(3*4, 6*4) = sum(4, 8) + sum(12, 24) = 12 + 36 = 48
+        extracted_value = json.loads(dynamic_form.extract(http_request))
+        self.assertEqual(extracted_value["test_group"]["user_input"]["0"]["test_func"], str(4))
+        self.assertEqual(extracted_value["test_group"]["user_input"]["2"]["test_func"], str(8))
+        self.assertEqual(extracted_value["test_group2"]["user_input"]["0"]["test_func2"], str(12))
+        self.assertEqual(extracted_value["test_group2"]["user_input"]["2"]["test_func2"], str(24))
+        self.assertEqual(extracted_value["test_sum"]["user_input"], str(48))
diff --git a/NEMO/tests/test_sensors.py b/NEMO/tests/test_evaluators.py
similarity index 75%
rename from NEMO/tests/test_sensors.py
rename to NEMO/tests/test_evaluators.py
index 70f39b9e0..3b408709c 100644
--- a/NEMO/tests/test_sensors.py
+++ b/NEMO/tests/test_evaluators.py
@@ -1,11 +1,6 @@
 from unittest import TestCase
 
-from NEMO.apps.sensors.evaluators import (
-    evaluate_boolean_expression,
-    evaluate_expression,
-    evaluate_modbus_expression,
-    modbus_functions,
-)
+from NEMO.evaluators import evaluate_boolean_expression, evaluate_expression, get_expression_variables
 
 
 class TestAstEval(TestCase):
@@ -36,23 +31,6 @@ def test_basic_evaluation(self):
         self.assertEqual(12, evaluate_expression("trunc(12.123)"))
         self.assertEqual(2, evaluate_expression("sqrt(4)"))
 
-    def test_modbus_evaluation(self):
-        # Test all modbus functions
-        variables_1 = {"my_list": [100]}
-        variables_2 = {"my_list": [100, 500]}
-        variables_4 = {"my_list": [100, 500, 1000, 2000]}
-        for function_name in modbus_functions:
-            evaluate_modbus_expression(f"decode_string(my_list)", **variables_1)
-            if "8" in function_name:
-                evaluate_modbus_expression(f"{function_name}(my_list)", **variables_1)
-            if "16" in function_name:
-                evaluate_modbus_expression(f"{function_name}(my_list)", **variables_1)
-            if "32" in function_name:
-                evaluate_modbus_expression(f"{function_name}(my_list)", **variables_2)
-            elif "64" in function_name:
-                evaluate_modbus_expression(f"{function_name}(my_list)", **variables_4)
-        evaluate_modbus_expression(f"round(decode_8bit_int(my_list))", **variables_1)
-
     def test_boolean_evaluation(self):
         self.assertFalse(evaluate_boolean_expression("False"))
         self.assertFalse(evaluate_boolean_expression("10 < 5"))
@@ -80,3 +58,6 @@ def test_boolean_evaluation(self):
         self.assertFalse(evaluate_boolean_expression("True and True and True and False"))
         self.assertFalse(evaluate_boolean_expression("value > 56 and value < 76", value=100))
         self.assertFalse(evaluate_boolean_expression("value > abs(-110)", value=100))
+
+    def test_variable_list(self):
+        self.assertSetEqual(get_expression_variables("value > abs(-110) + round(value2) * 2"), {"value", "value2"})
diff --git a/NEMO/tests/test_sensors/__init__.py b/NEMO/tests/test_sensors/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/NEMO/tests/test_sensors/test_modbus_sensors.py b/NEMO/tests/test_sensors/test_modbus_sensors.py
new file mode 100644
index 000000000..b0d246b6e
--- /dev/null
+++ b/NEMO/tests/test_sensors/test_modbus_sensors.py
@@ -0,0 +1,47 @@
+from unittest import mock
+
+from django.test import TestCase
+
+from NEMO.apps.sensors.evaluators import evaluate_modbus_expression, modbus_functions
+from NEMO.apps.sensors.models import Sensor, SensorCard, SensorCardCategory
+from NEMO.apps.sensors.sensors import mocked_modbus_client
+
+
+class ModbusInterlockTestCase(TestCase):
+    def test_modbus_evaluation(self):
+        # Test all modbus functions
+        variables_1 = {"my_list": [100]}
+        variables_2 = {"my_list": [100, 500]}
+        variables_4 = {"my_list": [100, 500, 1000, 2000]}
+        sensor = Sensor()
+        for function_name in modbus_functions:
+            sensor.formula = f"decode_string(my_list)"
+            evaluate_modbus_expression(sensor, **variables_1)
+            if "8" in function_name:
+                sensor.formula = f"{function_name}(my_list)"
+                evaluate_modbus_expression(sensor, **variables_1)
+            if "16" in function_name:
+                evaluate_modbus_expression(sensor, **variables_1)
+            if "32" in function_name:
+                evaluate_modbus_expression(sensor, **variables_2)
+            elif "64" in function_name:
+                evaluate_modbus_expression(sensor, **variables_4)
+        sensor.formula = f"round(decode_8bit_int(my_list))"
+        evaluate_modbus_expression(sensor, **variables_1)
+
+    @mock.patch("NEMO.apps.sensors.evaluators.ModbusTcpClient", side_effect=mocked_modbus_client)
+    def test_read_coils_function(self, mock_args):
+        interlock_card_category = SensorCardCategory.objects.get(key="modbus_tcp")
+        card = SensorCard.objects.create(server="server1.nist.gov", port=502, category=interlock_card_category)
+        sensor = Sensor()
+        sensor.sensor_card = card
+        sensor.formula = "read_coils(1)"
+        # 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_staff_charges.py b/NEMO/tests/test_staff_charges.py
new file mode 100644
index 000000000..4263b93d5
--- /dev/null
+++ b/NEMO/tests/test_staff_charges.py
@@ -0,0 +1,29 @@
+from django.test import TestCase
+
+from NEMO.exceptions import ProjectChargeException
+from NEMO.models import StaffCharge
+from NEMO.policy import policy_class as policy
+from NEMO.tests.test_utilities import create_user_and_project
+
+
+class StaffChargesTest(TestCase):
+    def test_staff_charges_not_allowed(self):
+        customer, customer_project = create_user_and_project()
+        staff, staff_project = create_user_and_project(is_staff=True)
+        new_staff_charge = StaffCharge()
+        new_staff_charge.customer = customer
+        new_staff_charge.staff_member = staff
+        new_staff_charge.project = customer_project
+        # By default, should work
+        policy.check_billing_to_project(customer_project, customer, new_staff_charge, new_staff_charge)
+        # Stop allowing staff charges, should fail
+        customer_project.allow_staff_charges = False
+        customer_project.save()
+        self.assertRaises(
+            ProjectChargeException,
+            policy.check_billing_to_project,
+            customer_project,
+            customer,
+            new_staff_charge,
+            new_staff_charge,
+        )
diff --git a/NEMO/tests/test_tool.py b/NEMO/tests/test_tool.py
index 216778520..5460d0ece 100644
--- a/NEMO/tests/test_tool.py
+++ b/NEMO/tests/test_tool.py
@@ -24,7 +24,7 @@ def setUp(self):
         interlock = Interlock.objects.create(card=interlock_card, channel=1)
         cleanroom_interlock = Interlock.objects.create(card=interlock_card, channel=2)
         owner = User.objects.create(username="mctest", first_name="Testy", last_name="McTester")
-        cleanroom = Area.objects.create(name="cleanroom", welcome_message="Welcome")
+        cleanroom = Area.objects.create(name="cleanroom")
         tool_data = {
             "name": "test_tool",
             "_category": "test",
@@ -55,7 +55,8 @@ def setUp(self):
             "_policy_off_weekend": True,
             "visible": True,
         }
-        area_door = Door.objects.create(name="cleanroom door", area=cleanroom, interlock=cleanroom_interlock)
+        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()
diff --git a/NEMO/tests/test_urls.py b/NEMO/tests/test_urls.py
index 2166c97e7..f3d89bfdb 100644
--- a/NEMO/tests/test_urls.py
+++ b/NEMO/tests/test_urls.py
@@ -124,6 +124,10 @@
         },
     },
     "compose_email": {"login_id": 1},
+    "knowledge_base_item": {"kwargs": {"kind": "user", "item_id": 1}},
+    "knowledge_base_items_search": {"kwargs": {"kind": "user"}},
+    "knowledge_base_categories": {"kwargs": {"kind": "user"}},
+    "knowledge_base_all_in_one": {"kwargs": {"kind": "user"}},
 }
 
 urls_to_skip = [