NEMO-CE 7.3.6__py3-none-any.whl → 7.3.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
NEMO/admin.py CHANGED
@@ -287,7 +287,6 @@ class ToolAdmin(admin.ModelAdmin):
287
287
  "parent_tool",
288
288
  "_category",
289
289
  "_operation_mode",
290
- "_qualifications_never_expire",
291
290
  "_problem_shutdown_enabled",
292
291
  "_reservation_required",
293
292
  "_logout_grace_period",
@@ -342,6 +341,17 @@ class ToolAdmin(admin.ModelAdmin):
342
341
  )
343
342
  },
344
343
  ),
344
+ (
345
+ "Qualification expiration",
346
+ {
347
+ "fields": (
348
+ "_qualification_reminder_days",
349
+ "_qualification_expiration_days",
350
+ "_qualification_expiration_never_used_days",
351
+ "_qualification_notification_email",
352
+ )
353
+ },
354
+ ),
345
355
  (
346
356
  "Area Access",
347
357
  {
@@ -0,0 +1,103 @@
1
+ # Generated by Django 4.2.27 on 2026-01-26 20:42
2
+
3
+ import re
4
+
5
+ import django.core.validators
6
+ import django.db.models.deletion
7
+ from django.db import migrations, models
8
+
9
+ import NEMO.fields
10
+
11
+
12
+ def migrate_tool_qualification_expiration_forward(apps, schema_editor):
13
+ Tool = apps.get_model("NEMO", "Tool")
14
+ Customization = apps.get_model("NEMO", "Customization")
15
+ tool_qualification_reminder_days = Customization.objects.filter(name="tool_qualification_reminder_days").first()
16
+ tool_qualification_expiration_days = Customization.objects.filter(name="tool_qualification_expiration_days").first()
17
+ tool_qualification_expiration_never_used_days = Customization.objects.filter(
18
+ name="tool_qualification_expiration_never_used_days"
19
+ ).first()
20
+ tool_qualification_notification = Customization.objects.filter(name="tool_qualification_cc").first()
21
+ for tool in Tool.objects.filter(_qualifications_never_expire=False, parent_tool__isnull=True):
22
+ if tool_qualification_expiration_days or tool_qualification_expiration_never_used_days:
23
+ if tool_qualification_reminder_days and tool_qualification_reminder_days.value:
24
+ tool._qualification_reminder_days = tool_qualification_reminder_days.value
25
+ if tool_qualification_expiration_days and tool_qualification_expiration_days.value:
26
+ tool._qualification_expiration_days = tool_qualification_expiration_days.value
27
+ if tool_qualification_expiration_never_used_days and tool_qualification_expiration_never_used_days.value:
28
+ tool._qualification_expiration_never_used_days = tool_qualification_expiration_never_used_days.value
29
+ if tool_qualification_notification and tool_qualification_notification.value:
30
+ tool._qualification_notification_email = tool_qualification_notification.value
31
+ tool.save()
32
+ if tool_qualification_reminder_days:
33
+ tool_qualification_reminder_days.delete()
34
+ if tool_qualification_expiration_days:
35
+ tool_qualification_expiration_days.delete()
36
+ if tool_qualification_expiration_never_used_days:
37
+ tool_qualification_expiration_never_used_days.delete()
38
+ if tool_qualification_notification:
39
+ tool_qualification_notification.delete()
40
+
41
+
42
+ class Migration(migrations.Migration):
43
+
44
+ dependencies = [
45
+ ("NEMO", "0140_alter_user_options"),
46
+ ]
47
+
48
+ operations = [
49
+ migrations.AddField(
50
+ model_name="tool",
51
+ name="_qualification_expiration_days",
52
+ field=models.PositiveIntegerField(
53
+ db_column="qualification_expiration_days",
54
+ blank=True,
55
+ help_text="The number of days from the user’s last tool use until the qualification expires.",
56
+ null=True,
57
+ ),
58
+ ),
59
+ migrations.AddField(
60
+ model_name="tool",
61
+ name="_qualification_expiration_never_used_days",
62
+ field=models.PositiveIntegerField(
63
+ db_column="qualification_expiration_never_used_days",
64
+ blank=True,
65
+ help_text="Number of days from the user's first qualification until the qualification expires (if the user never used the tool).",
66
+ null=True,
67
+ ),
68
+ ),
69
+ migrations.AddField(
70
+ model_name="tool",
71
+ name="_qualification_notification_email",
72
+ field=NEMO.fields.MultiEmailField(
73
+ db_column="qualification_notification_email",
74
+ blank=True,
75
+ help_text="The email addresses to cc on tool qualification expiration and on reminders. Separate multiple emails with commas.",
76
+ max_length=2000,
77
+ null=True,
78
+ ),
79
+ ),
80
+ migrations.AddField(
81
+ model_name="tool",
82
+ name="_qualification_reminder_days",
83
+ field=models.CharField(
84
+ db_column="qualification_reminder_days",
85
+ blank=True,
86
+ help_text="The (optional) number of days to send a reminder prior to the user's tool qualification expiration (below). A comma-separated list can be used for multiple reminders. This applies to both expiration cases.",
87
+ max_length=255,
88
+ null=True,
89
+ validators=[
90
+ django.core.validators.RegexValidator(
91
+ re.compile("^\\d+(?:,\\d+)*\\Z"),
92
+ code="invalid",
93
+ message="Enter only digits separated by commas.",
94
+ )
95
+ ],
96
+ ),
97
+ ),
98
+ migrations.RunPython(migrate_tool_qualification_expiration_forward, migrations.RunPython.noop),
99
+ migrations.RemoveField(
100
+ model_name="tool",
101
+ name="_qualifications_never_expire",
102
+ ),
103
+ ]
NEMO/models.py CHANGED
@@ -1349,10 +1349,32 @@ class Tool(SerializationByNameModel):
1349
1349
  _interlock = models.OneToOneField(
1350
1350
  "Interlock", db_column="interlock_id", blank=True, null=True, on_delete=models.SET_NULL
1351
1351
  )
1352
- _qualifications_never_expire = models.BooleanField(
1353
- default=False,
1354
- db_column="qualifications_never_expire",
1355
- help_text="Check this box if qualifications for this tool should never expire (even if the tool qualification expiration feature is enabled).",
1352
+ # Qualification expiration fields
1353
+ _qualification_reminder_days = models.CharField(
1354
+ db_column="qualification_reminder_days",
1355
+ null=True,
1356
+ blank=True,
1357
+ max_length=CHAR_FIELD_MEDIUM_LENGTH,
1358
+ validators=[validate_comma_separated_integer_list],
1359
+ help_text="The (optional) number of days to send a reminder prior to the user's tool qualification expiration (below). A comma-separated list can be used for multiple reminders. This applies to both expiration cases.",
1360
+ )
1361
+ _qualification_expiration_days = models.PositiveIntegerField(
1362
+ db_column="qualification_expiration_days",
1363
+ null=True,
1364
+ blank=True,
1365
+ help_text="The number of days from the user’s last tool use until the qualification expires.",
1366
+ )
1367
+ _qualification_expiration_never_used_days = models.PositiveIntegerField(
1368
+ db_column="qualification_expiration_never_used_days",
1369
+ null=True,
1370
+ blank=True,
1371
+ help_text="Number of days from the user's first qualification until the qualification expires (if the user never used the tool).",
1372
+ )
1373
+ _qualification_notification_email = fields.MultiEmailField(
1374
+ db_column="qualification_notification_email",
1375
+ null=True,
1376
+ blank=True,
1377
+ help_text="The email addresses to cc on tool qualification expiration and on reminders. Separate multiple emails with commas.",
1356
1378
  )
1357
1379
  # Policy fields:
1358
1380
  _requires_area_access = TreeForeignKey(
@@ -1530,17 +1552,6 @@ class Tool(SerializationByNameModel):
1530
1552
  self.raise_setter_error_if_child_tool("category")
1531
1553
  self._category = value
1532
1554
 
1533
- @property
1534
- def qualifications_never_expire(self):
1535
- return (
1536
- self.parent_tool.qualifications_never_expire if self.is_child_tool() else self._qualifications_never_expire
1537
- )
1538
-
1539
- @qualifications_never_expire.setter
1540
- def qualifications_never_expire(self, value):
1541
- self.raise_setter_error_if_child_tool("qualifications_never_expire")
1542
- self._qualifications_never_expire = value
1543
-
1544
1555
  @property
1545
1556
  def description(self):
1546
1557
  return self.parent_tool.description if self.is_child_tool() else self._description
@@ -1689,6 +1700,56 @@ class Tool(SerializationByNameModel):
1689
1700
  self.raise_setter_error_if_child_tool("interlock")
1690
1701
  self._interlock = value
1691
1702
 
1703
+ @property
1704
+ def qualification_reminder_days(self):
1705
+ return (
1706
+ self.parent_tool.qualification_reminder_days if self.is_child_tool() else self._qualification_reminder_days
1707
+ )
1708
+
1709
+ @qualification_reminder_days.setter
1710
+ def qualification_reminder_days(self, value):
1711
+ self.raise_setter_error_if_child_tool("qualification_reminder_days")
1712
+ self._qualification_reminder_days = value
1713
+
1714
+ @property
1715
+ def qualification_expiration_days(self):
1716
+ return (
1717
+ self.parent_tool.qualification_expiration_days
1718
+ if self.is_child_tool()
1719
+ else self._qualification_expiration_days
1720
+ )
1721
+
1722
+ @qualification_expiration_days.setter
1723
+ def qualification_expiration_days(self, value):
1724
+ self.raise_setter_error_if_child_tool("qualification_expiration_days")
1725
+ self._qualification_expiration_days = value
1726
+
1727
+ @property
1728
+ def qualification_expiration_never_used_days(self):
1729
+ return (
1730
+ self.parent_tool.qualification_expiration_never_used_days
1731
+ if self.is_child_tool()
1732
+ else self._qualification_expiration_never_used_days
1733
+ )
1734
+
1735
+ @qualification_expiration_never_used_days.setter
1736
+ def qualification_expiration_never_used_days(self, value):
1737
+ self.raise_setter_error_if_child_tool("qualification_expiration_never_used_days")
1738
+ self._qualification_expiration_never_used_days = value
1739
+
1740
+ @property
1741
+ def qualification_notification_email(self):
1742
+ return (
1743
+ self.parent_tool.qualification_notification_email
1744
+ if self.is_child_tool()
1745
+ else self._qualification_notification_email
1746
+ )
1747
+
1748
+ @qualification_notification_email.setter
1749
+ def qualification_notification_email(self, value):
1750
+ self.raise_setter_error_if_child_tool("qualification_notification_email")
1751
+ self._qualification_notification_email = value
1752
+
1692
1753
  @property
1693
1754
  def requires_area_access(self):
1694
1755
  return self.parent_tool.requires_area_access if self.is_child_tool() else self._requires_area_access
@@ -2379,6 +2440,11 @@ class Tool(SerializationByNameModel):
2379
2440
  else:
2380
2441
  raise ValueError(f"A {'project' if user else 'user'} must be provided for usage questions")
2381
2442
 
2443
+ def get_qualification_reminder_days(self) -> List[int]:
2444
+ if not self.qualification_reminder_days:
2445
+ return []
2446
+ return [int(days) for days in self.qualification_reminder_days.split(",") if days]
2447
+
2382
2448
  def clean(self):
2383
2449
  errors = {}
2384
2450
  if self.parent_tool_id:
@@ -623,97 +623,14 @@
623
623
  </div>
624
624
  </div>
625
625
  <div class="customization-separation" style="margin-bottom: 15px"></div>
626
- <h3 class="customization-section-title">Tool qualification expiration</h3>
627
- <p>
628
- If active, this feature will remove tool qualification from a user if the user has not used the tool after a while or never used it since qualified (configured separately).
629
- <p>
630
- The <a href="{% url 'customization' 'templates' %}?#tool_qualification_expiration_email_id">user tool qualification expiration email</a> need to be set to enable this feature.
631
- </p>
632
- <br />
633
- <div class="form-group {% if errors.tool_qualification_reminder_days %}has-error{% endif %}">
634
- <label class="control-label col-md-3" for="tool_qualification_reminder_days">Reminder days</label>
635
- <div class="col-md-5">
636
- <input type="text"
637
- id="tool_qualification_reminder_days"
638
- name="tool_qualification_reminder_days"
639
- class="form-control"
640
- value="{% if errors.tool_qualification_reminder_days %}{{ errors.tool_qualification_reminder_days.value }}{% else %}{{ tool_qualification_reminder_days }}{% endif %}" />
641
- </div>
642
- <div class="col-md-offset-3 col-md-9 help-block light-grey">
643
- {% if errors.tool_qualification_reminder_days %}
644
- {{ errors.tool_qualification_reminder_days.error }}
645
- {% else %}
646
- The (optional) number of days to send a reminder prior to the user's tool qualification expiration (below). A comma-separated list can be used for multiple reminders. This applies to both expiration cases.
647
- {% endif %}
648
- </div>
649
- </div>
650
- <div class="form-group {% if errors.tool_qualification_expiration_days %}has-error{% endif %}">
651
- <label class="control-label col-md-3" for="tool_qualification_expiration_days">
652
- Expiration days (previous tool usage)
653
- </label>
654
- <div class="col-md-5">
655
- <input type="number"
656
- step="1"
657
- id="tool_qualification_expiration_days"
658
- name="tool_qualification_expiration_days"
659
- class="form-control"
660
- value="{% if errors.tool_qualification_expiration_days %}{{ errors.tool_qualification_expiration_days.value }}{% else %}{{ tool_qualification_expiration_days }}{% endif %}" />
661
- </div>
662
- <div class="col-md-offset-3 col-md-9 help-block light-grey">
663
- {% if errors.tool_qualification_expiration_days %}
664
- {{ errors.tool_qualification_expiration_days.error }}
665
- {% else %}
666
- The number of days before the user's tool qualification expires since the user last used the tool.
667
- {% endif %}
668
- </div>
669
- </div>
670
- <div class="form-group {% if errors.tool_qualification_expiration_never_used_days %}has-error{% endif %}">
671
- <label class="control-label col-md-3" for="tool_qualification_expiration_never_used_days">
672
- Expiration days (no tool usage)
673
- </label>
674
- <div class="col-md-5">
675
- <input type="number"
676
- step="1"
677
- id="tool_qualification_expiration_never_used_days"
678
- name="tool_qualification_expiration_never_used_days"
679
- class="form-control"
680
- value="{% if errors.tool_qualification_expiration_never_used_days %}{{ errors.tool_qualification_expiration_never_used_days.value }}{% else %}{{ tool_qualification_expiration_never_used_days }}{% endif %}" />
681
- </div>
682
- <div class="col-md-offset-3 col-md-9 help-block light-grey">
683
- {% if errors.tool_qualification_expiration_never_used_days %}
684
- {{ errors.tool_qualification_expiration_never_used_days.error }}
685
- {% else %}
686
- The number of days before the user's tool qualification expires since the user qualified for the first time.
687
- {% endif %}
688
- </div>
689
- </div>
690
- <div class="form-group {% if errors.tool_qualification_cc %}has-error{% endif %}">
691
- <label class="control-label col-md-3" for="tool_qualification_cc">Reminder/expiration CC</label>
692
- <div class="col-md-5">
693
- <input type="text"
694
- id="tool_qualification_cc"
695
- name="tool_qualification_cc"
696
- class="form-control"
697
- value="{% if errors.tool_qualification_cc %}{{ errors.tool_qualification_cc.value }}{% else %}{{ tool_qualification_cc }}{% endif %}"
698
- placeholder="information@example.org" />
699
- </div>
700
- <div class="col-md-offset-3 col-md-9 help-block light-grey">
701
- {% if errors.tool_qualification_cc %}
702
- {{ errors.tool_qualification_cc.error }}
703
- {% else %}
704
- Extra email address to copy when a user's tool qualification reminder/expiration email is sent. A comma-separated list can be used.
705
- {% endif %}
706
- </div>
707
- </div>
708
- <div class="customization-separation" style="margin-bottom: 15px"></div>
709
- <div class="text-center">{% button type="save" value="Save settings" %}</div>
710
- </div>
711
- <script type="text/javascript">
626
+ <div class="text-center">{% button type="save" value="Save settings" %}</div>
627
+ </div>
628
+ <script type="text/javascript">
712
629
  $("#tool-tab-link").click(function() {setTimeout(on_tool_tab_show, 50)});
713
630
  function on_tool_tab_show()
714
631
  {
715
632
  auto_size_textarea(document.getElementById('tool_control_configuration_setting_template'))
716
633
  }
717
634
  on_tool_tab_show();
718
- </script>
719
- </form>
635
+ </script>
636
+ </form>
@@ -17,7 +17,7 @@
17
17
  History</a>
18
18
  </li>
19
19
  {% endif %}
20
- {% if show_usage_data_tab and not hide_data_history or show_usage_data_tab and user.is_any_part_of_staff %}
20
+ {% if not hide_data_history or user.is_any_part_of_staff %}
21
21
  <li style="width: 48%; text-align: center">
22
22
  <a href="#usage_data" style="padding: 10px 5px;" onclick="load_usage_data('{{ tool.id }}');">Usage Data History</a>
23
23
  </li>
@@ -50,7 +50,7 @@
50
50
  <a href="#config" onclick="load_config_history('{{ tool.id }}');">Config History</a>
51
51
  </li>
52
52
  {% endif %}
53
- {% if show_usage_data_tab and not hide_data_history or show_usage_data_tab and user.is_any_part_of_staff %}
53
+ {% if not hide_data_history or user.is_any_part_of_staff %}
54
54
  <li>
55
55
  <a href="#usage_data" onclick="load_usage_data('{{ tool.id }}');">Run Data History</a>
56
56
  </li>
@@ -98,21 +98,61 @@
98
98
  </div>
99
99
  </div>
100
100
  </form>
101
- {% if run_data_table.rows or pre_run_data_table.rows %}
101
+ {% if data_table.rows or run_data_table.rows or pre_run_data_table.rows %}
102
102
  <ul class="nav nav-tabs" id="usage-tabs">
103
+ {% if data_table.rows %}
104
+ <li class="active">
105
+ <a data-toggle="tab" href="#run-data-tab">Usage history</a>
106
+ </li>
107
+ {% endif %}
103
108
  {% if pre_run_data_table.rows %}
104
- <li class="{% if pre_run_data_table.rows %}active{% endif %}">
109
+ <li>
105
110
  <a data-toggle="tab" href="#pre-run-data-tab">Pre-Run Data</a>
106
111
  </li>
107
112
  {% endif %}
108
113
  {% if run_data_table.rows %}
109
- <li class="{% if not pre_run_data_table.rows %}active{% endif %}">
110
- <a data-toggle="tab" href="#run-data-tab">Run Data</a>
114
+ <li>
115
+ <a data-toggle="tab" href="#post-run-data-tab">Post-Run Data</a>
111
116
  </li>
112
117
  {% endif %}
113
118
  </ul>
114
119
  <div class="tab-content" style="margin-bottom: 0;">
115
- <div class="tab-pane {% if pre_run_data_table.rows %}active{% endif %}" id="pre-run-data-tab">
120
+ <div class="tab-pane active" id="run-data-tab">
121
+ <div class="col-md-12">
122
+ <div class="form-group pull-right extra-side-padding" style="padding-top: 10px">
123
+ {% button type="export" value="Export" onclick="load_usage_data('"|concat:tool_id|concat:"', 'all');" %}
124
+ </div>
125
+ </div>
126
+ <div class="panel-body">
127
+ {% if data_table.rows %}
128
+ <table class="table table-bordered table-condensed" style="margin-top: 10px">
129
+ <thead>
130
+ <tr>
131
+ {% for header in data_table.flat_headers %}<th>{{ header }}</th>{% endfor %}
132
+ </tr>
133
+ </thead>
134
+ <tbody>
135
+ {% for row in data_table.flat_rows %}
136
+ <tr>
137
+ {% for item in row %}
138
+ <td>
139
+ {% if item|class_name == "list" %}
140
+ {{ item|join:", " }}
141
+ {% else %}
142
+ {{ item|default_if_none:"" }}
143
+ {% endif %}
144
+ </td>
145
+ {% endfor %}
146
+ </tr>
147
+ {% endfor %}
148
+ </tbody>
149
+ </table>
150
+ {% else %}
151
+ <span class="italic">No run data was found between these dates</span>
152
+ {% endif %}
153
+ </div>
154
+ </div>
155
+ <div class="tab-pane" id="pre-run-data-tab">
116
156
  <div class="col-md-12">
117
157
  <div class="form-group pull-right extra-side-padding" style="padding-top: 10px">
118
158
  {% button type="export" value="Export" onclick="load_usage_data('"|concat:tool_id|concat:"', 'pre-run');" %}
@@ -147,7 +187,7 @@
147
187
  {% endif %}
148
188
  </div>
149
189
  </div>
150
- <div class="tab-pane {% if not pre_run_data_table.rows %}active{% endif %}" id="run-data-tab">
190
+ <div class="tab-pane" id="post-run-data-tab">
151
191
  <div class="col-md-12">
152
192
  <div class="form-group pull-right extra-side-padding" style="padding-top: 10px">
153
193
  {% button type="export" value="Export" onclick="load_usage_data('"|concat:tool_id|concat:"', 'run');" %}
@@ -7,7 +7,7 @@ from django.utils import timezone
7
7
 
8
8
  from NEMO.models import Account, EmailLog, Project, Qualification, Tool, UsageEvent, User
9
9
  from NEMO.tests.test_utilities import NEMOTestCaseMixin
10
- from NEMO.views.customization import EmailsCustomization, ToolCustomization
10
+ from NEMO.views.customization import EmailsCustomization
11
11
  from NEMO.views.timed_services import do_manage_tool_qualifications
12
12
 
13
13
 
@@ -45,7 +45,8 @@ class ToolQualificationTestCase(NEMOTestCaseMixin, TestCase):
45
45
  )
46
46
 
47
47
  # Never used days set but not regular days
48
- ToolCustomization.set("tool_qualification_expiration_never_used_days", 3)
48
+ self.tool.qualification_expiration_never_used_days = 3
49
+ self.tool.save()
49
50
  EmailsCustomization.set("user_office_email_address", "user_office@example.com")
50
51
  # Trigger the expiration timed service
51
52
  do_manage_tool_qualifications()
@@ -59,7 +60,8 @@ class ToolQualificationTestCase(NEMOTestCaseMixin, TestCase):
59
60
  Qualification.objects.create(tool=self.tool, user=self.user, qualified_on=qualification_date)
60
61
 
61
62
  # Expiration days are set but never used days are not
62
- ToolCustomization.set("tool_qualification_expiration_days", 3)
63
+ self.tool.qualification_expiration_days = 3
64
+ self.tool.save()
63
65
  EmailsCustomization.set("user_office_email_address", "user_office@example.com")
64
66
  # Trigger the expiration timed service
65
67
  do_manage_tool_qualifications()
@@ -72,8 +74,9 @@ class ToolQualificationTestCase(NEMOTestCaseMixin, TestCase):
72
74
  qualification_date = datetime.today() - timedelta(days=3)
73
75
  Qualification.objects.create(tool=self.tool, user=self.user, qualified_on=qualification_date)
74
76
 
75
- ToolCustomization.set("tool_qualification_expiration_never_used_days", 3)
76
- ToolCustomization.set("tool_qualification_expiration_days", 3)
77
+ self.tool.qualification_expiration_days = 3
78
+ self.tool.qualification_expiration_never_used_days = 3
79
+ self.tool.save()
77
80
  # Trigger the expiration timed service
78
81
  do_manage_tool_qualifications()
79
82
  # Qualification was NOT removed
@@ -84,8 +87,9 @@ class ToolQualificationTestCase(NEMOTestCaseMixin, TestCase):
84
87
  qualification_date = datetime.today() - timedelta(days=3)
85
88
  Qualification.objects.create(tool=self.tool, user=self.user, qualified_on=qualification_date)
86
89
 
87
- ToolCustomization.set("tool_qualification_expiration_never_used_days", 3)
88
- ToolCustomization.set("tool_qualification_expiration_days", 3)
90
+ self.tool.qualification_expiration_days = 3
91
+ self.tool.qualification_expiration_never_used_days = 3
92
+ self.tool.save()
89
93
  EmailsCustomization.set("user_office_email_address", "user_office@example.com")
90
94
  # Trigger the expiration timed service
91
95
  do_manage_tool_qualifications()
@@ -96,15 +100,10 @@ class ToolQualificationTestCase(NEMOTestCaseMixin, TestCase):
96
100
  mock_exist.return_value = True
97
101
  mock_open.return_value = ContentFile(b"Email template", name="template")
98
102
 
99
- # Tool is exempt
100
- self.tool._qualifications_never_expire = True
101
- self.tool.save()
102
-
103
103
  qualification_date = datetime.today() - timedelta(days=3)
104
104
  Qualification.objects.create(tool=self.tool, user=self.user, qualified_on=qualification_date)
105
105
 
106
- ToolCustomization.set("tool_qualification_expiration_never_used_days", 3)
107
- ToolCustomization.set("tool_qualification_expiration_days", 3)
106
+ # Tool is exempt, meaning no qualification expiration need to be added
108
107
  EmailsCustomization.set("user_office_email_address", "user_office@example.com")
109
108
  # Trigger the expiration timed service
110
109
  do_manage_tool_qualifications()
@@ -121,7 +120,8 @@ class ToolQualificationTestCase(NEMOTestCaseMixin, TestCase):
121
120
  user=self.user, operator=self.user, tool=self.tool, project=self.project, start=usage_date
122
121
  )
123
122
 
124
- ToolCustomization.set("tool_qualification_expiration_days", 3)
123
+ self.tool.qualification_expiration_days = 3
124
+ self.tool.save()
125
125
  EmailsCustomization.set("user_office_email_address", "user_office@example.com")
126
126
  # Trigger the expiration timed service
127
127
  do_manage_tool_qualifications()
@@ -134,7 +134,8 @@ class ToolQualificationTestCase(NEMOTestCaseMixin, TestCase):
134
134
  qualification_date = datetime.today() - timedelta(days=2)
135
135
  Qualification.objects.create(tool=self.tool, user=self.user, qualified_on=qualification_date)
136
136
 
137
- ToolCustomization.set("tool_qualification_expiration_never_used_days", 3)
137
+ self.tool.qualification_expiration_never_used_days = 3
138
+ self.tool.save()
138
139
  EmailsCustomization.set("user_office_email_address", "user_office@example.com")
139
140
  # Trigger the expiration timed service
140
141
  do_manage_tool_qualifications()
@@ -151,7 +152,8 @@ class ToolQualificationTestCase(NEMOTestCaseMixin, TestCase):
151
152
  user=self.user, operator=self.user, tool=self.tool, project=self.project, start=usage_date
152
153
  )
153
154
 
154
- ToolCustomization.set("tool_qualification_expiration_days", 3)
155
+ self.tool.qualification_expiration_days = 3
156
+ self.tool.save()
155
157
  EmailsCustomization.set("user_office_email_address", "user_office@example.com")
156
158
  # Trigger the expiration timed service
157
159
  do_manage_tool_qualifications()
@@ -172,7 +174,8 @@ class ToolQualificationTestCase(NEMOTestCaseMixin, TestCase):
172
174
  user=self.user, operator=self.user, tool=self.tool, project=self.project, start=usage_date
173
175
  )
