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