174
176
 
175
- ToolCustomization.set("tool_qualification_expiration_days", 3)
177
+ self.tool.qualification_expiration_days = 3
178
+ self.tool.save()
176
179
  EmailsCustomization.set("user_office_email_address", "user_office@example.com")
177
180
  # Trigger the expiration timed service
178
181
  do_manage_tool_qualifications()
@@ -189,7 +192,8 @@ class ToolQualificationTestCase(NEMOTestCaseMixin, TestCase):
189
192
  qualification_date = datetime.today() - timedelta(days=3)
190
193
  Qualification.objects.create(tool=self.tool, user=self.user, qualified_on=qualification_date)
191
194
 
192
- ToolCustomization.set("tool_qualification_expiration_never_used_days", 3)
195
+ self.tool.qualification_expiration_never_used_days = 3
196
+ self.tool.save()
193
197
  EmailsCustomization.set("user_office_email_address", "user_office@example.com")
194
198
  # Trigger the expiration timed service
195
199
  do_manage_tool_qualifications()
@@ -210,9 +214,10 @@ class ToolQualificationTestCase(NEMOTestCaseMixin, TestCase):
210
214
  user=self.user, operator=self.user, tool=self.tool, project=self.project, start=usage_date
211
215
  )
212
216
 
213
- ToolCustomization.set("tool_qualification_expiration_days", 3)
217
+ self.tool.qualification_expiration_days = 3
218
+ self.tool.qualification_notification_email = "qualif_cc@example.com"
219
+ self.tool.save()
214
220
  EmailsCustomization.set("user_office_email_address", "user_office@example.com")
215
- ToolCustomization.set("tool_qualification_cc", "qualif_cc@example.com")
216
221
  # Set alternate email for user
217
222
  prefs = self.user.get_preferences()
218
223
  prefs.email_alternate = "user.alternate@example.com"
@@ -239,9 +244,10 @@ class ToolQualificationTestCase(NEMOTestCaseMixin, TestCase):
239
244
  qualification_date = datetime.today() - timedelta(days=20)
240
245
  Qualification.objects.create(tool=self.tool, user=self.user, qualified_on=qualification_date)
241
246
 
242
- ToolCustomization.set("tool_qualification_expiration_never_used_days", 3)
247
+ self.tool.qualification_expiration_never_used_days = 3
248
+ self.tool.qualification_notification_email = "qualif_cc@example.com"
249
+ self.tool.save()
243
250
  EmailsCustomization.set("user_office_email_address", "user_office@example.com")
244
- ToolCustomization.set("tool_qualification_cc", "qualif_cc@example.com")
245
251
  # Set alternate email for user
246
252
  prefs = self.user.get_preferences()
247
253
  prefs.email_alternate = "user.alternate@example.com"
@@ -273,9 +279,10 @@ class ToolQualificationTestCase(NEMOTestCaseMixin, TestCase):
273
279
  user=self.user, operator=self.user, tool=self.tool, project=self.project, start=usage_date
274
280
  )
275
281
 
276
- ToolCustomization.set("tool_qualification_expiration_days", 3)
277
282
  # Set reminder 2 days before expiration
278
- ToolCustomization.set("tool_qualification_reminder_days", 2)
283
+ self.tool.qualification_expiration_days = 3
284
+ self.tool.qualification_reminder_days = "3,2"
285
+ self.tool.save()
279
286
  # Trigger the expiration timed service
280
287
  do_manage_tool_qualifications()
281
288
  # Qualification was NOT removed (3 days disqualified, but only 2 days since qualification)
@@ -292,9 +299,10 @@ class ToolQualificationTestCase(NEMOTestCaseMixin, TestCase):
292
299
  Qualification.objects.create(tool=self.tool, user=self.user, qualified_on=qualification_date)
293
300
  EmailsCustomization.set("user_office_email_address", "user_office@example.com")
294
301
 
295
- ToolCustomization.set("tool_qualification_expiration_never_used_days", 3)
296
302
  # Set reminder 2 days before expiration
297
- ToolCustomization.set("tool_qualification_reminder_days", 2)
303
+ self.tool.qualification_expiration_never_used_days = 3
304
+ self.tool.qualification_reminder_days = "3,2"
305
+ self.tool.save()
298
306
  # Trigger the expiration timed service
299
307
  do_manage_tool_qualifications()
300
308
  # Qualification was NOT removed (3 days disqualified, but only 2 days since qualification)
@@ -315,9 +323,10 @@ class ToolQualificationTestCase(NEMOTestCaseMixin, TestCase):
315
323
  user=self.user, operator=self.user, tool=self.tool, project=self.project, start=usage_date
316
324
  )
317
325
 
318
- ToolCustomization.set("tool_qualification_expiration_days", 3)
319
326
  # Set reminder 1 day before expiration
320
- ToolCustomization.set("tool_qualification_reminder_days", 1)
327
+ self.tool.qualification_expiration_days = 3
328
+ self.tool.qualification_reminder_days = "1"
329
+ self.tool.save()
321
330
  # Trigger the expiration timed service
322
331
  do_manage_tool_qualifications()
323
332
  # Qualification was NOT removed (3 days disqualified, but only 2 days since qualification)
@@ -334,9 +343,10 @@ class ToolQualificationTestCase(NEMOTestCaseMixin, TestCase):
334
343
  Qualification.objects.create(tool=self.tool, user=self.user, qualified_on=qualification_date)
335
344
  EmailsCustomization.set("user_office_email_address", "user_office@example.com")
336
345
 
337
- ToolCustomization.set("tool_qualification_expiration_never_used_days", 3)
338
346
  # Set reminder 1 day before expiration
339
- ToolCustomization.set("tool_qualification_reminder_days", 1)
347
+ self.tool.qualification_expiration_never_used_days = 3
348
+ self.tool.qualification_reminder_days = "1"
349
+ self.tool.save()
340
350
  # Trigger the expiration timed service
341
351
  do_manage_tool_qualifications()
342
352
  # Qualification was NOT removed (3 days disqualified, but only 2 days since qualification)
NEMO/views/api.py CHANGED
@@ -413,10 +413,15 @@ class ToolViewSet(ModelViewSet):
413
413
  "_category": string_filters,
414
414
  "_operational": boolean_filters,
415
415
  "_location": string_filters,
416
+ "_missed_reservation_threshold": number_filters,
417
+ "_late_cancellation_reservation_threshold": number_filters,
418
+ "_qualification_reminder_days": string_filters,
419
+ "_qualification_expiration_days": number_filters,
420
+ "_qualification_expiration_never_used_days": number_filters,
421
+ "_qualification_notification_email": string_filters,
416
422
  "_requires_area_access": key_filters,
417
423
  "_requires_area_occupancy_minimum": number_filters,
418
424
  "_problem_shutdown_enabled": boolean_filters,
419
- "_qualifications_never_expire": boolean_filters,
420
425
  }
421
426
 
422
427
 
NEMO/views/calendar.py CHANGED
@@ -440,7 +440,6 @@ def configuration_agenda_event_feed(request, start, end):
440
440
  if item_id and not all_tools:
441
441
  events = events.filter(**{f"{item_type.value}__id": item_id})
442
442
 
443
- # TODO: Filter events that only have to do with the current user's primary, backup and superuser tools.
444
443
  personal_schedule = request.GET.get("personal_schedule")
445
444
 
446
445
  dictionary = {
@@ -877,8 +876,8 @@ def modify_reservation(request, current_user, start_delta, end_delta):
877
876
  reservation_to_cancel.cancellation_time = now
878
877
  reservation_to_cancel.cancelled_by = current_user
879
878
 
880
- if reservation_to_cancel.start != new_reservation.start:
881
- # Only check if it has a different time, since penalties for extending a reservation
879
+ if reservation_to_cancel.start < new_reservation.start:
880
+ # Only check if it has a different time that is later. If moved earlier, it's fine
882
881
  check_for_late_cancellation(current_user, reservation_to_cancel)
883
882
 
884
883
  policy_problems, overridable = policy.check_to_save_reservation(
@@ -683,10 +683,6 @@ class ToolCustomization(CustomizationBase):
683
683
  "tool_control_use_for_other_remote": "Use this tool for a remote project",
684
684
  "tool_control_note_show": "",
685
685
  "tool_control_note_copy_reservation": "",
686
- "tool_qualification_reminder_days": "",
687
- "tool_qualification_expiration_days": "",
688
- "tool_qualification_expiration_never_used_days": "",
689
- "tool_qualification_cc": "",
690
686
  "tool_problem_max_image_size_pixels": "750",
691
687
  "tool_problem_send_to_all_qualified_users": "",
692
688
  "tool_problem_allow_regular_user_preferences": "",
@@ -709,17 +705,13 @@ class ToolCustomization(CustomizationBase):
709
705
  if (
710
706
  name
711
707
  in [
712
- "tool_qualification_expiration_days",
713
708
  "tool_problem_max_image_size_pixels",
714
709
  "tool_configuration_near_future_days",
715
710
  ]
716
711
  and value
717
712
  ):
718
713
  validate_integer(value)
719
- if name == "tool_qualification_reminder_days" and value:
720
- # Check that we have an integer or a list of integers
721
- validate_comma_separated_integer_list(value)
722
- elif name == "tool_qualification_cc" or name == "tool_grant_access_emails":
714
+ if name == "tool_grant_access_emails":
723
715
  recipients = tuple([e for e in value.split(",") if e])
724
716
  for email in recipients:
725
717
  validate_email(email)
@@ -898,24 +898,17 @@ def manage_tool_qualifications(request):
898
898
 
899
899
  def do_manage_tool_qualifications(request=None):
900
900
  user_office_email = EmailsCustomization.get("user_office_email_address")
901
- qualification_reminder_days = ToolCustomization.get("tool_qualification_reminder_days")
902
- qualification_expiration_days = ToolCustomization.get("tool_qualification_expiration_days")
903
- qualification_expiration_never_used = ToolCustomization.get("tool_qualification_expiration_never_used_days")
904
901
  template = get_media_file_contents("tool_qualification_expiration_email.html")
905
902
  if user_office_email and template:
906
- if qualification_expiration_days or qualification_expiration_never_used:
907
- qualification_expiration_days = quiet_int(qualification_expiration_days, None)
908
- qualification_expiration_never_used = quiet_int(qualification_expiration_never_used, None)
909
- # Only applies if there is no qualification level or the qualification level has qualify_user set to True
910
- for qualification in (
911
- Qualification.objects.filter(
912
- user__is_active=True, user__is_staff=False, tool___qualifications_never_expire=False
913
- )
914
- .filter(Q(qualification_level__isnull=True) | Q(qualification_level__qualify_user=True))
915
- .prefetch_related("tool", "user")
916
- ):
917
- user = qualification.user
918
- tool = qualification.tool
903
+ # Only applies if there is no qualification level or the qualification level has qualify_user set to True
904
+ for qualification in (
905
+ Qualification.objects.filter(user__is_active=True, user__is_staff=False)
906
+ .filter(Q(qualification_level__isnull=True) | Q(qualification_level__qualify_user=True))
907
+ .prefetch_related("tool", "user")
908
+ ):
909
+ user = qualification.user
910
+ tool = qualification.tool
911
+ if tool.qualification_expiration_days or tool.qualification_expiration_never_used_days:
919
912
  last_tool_use = None
920
913
  try:
921
914
  # Last tool use cannot be before the last time they qualified
@@ -924,15 +917,15 @@ def do_manage_tool_qualifications(request=None):
924
917
  qualification.qualified_on,
925
918
  )
926
919
  expiration_date: date = (
927
- last_tool_use + timedelta(days=qualification_expiration_days)
928
- if qualification_expiration_days
920
+ last_tool_use + timedelta(days=tool.qualification_expiration_days)
921
+ if tool.qualification_expiration_days
929
922
  else None
930
923
  )
931
924
  except UsageEvent.DoesNotExist:
932
925
  # User never used the tool, use the qualification date
933
926
  expiration_date: date = (
934
- qualification.qualified_on + timedelta(days=qualification_expiration_never_used)
935
- if qualification_expiration_never_used
927
+ qualification.qualified_on + timedelta(days=tool.qualification_expiration_never_used_days)
928
+ if tool.qualification_expiration_never_used_days
936
929
  else None
937
930
  )
938
931
  # Check for staff on tools
@@ -945,25 +938,37 @@ def do_manage_tool_qualifications(request=None):
945
938
  "Qualification expired",
946
939
  )
947
940
  send_tool_qualification_expiring_email(
948
- qualification, last_tool_use, expiration_date, request=request
941
+ qualification,
942
+ last_tool_use,
943
+ expiration_date,
944
+ tool.qualification_notification_email,
945
+ request=request,
949
946
  )
950
- if qualification_reminder_days:
951
- for remaining_days in [int(days) for days in qualification_reminder_days.split(",")]:
947
+ if tool.get_qualification_reminder_days():
948
+ for remaining_days in tool.get_qualification_reminder_days():
952
949
  if expiration_date - timedelta(days=remaining_days) == date.today():
953
950
  send_tool_qualification_expiring_email(
954
- qualification, last_tool_use, expiration_date, remaining_days, request=request
951
+ qualification,
952
+ last_tool_use,
953
+ expiration_date,
954
+ tool.qualification_notification_email,
955
+ remaining_days,
956
+ request=request,
955
957
  )
956
958
  return HttpResponse()
957
959
 
958
960
 
959
961
  def send_tool_qualification_expiring_email(
960
- qualification: Qualification, last_tool_use: date, expiration_date: date, remaining_days: int = None, request=None
962
+ qualification: Qualification,
963
+ last_tool_use: date,
964
+ expiration_date: date,
965
+ cc_email: List[str],
966
+ remaining_days: int = None,
967
+ request=None,
961
968
  ):
962
969
  user_office_email = EmailsCustomization.get("user_office_email_address")
963
970
  template = get_media_file_contents("tool_qualification_expiration_email.html")
964
971
  # Add extra cc emails
965
- tool_qualification_cc = ToolCustomization.get("tool_qualification_cc")
966
- ccs = [e for e in tool_qualification_cc.split(",") if e]
967
972
  if remaining_days:
968
973
  subject_expiration = f" expires in {remaining_days} days!"
969
974
  else:
@@ -980,7 +985,11 @@ def send_tool_qualification_expiring_email(
980
985
  message = render_email_template(template, dictionary, request)
981
986
  email_notification = qualification.user.get_preferences().email_send_tool_qualification_expiration_emails
982
987
  qualification.user.email_user(
983
- subject=subject, message=message, from_email=user_office_email, cc=ccs, email_notification=email_notification
988
+ subject=subject,
989
+ message=message,
990
+ from_email=user_office_email,
991
+ cc=cc_email,
992
+ email_notification=email_notification,
984
993
  )
985
994
 
986
995
 
@@ -41,6 +41,7 @@ from NEMO.models import (
41
41
  ToolUsageQuestionType,
42
42
  ToolUsageQuestions,
43
43
  ToolWaitList,
44
+ TrainingEvent,
44
45
  UsageEvent,
45
46
  User,
46
47
  )
@@ -149,9 +150,6 @@ def tool_status(request, tool_id):
149
150
  or (user_is_qualified and broadcast_upcoming_reservation == "qualified")
150
151
  or broadcast_upcoming_reservation == "all",
151
152
  "tool_control_show_task_details": ToolCustomization.get_bool("tool_control_show_task_details"),
152
- "show_usage_data_tab": ToolUsageQuestions.objects.filter(enabled=True)
153
- .filter(Q(only_for_tools=None) | Q(only_for_tools__in=[tool_id]))
154
- .exists(),
155
153
  "user_can_see_documents": user.is_any_part_of_staff
156
154
  or not ToolCustomization.get_bool("tool_control_show_documents_only_qualified_users")
157
155
  or user_is_qualified,
@@ -184,7 +182,8 @@ def tool_status(request, tool_id):
184
182
  if ToolCustomization.get_bool("tool_control_note_copy_reservation"):
185
183
  dictionary["reservation_note"] = current_reservation.note
186
184
 
187
- dictionary["next_reservation"] = (
185
+ dictionary["next_reservation"] = None
186
+ next_reservation = (
188
187
  Reservation.objects.filter(
189
188
  start__gt=timezone.now(),
190
189
  cancelled=False,
@@ -195,6 +194,15 @@ def tool_status(request, tool_id):
195
194
  .order_by("start")
196
195
  .first()
197
196
  )
197
+ next_training = (
198
+ TrainingEvent.objects.filter(start__gt=timezone.now(), tool=tool, cancelled=False).order_by("start").first()
199
+ )
200
+ if next_reservation:
201
+ dictionary["next_reservation"] = next_reservation
202
+ if next_training:
203
+ if not next_reservation or next_reservation.start > next_training.start:
204
+ next_training.user = next_training.trainer # for it to work with email link to user
205
+ dictionary["next_reservation"] = next_training
198
206
 
199
207
  # Staff need the user list to be able to qualify users for the tool.
200
208
  if user.is_staff_on_tool(tool):
@@ -254,7 +262,7 @@ def usage_data_history(request, tool_id):
254
262
  if not last and not start and not end:
255
263
  # Default to last 25 records
256
264
  last = 25
257
- usage_events = UsageEvent.objects.filter(tool_id=tool_id)
265
+ usage_events = UsageEvent.objects.filter(tool_id__in=Tool.objects.get(pk=tool_id).get_family_tool_ids())
258
266
 
259
267
  if start:
260
268
  usage_events = usage_events.filter(end__gte=start)
@@ -266,6 +274,7 @@ def usage_data_history(request, tool_id):
266
274
  except ValueError:
267
275
  pass
268
276
 
277
+ all_usage_events = usage_events.order_by("-end")
269
278
  pre_usage_events = usage_events.order_by("-start")
270
279
  post_usage_events = usage_events.filter(end__isnull=False).order_by("-end")
271
280
  if last:
@@ -274,22 +283,34 @@ def usage_data_history(request, tool_id):
274
283
  last = int(last)
275
284
  except ValueError:
276
285
  last = 25
286
+ all_usage_events = all_usage_events[:last]
277
287
  pre_usage_events = pre_usage_events[:last]
278
288
  post_usage_events = post_usage_events[:last]
279
289
 
290
+ table_data = BasicDisplayTable()
291
+ table_data.add_header(("user", "User"))
292
+ table_data.add_header(("operator", "Operator"))
293
+ if show_project_info:
294
+ table_data.add_header(("project", "Project"))
295
+ table_data.add_header(("start_date", "Start date"))
296
+ table_data.add_header(("end_date", "End date"))
297
+
280
298
  table_pre_run_data = BasicDisplayTable()
281
299
  table_pre_run_data.add_header(("user", "User"))
282
300
  table_pre_run_data.add_header(("operator", "Operator"))
283
301
  if show_project_info:
284
302
  table_pre_run_data.add_header(("project", "Project"))
285
- table_pre_run_data.add_header(("date", "Start date"))
303
+ table_pre_run_data.add_header(("start_date", "Start date"))
286
304
 
287
- table_run_data = BasicDisplayTable()
288
- table_run_data.add_header(("user", "User"))
289
- table_run_data.add_header(("operator", "Operator"))
305
+ table_post_run_data = BasicDisplayTable()
306
+ table_post_run_data.add_header(("user", "User"))
307
+ table_post_run_data.add_header(("operator", "Operator"))
290
308
  if show_project_info:
291
- table_run_data.add_header(("project", "Project"))
292
- table_run_data.add_header(("date", "End date"))
309
+ table_post_run_data.add_header(("project", "Project"))
310
+ table_post_run_data.add_header(("end_date", "End date"))
311
+
312
+ for usage_event in all_usage_events:
313
+ format_usage_data(table_data, usage_event, None, show_project_info, csv_export, True)
293
314
 
294
315
  for usage_event in pre_usage_events:
295
316
  if usage_event.pre_run_data:
@@ -297,20 +318,21 @@ def usage_data_history(request, tool_id):
297
318
  table_pre_run_data,
298
319
  usage_event,
299
320
  usage_event.pre_run_data,
300
- usage_event.start,
301
321
  show_project_info,
302
322
  csv_export,
303
323
  )
304
324
 
305
325
  for usage_event in post_usage_events:
306
326
  if usage_event.run_data:
307
- format_usage_data(
308
- table_run_data, usage_event, usage_event.run_data, usage_event.end, show_project_info, csv_export
309
- )
327
+ format_usage_data(table_post_run_data, usage_event, usage_event.run_data, show_project_info, csv_export)
310
328
 
311
329
  if csv_export:
312
- response = table_run_data.to_csv() if csv_export == "run" else table_pre_run_data.to_csv()
313
- filename = f"tool{'' if csv_export == 'run' else '_pre'}_usage_data_export_{export_format_datetime()}.csv"
330
+ response = (
331
+ table_data.to_csv()
332
+ if csv_export == "all"
333
+ else table_post_run_data.to_csv() if csv_export == "run" else table_pre_run_data.to_csv()
334
+ )
335
+ filename = f"tool{'' if csv_export == 'all' else 'post' if csv_export == 'run' else '_pre'}_usage_data_export_{export_format_datetime()}.csv"
314
336
  response["Content-Disposition"] = f'attachment; filename="{filename}"'
315
337
  return response
316
338
  else:
@@ -319,7 +341,8 @@ def usage_data_history(request, tool_id):
319
341
  "data_history_start": start.date() if start else None,
320
342
  "data_history_end": end.date() if end else None,
321
343
  "data_history_last": str(last),
322
- "run_data_table": table_run_data,
344
+ "data_table": table_data,
345
+ "run_data_table": table_post_run_data,
323
346
  "pre_run_data_table": table_pre_run_data,
324
347
  "data_history_user": User.objects.get(id=user_id) if user_id else None,
325
348
  "show_project_info": show_project_info or False,
@@ -899,17 +922,16 @@ def format_usage_data(
899
922
  table_result: BasicDisplayTable,
900
923
  usage_event: UsageEvent,
901
924
  usage_run_data: str,
902
- date_field: datetime,
903
925
  show_project_info: str,
904
926
  csv_export: str,
927
+ all_data: bool = False,
905
928
  ):
906
929
  usage_data = {}
907
- date_data = format_datetime(date_field, "SHORT_DATETIME_FORMAT")
908
930
 
909
931
  try:
910
932
  user_data = f"{usage_event.user.first_name} {usage_event.user.last_name}"
911
933
  operator_data = f"{usage_event.operator.first_name} {usage_event.operator.last_name}"
912
- run_data: Dict = loads(usage_run_data)
934
+ run_data: Dict = loads(usage_run_data) if usage_run_data else {}
913
935
  for question_key, question in run_data.items():
914
936
  if "user_input" in question and not question.get("readonly", False):
915
937
  if question["type"] == "group":
@@ -945,7 +967,10 @@ def format_usage_data(
945
967
  if group_usage_data:
946
968
  group_usage_data["user"] = user_data
947
969
  group_usage_data["operator"] = operator_data
948
- group_usage_data["date"] = date_data
970
+ group_usage_data["start_date"] = format_datetime(
971
+ usage_event.start, "SHORT_DATETIME_FORMAT"
972
+ )
973
+ group_usage_data["end_date"] = format_datetime(usage_event.end, "SHORT_DATETIME_FORMAT")
949
974
  if show_project_info:
950
975
  group_usage_data["project"] = usage_event.project.name
951
976
  table_result.add_row(group_usage_data)
@@ -955,10 +980,13 @@ def format_usage_data(
955
980
  usage_data[question_key] = (
956
981
  table_result.formatted_value(question["user_input"]) + suffix if question["user_input"] else ""
957
982
  )
958
- if usage_data:
983
+ if usage_data or all_data:
959
984
  usage_data["user"] = user_data
960
985
  usage_data["operator"] = operator_data
961
- usage_data["date"] = date_data
986
+ usage_data["start_date"] = format_datetime(usage_event.start, "SHORT_DATETIME_FORMAT")
987
+ usage_data["end_date"] = (
988
+ format_datetime(usage_event.end, "SHORT_DATETIME_FORMAT") if usage_event.end else ""
989
+ )
962
990
  if show_project_info:
963
991
  usage_data["project"] = usage_event.project.name
964
992
  table_result.add_row(usage_data)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: NEMO-CE
3
- Version: 7.3.6
3
+ Version: 7.3.8
4
4
  Summary: NEMO Community Edition is a laboratory logistics web application based of NEMO. Use it to schedule reservations, control tool access, track maintenance issues, and more.
5
5
  Author-email: Atlantis Labs LLC <atlantis@atlantislabs.io>
6
6
  License: MIT License
@@ -1,6 +1,6 @@
1
1
  NEMO/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  NEMO/actions.py,sha256=CXWeXVdqqANcBANM-gBS1tuQmL1-eTItfsc4XBdWlZ4,14297
3
- NEMO/admin.py,sha256=INM9JBYoWQqbI0qrAH18sbAfqRiuh9UVCEw8hXmhF4o,87307
3
+ NEMO/admin.py,sha256=6WEEMIva5e-E8FRcRbKo4rv9OQoSoxN5D-xWdpfO_Sk,87619
4
4
  NEMO/constants.py,sha256=wwg5OX_uQtZjauK7puUJEhrpNHWGDexBsQqNF1v3vUE,662
5
5
  NEMO/context_processors.py,sha256=iVqQa7ZOLW65jjP75IbiC2NPeolgCm_0eKABHpsz4ts,6712
6
6
  NEMO/decorators.py,sha256=vBl836p-AMmZEV_k1QYCReUMC7-C_G2-BNpqCjcK_eI,7428
@@ -14,7 +14,7 @@ NEMO/middleware.py,sha256=JqLnixd23Elt-XMQ5X-DoKVaSsddpMwOcf1uPWaukxQ,6339
14
14
  NEMO/migrations_utils.py,sha256=rEQTYq13rPbjRxWYeJMxt324L7SAaQrfXBB180-ZIBI,2755
15
15
  NEMO/mixins.py,sha256=f5QMQ3OaCxSgHWa2vWZxRIx_Vk35zMekLMXAS6HI1RU,20114
16
16
  NEMO/model_tree.py,sha256=Nj6u-5SkzbYQ7q0snyPt3OokH08WIUO6L-ivk8FjUHM,4632
17
- NEMO/models.py,sha256=g-gjeZjmRyTsJooalp9PVzolv6PP-cn3k-S9iIAPFfA,297657
17
+ NEMO/models.py,sha256=Z90C6tI3evrQY97mxXCqzb1UINXVKjCWbeKI6HGw4s0,300481
18
18
  NEMO/parsers.py,sha256=FkKlN3_3DJ9NE6-ezSzdaFl5Qvj9b4mCxBaf6Xboyow,1707
19
19
  NEMO/permissions.py,sha256=tq3dalidpxICq8zQdSyHoXKlsZa_DRdP6nJ-WnlzNKI,949
20
20
  NEMO/policy.py,sha256=An893hR6S8-zBhTVUwOii09lPbUfO1kWRPhZAoiuuZ8,79917
@@ -216,6 +216,7 @@ NEMO/migrations/0137_area_nemo_area_tree_id_lft_idx.py,sha256=SGGh2K8TunyMzIRnl9
216
216
  NEMO/migrations/0138_area_late_cancellation_reservation_threshold_and_more.py,sha256=snXfdPumUpInH8A5hKBbEZy6azcjdDdH1OBFBZ82w_w,1438
217
217
  NEMO/migrations/0139_unplannedoutage.py,sha256=nfXnrZzXPWne-hvsYtyTWpL-oDaVQRyrBVvKrkYa03w,828
218
218
  NEMO/migrations/0140_alter_user_options.py,sha256=_sxUgIG0h5HgrMpJ_HWZ412TWqiSOmG8A_ObY3yXoHk,768
219
+ NEMO/migrations/0141_toolqualificationexpiration.py,sha256=qFh_oEjtzGnUHSqGsoDFlDESPkYUII07GbzF73Jlw4w,4843
219
220
  NEMO/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
220
221
  NEMO/plugins/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
221
222
  NEMO/plugins/utils.py,sha256=t-CJXiqjHbV16QYm7Y21RzIMBAfOaCgZ5s1J6ruFuOY,6054
@@ -358,7 +359,7 @@ NEMO/templates/customizations/customizations_requests.html,sha256=2XQpylF2TdweTi
358
359
  NEMO/templates/customizations/customizations_safety.html,sha256=VXIbFwXcndqGNqvLTWgmhAzPfq6p7ZNGXZYmAfQ6cFs,5071
359
360
  NEMO/templates/customizations/customizations_shadowing_verification.html,sha256=CecVbJIFSBibyywVIJQQO76Hpj6DEsbi1xD_O14xH9M,3619
360
361
  NEMO/templates/customizations/customizations_templates.html,sha256=MHC1jI_0iVkbFvgWu-G-zNtoPuug4IX9p17rIPzUMys,39079
361
- NEMO/templates/customizations/customizations_tool.html,sha256=0XmxvYwBfripjtBm9Avp0cy879fy951SCrs4wGfXP98,40152
362
+ NEMO/templates/customizations/customizations_tool.html,sha256=-ntTV8sY_RbjyXG3NEloRosMV397vUAabZDy8lB7c4A,34473
362
363
  NEMO/templates/customizations/customizations_training.html,sha256=-dyuOvvKmx7uwCtknarrTCWm4hqfcIhMEPVB1vWEFbE,17629
363
364
  NEMO/templates/customizations/customizations_upload.html,sha256=OR_bKCQaBiE1Pg6kL_uLMt0I160g2clKmRPjKABZcBg,1866
364
365
  NEMO/templates/customizations/customizations_user.html,sha256=9tncqwlww01ko1H8ad3G-ptj0fsSwqnh0DRCTGU7eFc,15097
@@ -461,8 +462,8 @@ NEMO/templates/tool_control/logout_user.html,sha256=UCII2yaDkBFqC-9tyBo8QiqRiZNV
461
462
  NEMO/templates/tool_control/past_tasks_and_comments.html,sha256=NnerYy8BKMqdvsz8PJuWuU4VN6zJfqARHm35gnwWeCM,5175
462
463
  NEMO/templates/tool_control/qualified_users.html,sha256=ZPmdePYF0aW_oJO44woEb_PRlXfOB0V4PMJVub79b6g,5974
463
464
  NEMO/templates/tool_control/tool_control.html,sha256=mAtFwqVJFzLcKgUcmKtPtvcjfpjFX6YiXRTrFSQacrc,19558
464
- NEMO/templates/tool_control/tool_status.html,sha256=YFGrMNC5xpEh0CHLv500IHkI3Ro5IQUydfMC_qHO1oQ,77030
465
- NEMO/templates/tool_control/usage_data.html,sha256=UhSixd5TD8yAzIELufB7K1DXRo_FYDNkox5KZiNpz8g,10928
465
+ NEMO/templates/tool_control/tool_status.html,sha256=lB1FJcuGOYkPHO3cmmoSaODbJBjB2gqCKMexoFVMFzI,76934
466
+ NEMO/templates/tool_control/usage_data.html,sha256=gogSex9YAP7jE32Qeb1AyD4NJeLaSEFd6cVQuy7KVGA,12680
466
467
  NEMO/templates/tool_control/use_tool_for_other.html,sha256=JqZM4rpy7UE_zuk8w42ShY3o7PPMd3LcW_L0RelNChk,430
467
468
  NEMO/templates/tool_credentials/tool_credentials.html,sha256=nPZsZZJmRFTXMeE_IxW-M8_zYNPHaxzsCXXxMULrN9c,3551
468
469
  NEMO/templates/training/get_projects.html,sha256=Q64ODiogehjiCJw-BWlheFXiGzEgojyTq_bA2F2mS8U,557
@@ -532,7 +533,7 @@ NEMO/tests/test_requests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZ
532
533
  NEMO/tests/test_requests/test_adjustment_requests.py,sha256=nNycsuvDt3aYACTthQizB0aca9nJTMRV6kZu8T4v-gc,12533
533
534
  NEMO/tests/test_tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
534
535
  NEMO/tests/test_tools/test_tool.py,sha256=tbF9XhEnqhVZWFiPNRToDRhARhXMcavaHr1KEtUCy6Q,18011
535
- NEMO/tests/test_tools/test_tool_qualification_expiration.py,sha256=MNv1VyEZGOeAEWYDrEwyS1l0j5ZSC-yUdjNlqb4EZJA,19062
536
+ NEMO/tests/test_tools/test_tool_qualification_expiration.py,sha256=rwfFCuqXh9hVC_7ymsfDlsVMLa-51pCAn3EzGA4mL0k,18846
536
537
  NEMO/tests/test_tools/test_waitlist.py,sha256=FOkiAtjtmn8FYwZ4PZErGBFHdLraN7ZNsc375hgN8ao,35510
537
538
  NEMO/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
538
539
  NEMO/views/abuse.py,sha256=S53PaFKbBAhJkv82SU63Nat1h0YBjUZ4oPhK9LqP7VY,5811
@@ -541,18 +542,18 @@ NEMO/views/accounts_and_projects.py,sha256=2qgAfK6BRMI4Db_rWkSd8O8y7LZxTeMqNdveX
541
542
  NEMO/views/adjustment_requests.py,sha256=du-vsgjapNjbQxaFewUZYEsOOBx-u2DtnP9E6OLrkTA,29021
542
543
  NEMO/views/admin_autocomplete.py,sha256=vqAiQrg381-ga9a3XF9zrc5yCdP_SLG0L_cd1Ikfjz4,1601
543
544
  NEMO/views/alerts.py,sha256=zKUFwn1ZnRl4AP8Bcg3A6KF6eSlPoOTvVcchA06R9yk,2004
544
- NEMO/views/api.py,sha256=cBAFKWOdq4aUDAG1N2mQHH70BNjU9SuH4fb63XVE99w,45521
545
+ NEMO/views/api.py,sha256=XbZHdNsWmDpPiuPN_y37lNbRZjc-w8jqxcOdHXdBULc,45833
545
546
  NEMO/views/api_billing.py,sha256=txdVWKtrT-ghq5KG-ODdjdEtbm_bdCIL-ZunltGUuFw,16415
546
547
  NEMO/views/area_access.py,sha256=iymFELXlRbwGhaYx-vLiTvfsuRQafhDk1QafhqBieEY,22538
547
548
  NEMO/views/authentication.py,sha256=pnsBH84EcgnkwhscFleX_yIzTvzg9CIiUsX13d1r8Lc,15838
548
549
  NEMO/views/buddy_requests.py,sha256=8BbQUdsHRp2kIoE5FdeY5ybH04VUBm-XHK6WonflnfA,7305
549
- NEMO/views/calendar.py,sha256=oC0ln7nKZ4VbLPph1xarKtsy11_JfqNtt82DSGck4AQ,72670
550
+ NEMO/views/calendar.py,sha256=846CPZshOS4TbuYZLAxt0QOQY_5RHz0oZjxZHn0fmdc,72559
550
551
  NEMO/views/charge_validation.py,sha256=aJ1UfkFGQqyxV3GkZt8503NzOPEloyIKTylOxe0pRFY,4078
551
552
  NEMO/views/configuration_agenda.py,sha256=JMW3svUkkGrov36WsbSLn4EEY5IPIon9tMAsRI3C2iw,3168
552
553
  NEMO/views/constants.py,sha256=jhBysGdFauZE_ByAdqv9jH3s-6PZSncAacqh--0fKRE,97
553
554
  NEMO/views/consumables.py,sha256=utVY-oKTi1YwgQElHgDZMuVahb3ZetdxNx_J-7jFLxM,14367
554
555
  NEMO/views/contact_staff.py,sha256=_h2pvWsvlacmR1I-BmEXHlqr9Ms0PWcNn8Qe5I90UCY,488
555
- NEMO/views/customization.py,sha256=BlPMsOqAgNIqJ9SoGXVobnw0kHbsnmzDPvmNIzDHcRM,42282
556
+ NEMO/views/customization.py,sha256=RIoUneMvgTLNWZTZ1Pdod1TiCut5uXarwiyRSVcwfcA,41807
556
557
  NEMO/views/documents.py,sha256=JmDR6ImlNstjddVuMGuADEJS2cgracinD8IX0xIrfYY,3763
557
558
  NEMO/views/email.py,sha256=-gJCDiX9uEULD33dpDOvLA38HNnOhp-gquPZzyJQ57c,17795
558
559
  NEMO/views/event_details.py,sha256=b_xH-JA7uNXt5n9vVl2AUzEPUkBDn60QcirWaH8ByTM,2786
@@ -576,8 +577,8 @@ NEMO/views/sidebar.py,sha256=SH8--3cpjgThM2WfsiYJ3VzRgM12RKXfrUkcQG5QeP4,980
576
577
  NEMO/views/staff_assistance_requests.py,sha256=zHBfH5XxYCJLfC9i30v7naMsC_zWFg4ZSP7KlZuTSRE,9231
577
578
  NEMO/views/status_dashboard.py,sha256=Wv1yu4qiKLeHFcMhNASDGEKvj14jsJ3Jf5vJlQGtqgk,24990
578
579
  NEMO/views/tasks.py,sha256=Vh3ipdSj40LRQD4_Al52C6HW4TvjMor_dDRmkzGBhCc,20447
579
- NEMO/views/timed_services.py,sha256=NRb2UKi5czXBHDA3dXeQlpU2vRJdOEe9QuOWA6WQHQk,61357
580
- NEMO/views/tool_control.py,sha256=TjslWQEkZAVvuMAxZ8xgbMV9-c5vjmPi3hm8P6OB55Y,41141
580
+ NEMO/views/timed_services.py,sha256=exq83UVulBNGLIaW9E2xE2w_yF94ed3kPmqS_aLhdFk,61102
581
+ NEMO/views/tool_control.py,sha256=YP11yCM8Kha2egMtl9B7v8A37kK9CqsuVlUFyctjeAU,42681
581
582
  NEMO/views/tool_credentials.py,sha256=2EYibZCuJ1rj8b0ubNv0P9SBhprWJCg403MVStO7n3w,2086
582
583
  NEMO/views/training.py,sha256=Mnoea9ZFk7BNvOB2bYoQTAFO5G5yaz51FBOcoToRt-c,8992
583
584
  NEMO/views/training_new.py,sha256=jPQewiKEoTIpRTOjoh1QXfMJaG9MglOnvKvL_Pn25lQ,52212
@@ -589,9 +590,9 @@ NEMO/widgets/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
589
590
  NEMO/widgets/configuration_editor.py,sha256=aWgg6rYrgSZcPSURU0Fs48nPSBncXlRyGVPqvx89PaA,4496
590
591
  NEMO/widgets/dynamic_form.py,sha256=GnXPEc9NMc6hRLGzR3DK3cglXX0zPyLwsfWXQ01CdhM,59705
591
592
  NEMO/widgets/item_tree.py,sha256=tl1d0EgwVXFQQhCmZvcjB_1qmla9qvGxyWBLwWpgxfU,9964
592
- nemo_ce-7.3.6.dist-info/licenses/LICENSE,sha256=O-0zMbcEi6wXz1DiSdVgzMlQjJcNqNe5KDv08uYzqR0,1055
593
- nemo_ce-7.3.6.dist-info/METADATA,sha256=xrdB5tg27VBJ7G7kV7jnZgupqrhu0K_ZKdS4uQqFUCk,3804
594
- nemo_ce-7.3.6.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
595
- nemo_ce-7.3.6.dist-info/entry_points.txt,sha256=ESVtd5EFgPSKwlEZ92GSowvz-wM7K3f11Xr15Qk2a40,55
596
- nemo_ce-7.3.6.dist-info/top_level.txt,sha256=PraGZBuSHU5e2U69ztvBNqTyC7zHCNCbjF3rpqqBu0o,5
597
- nemo_ce-7.3.6.dist-info/RECORD,,
593
+ nemo_ce-7.3.8.dist-info/licenses/LICENSE,sha256=O-0zMbcEi6wXz1DiSdVgzMlQjJcNqNe5KDv08uYzqR0,1055
594
+ nemo_ce-7.3.8.dist-info/METADATA,sha256=DSfcSORVj7p46BZ2CbUKZ2ZrtGX6utK1NHJNQSpHwUw,3804
595
+ nemo_ce-7.3.8.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
596
+ nemo_ce-7.3.8.dist-info/entry_points.txt,sha256=ESVtd5EFgPSKwlEZ92GSowvz-wM7K3f11Xr15Qk2a40,55
597
+ nemo_ce-7.3.8.dist-info/top_level.txt,sha256=PraGZBuSHU5e2U69ztvBNqTyC7zHCNCbjF3rpqqBu0o,5
598
+ nemo_ce-7.3.8.dist-info/RECORD,,