summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorYulqen <246857+yulqen@users.noreply.github.com>2024-09-10 15:58:55 +0100
committerGitHub <noreply@github.com>2024-09-10 15:58:55 +0100
commitdcfe6d87098bbe064d2db268f0972865c3d412bf (patch)
tree2b82df07a5290f80f25e9780596cbce0f128454f
parent7ddc22b821f405546786977225e7b194a19b2b77 (diff)
parent15b2dc965bfe2271d73476fcf9ff636c60113908 (diff)
Merge pull request #106 from defencedigital/postgres-migration
Adds new Engagement Strategy stuff
-rw-r--r--core/static/css/output.css278
-rw-r--r--core/templates/core/base.html55
-rw-r--r--engagements/admin.py17
-rw-r--r--engagements/forms.py115
-rw-r--r--engagements/migrations/0004_regulatorycycle_alter_engagementeffort_effort_type.py42
-rw-r--r--engagements/migrations/0005_engagementstrategy.py62
-rw-r--r--engagements/migrations/0006_engagementstrategy_owned_by_and_more.py38
-rw-r--r--engagements/migrations/0007_alter_engagementstrategy_reviewed_by.py27
-rw-r--r--engagements/migrations/0008_alter_engagementstrategy_options_and_more.py21
-rw-r--r--engagements/migrations/0009_alter_engagementstrategy_description.py18
-rw-r--r--engagements/migrations/0010_remove_engagementstrategy_name.py17
-rw-r--r--engagements/models.py65
-rw-r--r--engagements/templates/engagements/engagement_detail.html299
-rw-r--r--engagements/templates/engagements/engagement_effort_create.html2
-rw-r--r--engagements/templates/engagements/engagement_form.html5
-rw-r--r--engagements/templates/engagements/engagement_strategy_form.html55
-rw-r--r--engagements/templates/engagements/snippets/effort_detail.html67
-rw-r--r--engagements/tests/conftest.py68
-rw-r--r--engagements/tests/test_forms.py168
-rw-r--r--engagements/tests/test_models.py65
-rw-r--r--engagements/tests/test_views.py177
-rw-r--r--engagements/urls.py6
-rw-r--r--engagements/utils.py6
-rw-r--r--engagements/views.py29
-rw-r--r--instruments/tests/test_models.py37
25 files changed, 1398 insertions, 341 deletions
diff --git a/core/static/css/output.css b/core/static/css/output.css
index 7850c75..a371851 100644
--- a/core/static/css/output.css
+++ b/core/static/css/output.css
@@ -913,6 +913,69 @@ html {
margin-inline-end: -1rem;
}
+.join {
+ display: inline-flex;
+ align-items: stretch;
+ border-radius: var(--rounded-btn, 0.5rem);
+}
+
+.join :where(.join-item) {
+ border-start-end-radius: 0;
+ border-end-end-radius: 0;
+ border-end-start-radius: 0;
+ border-start-start-radius: 0;
+}
+
+.join .join-item:not(:first-child):not(:last-child),
+ .join *:not(:first-child):not(:last-child) .join-item {
+ border-start-end-radius: 0;
+ border-end-end-radius: 0;
+ border-end-start-radius: 0;
+ border-start-start-radius: 0;
+}
+
+.join .join-item:first-child:not(:last-child),
+ .join *:first-child:not(:last-child) .join-item {
+ border-start-end-radius: 0;
+ border-end-end-radius: 0;
+}
+
+.join .dropdown .join-item:first-child:not(:last-child),
+ .join *:first-child:not(:last-child) .dropdown .join-item {
+ border-start-end-radius: inherit;
+ border-end-end-radius: inherit;
+}
+
+.join :where(.join-item:first-child:not(:last-child)),
+ .join :where(*:first-child:not(:last-child) .join-item) {
+ border-end-start-radius: inherit;
+ border-start-start-radius: inherit;
+}
+
+.join .join-item:last-child:not(:first-child),
+ .join *:last-child:not(:first-child) .join-item {
+ border-end-start-radius: 0;
+ border-start-start-radius: 0;
+}
+
+.join :where(.join-item:last-child:not(:first-child)),
+ .join :where(*:last-child:not(:first-child) .join-item) {
+ border-start-end-radius: inherit;
+ border-end-end-radius: inherit;
+}
+
+@supports not selector(:has(*)) {
+ :where(.join *) {
+ border-radius: inherit;
+ }
+}
+
+@supports selector(:has(*)) {
+ :where(.join *:has(.join-item)) {
+ border-radius: inherit;
+ }
+}
+
.link {
cursor: pointer;
text-decoration-line: underline;
@@ -1085,6 +1148,16 @@ html {
text-align: inherit;
}
+.join > :where(*:not(:first-child)) {
+ margin-top: 0px;
+ margin-bottom: 0px;
+ margin-inline-start: -1px;
+}
+
+.join > :where(*:not(:first-child)):is(.btn) {
+ margin-inline-start: calc(var(--border-btn) * -1);
+}
+
.link:focus {
outline: 2px solid transparent;
outline-offset: 2px;
@@ -1251,6 +1324,78 @@ html {
}
}
+.join.join-vertical {
+ flex-direction: column;
+}
+
+.join.join-vertical .join-item:first-child:not(:last-child),
+ .join.join-vertical *:first-child:not(:last-child) .join-item {
+ border-end-start-radius: 0;
+ border-end-end-radius: 0;
+ border-start-start-radius: inherit;
+ border-start-end-radius: inherit;
+}
+
+.join.join-vertical .join-item:last-child:not(:first-child),
+ .join.join-vertical *:last-child:not(:first-child) .join-item {
+ border-start-start-radius: 0;
+ border-start-end-radius: 0;
+ border-end-start-radius: inherit;
+ border-end-end-radius: inherit;
+}
+
+.join.join-horizontal {
+ flex-direction: row;
+}
+
+.join.join-horizontal .join-item:first-child:not(:last-child),
+ .join.join-horizontal *:first-child:not(:last-child) .join-item {
+ border-end-end-radius: 0;
+ border-start-end-radius: 0;
+ border-end-start-radius: inherit;
+ border-start-start-radius: inherit;
+}
+
+.join.join-horizontal .join-item:last-child:not(:first-child),
+ .join.join-horizontal *:last-child:not(:first-child) .join-item {
+ border-end-start-radius: 0;
+ border-start-start-radius: 0;
+ border-end-end-radius: inherit;
+ border-start-end-radius: inherit;
+}
+
+.join.join-vertical > :where(*:not(:first-child)) {
+ margin-left: 0px;
+ margin-right: 0px;
+ margin-top: -1px;
+}
+
+.join.join-vertical > :where(*:not(:first-child)):is(.btn) {
+ margin-top: calc(var(--border-btn) * -1);
+}
+
+.join.join-horizontal > :where(*:not(:first-child)) {
+ margin-top: 0px;
+ margin-bottom: 0px;
+ margin-inline-start: -1px;
+}
+
+.join.join-horizontal > :where(*:not(:first-child)):is(.btn) {
+ margin-inline-start: calc(var(--border-btn) * -1);
+}
+
+.table-sm :not(thead):not(tfoot) tr {
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+}
+
+.table-sm :where(th, td) {
+ padding-left: 0.75rem;
+ padding-right: 0.75rem;
+ padding-top: 0.5rem;
+ padding-bottom: 0.5rem;
+}
+
.static {
position: static;
}
@@ -1271,6 +1416,10 @@ html {
top: 0.5rem;
}
+.z-10 {
+ z-index: 10;
+}
+
.mx-auto {
margin-left: auto;
margin-right: auto;
@@ -1281,6 +1430,11 @@ html {
margin-bottom: 0.5rem;
}
+.my-4 {
+ margin-top: 1rem;
+ margin-bottom: 1rem;
+}
+
.my-8 {
margin-top: 2rem;
margin-bottom: 2rem;
@@ -1362,6 +1516,10 @@ html {
display: grid;
}
+.hidden {
+ display: none;
+}
+
.size-4 {
width: 1rem;
height: 1rem;
@@ -1400,6 +1558,10 @@ html {
max-width: 42rem;
}
+.border-collapse {
+ border-collapse: collapse;
+}
+
.list-inside {
list-style-position: inside;
}
@@ -1444,6 +1606,12 @@ html {
gap: 2rem;
}
+.space-x-2 > :not([hidden]) ~ :not([hidden]) {
+ --tw-space-x-reverse: 0;
+ margin-right: calc(0.5rem * var(--tw-space-x-reverse));
+ margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
+}
+
.space-x-4 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(1rem * var(--tw-space-x-reverse));
@@ -1489,6 +1657,10 @@ html {
border-radius: 0.25rem;
}
+.rounded-full {
+ border-radius: 9999px;
+}
+
.rounded-lg {
border-radius: 0.5rem;
}
@@ -1537,11 +1709,21 @@ html {
border-color: rgb(209 213 219 / var(--tw-border-opacity));
}
+.border-gray-400 {
+ --tw-border-opacity: 1;
+ border-color: rgb(156 163 175 / var(--tw-border-opacity));
+}
+
.border-green-500 {
--tw-border-opacity: 1;
border-color: rgb(34 197 94 / var(--tw-border-opacity));
}
+.border-slate-400 {
+ --tw-border-opacity: 1;
+ border-color: rgb(148 163 184 / var(--tw-border-opacity));
+}
+
.border-transparent {
border-color: transparent;
}
@@ -1586,11 +1768,21 @@ html {
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
}
+.bg-green-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(34 197 94 / var(--tw-bg-opacity));
+}
+
.bg-indigo-600 {
--tw-bg-opacity: 1;
background-color: rgb(79 70 229 / var(--tw-bg-opacity));
}
+.bg-red-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(239 68 68 / var(--tw-bg-opacity));
+}
+
.bg-white {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
@@ -1601,6 +1793,20 @@ html {
background-color: rgb(254 249 195 / var(--tw-bg-opacity));
}
+.bg-yellow-300 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(253 224 71 / var(--tw-bg-opacity));
+}
+
+.bg-yellow-500 {
+ --tw-bg-opacity: 1;
+ background-color: rgb(234 179 8 / var(--tw-bg-opacity));
+}
+
+.p-1 {
+ padding: 0.25rem;
+}
+
.p-1\.5 {
padding: 0.375rem;
}
@@ -1632,6 +1838,11 @@ html {
padding-right: 1rem;
}
+.px-5 {
+ padding-left: 1.25rem;
+ padding-right: 1.25rem;
+}
+
.px-6 {
padding-left: 1.5rem;
padding-right: 1.5rem;
@@ -1642,6 +1853,11 @@ html {
padding-right: 2rem;
}
+.py-0 {
+ padding-top: 0px;
+ padding-bottom: 0px;
+}
+
.py-1 {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
@@ -1721,11 +1937,6 @@ html {
line-height: 2.5rem;
}
-.text-base {
- font-size: 1rem;
- line-height: 1.5rem;
-}
-
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
@@ -1787,6 +1998,11 @@ html {
color: rgb(0 0 0 / var(--tw-text-opacity));
}
+.text-blue-500 {
+ --tw-text-opacity: 1;
+ color: rgb(59 130 246 / var(--tw-text-opacity));
+}
+
.text-blue-600 {
--tw-text-opacity: 1;
color: rgb(37 99 235 / var(--tw-text-opacity));
@@ -1812,6 +2028,11 @@ html {
color: rgb(55 65 81 / var(--tw-text-opacity));
}
+.text-gray-800 {
+ --tw-text-opacity: 1;
+ color: rgb(31 41 55 / var(--tw-text-opacity));
+}
+
.text-gray-900 {
--tw-text-opacity: 1;
color: rgb(17 24 39 / var(--tw-text-opacity));
@@ -1886,6 +2107,16 @@ html {
--tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity));
}
+.transition-colors {
+ transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 150ms;
+}
+
+.duration-200 {
+ transition-duration: 200ms;
+}
+
.placeholder\:text-gray-400::-moz-placeholder {
--tw-text-opacity: 1;
color: rgb(156 163 175 / var(--tw-text-opacity));
@@ -1906,6 +2137,11 @@ html {
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
}
+.hover\:bg-gray-200:hover {
+ --tw-bg-opacity: 1;
+ background-color: rgb(229 231 235 / var(--tw-bg-opacity));
+}
+
.hover\:bg-indigo-500:hover {
--tw-bg-opacity: 1;
background-color: rgb(99 102 241 / var(--tw-bg-opacity));
@@ -1921,11 +2157,21 @@ html {
color: rgb(191 219 254 / var(--tw-text-opacity));
}
+.hover\:text-blue-700:hover {
+ --tw-text-opacity: 1;
+ color: rgb(29 78 216 / var(--tw-text-opacity));
+}
+
.hover\:text-blue-900:hover {
--tw-text-opacity: 1;
color: rgb(30 58 138 / var(--tw-text-opacity));
}
+.hover\:text-gray-700:hover {
+ --tw-text-opacity: 1;
+ color: rgb(55 65 81 / var(--tw-text-opacity));
+}
+
.hover\:text-gray-800:hover {
--tw-text-opacity: 1;
color: rgb(31 41 55 / var(--tw-text-opacity));
@@ -1936,6 +2182,16 @@ html {
color: rgb(99 102 241 / var(--tw-text-opacity));
}
+.hover\:text-indigo-900:hover {
+ --tw-text-opacity: 1;
+ color: rgb(49 46 129 / var(--tw-text-opacity));
+}
+
+.hover\:text-red-700:hover {
+ --tw-text-opacity: 1;
+ color: rgb(185 28 28 / var(--tw-text-opacity));
+}
+
.hover\:underline:hover {
text-decoration-line: underline;
}
@@ -2032,9 +2288,21 @@ html {
}
@media (min-width: 768px) {
+ .md\:w-1\/3 {
+ width: 33.333333%;
+ }
+
+ .md\:w-2\/3 {
+ width: 66.666667%;
+ }
+
.md\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
+
+ .md\:flex-row {
+ flex-direction: row;
+ }
}
@media (min-width: 1024px) {
diff --git a/core/templates/core/base.html b/core/templates/core/base.html
index 1bf6886..2a3f446 100644
--- a/core/templates/core/base.html
+++ b/core/templates/core/base.html
@@ -8,37 +8,42 @@
<link rel="stylesheet" href="{% static 'css/output.css' %}">
<link rel="preconnect" href="https://fonts.gstatic.com">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
- <script src="https://unpkg.com/htmx.org@1.9.0" integrity="sha384-aOxz9UdWG0yBiyrTwPeMibmaoq07/d3a96GCbb9x60f3mOt5zwkjdbcHFnKH8qls" crossorigin="anonymous"></script>
+ <script src="https://unpkg.com/htmx.org@1.9.0"
+ integrity="sha384-aOxz9UdWG0yBiyrTwPeMibmaoq07/d3a96GCbb9x60f3mOt5zwkjdbcHFnKH8qls"
+ crossorigin="anonymous"></script>
<title>{% block title %}{% endblock title %}</title>
{% block extra_head_tags %}{% endblock extra_head_tags %}
</head>
<body>
{% block navbar %}
-<nav class="bg-blue-600 p-4">
- <div class="container mx-auto flex items-center justify-between">
- <div class="flex items-center space-x-4">
- <a href="#" class="text-white text-xl font-semibold">Home</a>
- <a href="{% url 'engagements:home' %}" class="text-white hover:text-blue-200">Engagement Plans</a>
- <a href="{% url 'engagements:regulatedentities' %}" class="text-white hover:text-blue-200">Regulated Entities</a>
- <a href="#" class="text-white hover:text-blue-200">Reporting</a>
- <a href="#" class="text-white hover:text-blue-200">Dashboards</a>
- </div>
- <div class="flex items-center space-x-4">
- {% if request.user.is_authenticated %}
- <span class="text-white">{{ user }}</span>
- <a href="{% url 'logout' %}" class="text-white hover:text-blue-200">Log Out</a>
- {% else %}
- <a href="{% url 'login' %}" class="text-white hover:text-blue-200">Log In</a>
- {% endif %}
- <a href="#" class="text-white hover:text-blue-200">Help</a>
- </div>
- </div>
-</nav>
+ <nav class="bg-blue-600 p-4">
+ <div class="container mx-auto flex items-center justify-between">
+ <div class="flex items-center space-x-4">
+ <a href="#" class="text-white text-xl font-semibold">Home</a>
+ <a href="{% url 'engagements:home' %}" class="text-white hover:text-blue-200">Engagement Plans</a>
+ <a href="{% url 'engagements:regulatedentities' %}" class="text-white hover:text-blue-200">Regulated
+ Entities</a>
+ <a href="#" class="text-white hover:text-blue-200">Reporting</a>
+ <a href="#" class="text-white hover:text-blue-200">Dashboards</a>
+ </div>
+ <div class="flex items-center space-x-4">
+ {% if request.user.is_authenticated %}
+ <span class="text-white">{{ user }}</span>
+ <a href="{% url 'logout' %}" class="text-white hover:text-blue-200">Log Out</a>
+ {% else %}
+ <a href="{% url 'login' %}" class="text-white hover:text-blue-200">Log In</a>
+ {% endif %}
+ <a href="#" class="text-white hover:text-blue-200">Help</a>
+ </div>
+ </div>
+ </nav>
{% endblock navbar %}
- <div class="container mx-auto">
- {% block content %}
- {% endblock content %}
- </div>
+<div class="container mx-auto">
+ {% block content %}
+ {% block messages %}
+ {% endblock messages %}
+ {% endblock content %}
+</div>
</body>
</html>
diff --git a/engagements/admin.py b/engagements/admin.py
index 9861867..3b9d05c 100644
--- a/engagements/admin.py
+++ b/engagements/admin.py
@@ -4,9 +4,11 @@ from .models import (
Engagement,
EngagementEffort,
EngagementType,
+ EngagementStrategy,
Organisation,
Person,
RegulatedEntityType,
+ RegulatoryCycle,
RegulatoryRole,
)
@@ -15,6 +17,10 @@ site = admin.site
site.site_header = "DefNucSyR Engagement Database (DED)"
+class EngagementStrategyAdmin(admin.ModelAdmin):
+ list_display = ("__str__", "owned_by")
+
+
class PersonAdmin(admin.ModelAdmin):
list_display = ("__str__", "organisation")
@@ -23,10 +29,21 @@ class PersonAdmin(admin.ModelAdmin):
return f"{obj.first_name} {obj.last_name}"
+class RegulatoryCycleAdmin(admin.ModelAdmin):
+ @admin.display(description="year")
+ def year(self, obj):
+ return f"{obj.get_year()}"
+
+ list_display = ("__str__", "year")
+ ordering = ("-start_date",)
+
+
site.register(Person, PersonAdmin)
site.register(Organisation)
site.register(RegulatedEntityType)
site.register(RegulatoryRole)
+site.register(RegulatoryCycle, RegulatoryCycleAdmin)
site.register(EngagementType)
+site.register(EngagementStrategy, EngagementStrategyAdmin)
site.register(Engagement)
site.register(EngagementEffort)
diff --git a/engagements/forms.py b/engagements/forms.py
index 2532892..cccaf3b 100644
--- a/engagements/forms.py
+++ b/engagements/forms.py
@@ -1,16 +1,14 @@
from django import forms
+from django.core.exceptions import ValidationError
from django.forms.widgets import HiddenInput
-from .models import Engagement, EngagementEffort
-
-
-# TODO - need to handle errors correctly in this form and in the template
+from .models import Engagement, EngagementEffort, EngagementStrategy
class EngagementEffortReportingCreateForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- self.fields['is_planned'].widget.attrs.update({"class": "select-lg"})
+ self.fields["is_planned"].widget.attrs.update({"class": "select-lg"})
# for field in self.fields.values():
# field.widget.attrs['class'] = 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50'
@@ -18,14 +16,14 @@ class EngagementEffortReportingCreateForm(forms.ModelForm):
model = EngagementEffort
help_texts = {
"is_planned": ("To distinguish planned events from retrospective recording."),
- "officers": ("Include yourself here but you can also add effort for your colleagues.")
+ "officers": ("Include yourself here but you can also add effort for your colleagues."),
}
fields = [
- 'is_planned',
- 'proposed_start_date',
- 'proposed_end_date',
- 'officers',
- 'notes',
+ "is_planned",
+ "proposed_start_date",
+ "proposed_end_date",
+ "officers",
+ "notes",
]
widgets = {
# 'is_planned': forms.Select(
@@ -35,27 +33,35 @@ class EngagementEffortReportingCreateForm(forms.ModelForm):
# ),
# attrs={'class': 'select-lg w-full max-w-xs'}
# ),
- 'proposed_start_date': forms.DateTimeInput(
+ "proposed_start_date": forms.DateTimeInput(
attrs={
- 'type': 'datetime-local',
- 'class': 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50'
+ "type": "datetime-local",
+ "class": "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50",
},
- format='j M y H:i',
+ format="j M y H:i",
),
- 'proposed_end_date': forms.DateTimeInput(
+ "proposed_end_date": forms.DateTimeInput(
attrs={
- 'type': 'datetime-local',
- 'class': 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50'
+ "type": "datetime-local",
+ "class": "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50",
},
- format='j M y H:i',
+ format="j M y H:i",
+ ),
+ "officers": forms.SelectMultiple(
+ attrs={
+ "class": "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
+ }
+ ),
+ "notes": forms.Textarea(
+ attrs={
+ "class": "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50",
+ "rows": 3,
+ }
),
- 'officers': forms.SelectMultiple(attrs={'class': 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50'}),
- 'notes': forms.Textarea(attrs={'class': 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50', 'rows': 3}),
}
class EngagementEffortRegulationCreateForm(forms.ModelForm):
-
class Meta:
model = EngagementEffort
fields = [
@@ -66,9 +72,7 @@ class EngagementEffortRegulationCreateForm(forms.ModelForm):
"sub_instruments",
"notes",
]
- help_texts = {
- "is_planned": ("To distinguish planned events from retrospective recording.")
- }
+ help_texts = {"is_planned": ("To distinguish planned events from retrospective recording.")}
widgets = {
"proposed_start_date": forms.DateTimeInput(
attrs={
@@ -86,7 +90,6 @@ class EngagementEffortRegulationCreateForm(forms.ModelForm):
class EngagementEffortPlanningCreateForm(forms.ModelForm):
-
class Meta:
model = EngagementEffort
fields = [
@@ -96,9 +99,7 @@ class EngagementEffortPlanningCreateForm(forms.ModelForm):
"officers",
"notes",
]
- help_texts = {
- "is_planned": ("To distinguish planned events from retrospective recording.")
- }
+ help_texts = {"is_planned": ("To distinguish planned events from retrospective recording.")}
widgets = {
"proposed_start_date": forms.DateTimeInput(
attrs={
@@ -118,28 +119,30 @@ class EngagementEffortPlanningCreateForm(forms.ModelForm):
class EngagementEffortTravelCreateForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- self.fields['is_planned'].widget.attrs.update({"class": "select-lg"})
+ self.fields["is_planned"].widget.attrs.update({"class": "select-lg"})
for field in self.fields.values():
- field.widget.attrs['class'] = 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50'
+ field.widget.attrs["class"] = (
+ "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
+ )
class Meta:
model = EngagementEffort
fields = ["is_planned", "proposed_start_date", "proposed_end_date", "officers"]
widgets = {
- "is_planned": forms.Select(choices=((True, "YES"), (False, "NO")), attrs={"class": "select select-bordered w-full max-w-xs"}),
+ "is_planned": forms.Select(
+ choices=((True, "YES"), (False, "NO")), attrs={"class": "select select-bordered w-full max-w-xs"}
+ ),
"proposed_start_date": forms.DateTimeInput(attrs={"type": "datetime-local"}),
"proposed_end_date": forms.DateTimeInput(attrs={"type": "datetime-local"}),
}
- help_texts = {
- "is_planned": ("To distinguish planned events from retrospective recording.")
- }
+ help_texts = {"is_planned": ("To distinguish planned events from retrospective recording.")}
class EngagementEffortCreateForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- self.fields['engagement'].widget = HiddenInput()
- self.fields['effort_type'].widget = HiddenInput()
+ self.fields["engagement"].widget = HiddenInput()
+ self.fields["effort_type"].widget = HiddenInput()
if kwargs.get("initial"):
if not kwargs["initial"]["effort_type"] == "REGULATION":
self.fields["sub_instruments"].widget = HiddenInput()
@@ -162,20 +165,32 @@ class EngagementEffortCreateForm(forms.ModelForm):
"proposed_start_date": forms.DateTimeInput(
attrs={
"type": "datetime-local",
- "class": "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
+ "class": "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50",
},
format="j M y H:i",
),
"proposed_end_date": forms.DateTimeInput(
attrs={
"type": "datetime-local",
- "class": "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
+ "class": "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50",
},
format="j M y H:i",
),
- "effort_type": forms.Select(attrs={'class': 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50'}),
- "officers": forms.SelectMultiple(attrs={'class': 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50'}),
- "sub_instruments": forms.SelectMultiple(attrs={'class': 'mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50'}),
+ "effort_type": forms.Select(
+ attrs={
+ "class": "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
+ }
+ ),
+ "officers": forms.SelectMultiple(
+ attrs={
+ "class": "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
+ }
+ ),
+ "sub_instruments": forms.SelectMultiple(
+ attrs={
+ "class": "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
+ }
+ ),
}
@@ -187,6 +202,16 @@ class EngagementCreateForm(forms.ModelForm):
except KeyError:
pass
+ def clean_proposed_end_date(self):
+ proposed_start_date = self.cleaned_data["proposed_start_date"]
+ proposed_end_date = self.cleaned_data["proposed_end_date"]
+
+ if proposed_start_date and proposed_end_date:
+ if proposed_start_date > proposed_end_date:
+ raise ValidationError("The proposed start date must be before the proposed end date.")
+
+ return proposed_start_date
+
class Meta:
model = Engagement
fields = [
@@ -209,3 +234,9 @@ class EngagementCreateForm(forms.ModelForm):
"engagement_type": forms.Select(),
"officers": forms.SelectMultiple(),
}
+
+
+class EngagementStrategyCreateForm(forms.ModelForm):
+ class Meta:
+ fields = "__all__"
+ model = EngagementStrategy
diff --git a/engagements/migrations/0004_regulatorycycle_alter_engagementeffort_effort_type.py b/engagements/migrations/0004_regulatorycycle_alter_engagementeffort_effort_type.py
new file mode 100644
index 0000000..00d9db6
--- /dev/null
+++ b/engagements/migrations/0004_regulatorycycle_alter_engagementeffort_effort_type.py
@@ -0,0 +1,42 @@
+# Generated by Django 5.0.4 on 2024-09-10 10:27
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("engagements", "0003_engagementeffort_notes"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="RegulatoryCycle",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("date_created", models.DateTimeField(auto_now_add=True)),
+ ("last_modified", models.DateTimeField(auto_now=True)),
+ ("start_date", models.DateField()),
+ ("end_date", models.DateField()),
+ ("description", models.TextField(blank=True, max_length=1024, null=True)),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ migrations.AlterField(
+ model_name="engagementeffort",
+ name="effort_type",
+ field=models.CharField(
+ choices=[
+ ("TRAVEL", "Travel"),
+ ("PLANNING", "Planning"),
+ ("REGULATION", "Regulation (On-site or Remote)"),
+ ("DISCUSSION", "Discussion"),
+ ("REPORTING", "Reporting"),
+ ],
+ max_length=32,
+ verbose_name="Effort Type",
+ ),
+ ),
+ ]
diff --git a/engagements/migrations/0005_engagementstrategy.py b/engagements/migrations/0005_engagementstrategy.py
new file mode 100644
index 0000000..213499d
--- /dev/null
+++ b/engagements/migrations/0005_engagementstrategy.py
@@ -0,0 +1,62 @@
+# Generated by Django 5.0.4 on 2024-09-10 11:24
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("engagements", "0004_regulatorycycle_alter_engagementeffort_effort_type"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="EngagementStrategy",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("date_created", models.DateTimeField(auto_now_add=True)),
+ ("last_modified", models.DateTimeField(auto_now=True)),
+ ("name", models.CharField(max_length=128)),
+ ("description", models.TextField(max_length=1024)),
+ ("inspector_sign_off", models.DateField(blank=True, null=True)),
+ ("management_sign_off", models.DateField(blank=True, null=True)),
+ (
+ "status",
+ models.CharField(
+ choices=[
+ ("DRAFT", "Draft"),
+ ("SUBMITTED", "Submitted"),
+ ("APPROVED", "Approved"),
+ ("REJECTED", "Rejected"),
+ ],
+ default="DRAFT",
+ max_length=32,
+ ),
+ ),
+ (
+ "end_year",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="end_year",
+ to="engagements.regulatorycycle",
+ ),
+ ),
+ (
+ "organisation",
+ models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="engagements.organisation"),
+ ),
+ (
+ "start_year",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="start_year",
+ to="engagements.regulatorycycle",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ ),
+ ]
diff --git a/engagements/migrations/0006_engagementstrategy_owned_by_and_more.py b/engagements/migrations/0006_engagementstrategy_owned_by_and_more.py
new file mode 100644
index 0000000..8b0a509
--- /dev/null
+++ b/engagements/migrations/0006_engagementstrategy_owned_by_and_more.py
@@ -0,0 +1,38 @@
+# Generated by Django 5.0.4 on 2024-09-10 12:33
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("engagements", "0005_engagementstrategy"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="engagementstrategy",
+ name="owned_by",
+ field=models.ForeignKey(
+ default=1,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="owned_engagement_strategies",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name="engagementstrategy",
+ name="reviewed_by",
+ field=models.ForeignKey(
+ default=1,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="reviewed_engagement_strategies",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ preserve_default=False,
+ ),
+ ]
diff --git a/engagements/migrations/0007_alter_engagementstrategy_reviewed_by.py b/engagements/migrations/0007_alter_engagementstrategy_reviewed_by.py
new file mode 100644
index 0000000..cacae95
--- /dev/null
+++ b/engagements/migrations/0007_alter_engagementstrategy_reviewed_by.py
@@ -0,0 +1,27 @@
+# Generated by Django 5.0.4 on 2024-09-10 12:45
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("engagements", "0006_engagementstrategy_owned_by_and_more"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="engagementstrategy",
+ name="reviewed_by",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="reviewed_engagement_strategies",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ]
diff --git a/engagements/migrations/0008_alter_engagementstrategy_options_and_more.py b/engagements/migrations/0008_alter_engagementstrategy_options_and_more.py
new file mode 100644
index 0000000..c9b7a50
--- /dev/null
+++ b/engagements/migrations/0008_alter_engagementstrategy_options_and_more.py
@@ -0,0 +1,21 @@
+# Generated by Django 5.0.4 on 2024-09-10 12:49
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("engagements", "0007_alter_engagementstrategy_reviewed_by"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="engagementstrategy",
+ options={"ordering": ("start_year",), "verbose_name_plural": "Engagement Strategies"},
+ ),
+ migrations.AlterUniqueTogether(
+ name="engagementstrategy",
+ unique_together={("start_year", "organisation")},
+ ),
+ ]
diff --git a/engagements/migrations/0009_alter_engagementstrategy_description.py b/engagements/migrations/0009_alter_engagementstrategy_description.py
new file mode 100644
index 0000000..8fc9511
--- /dev/null
+++ b/engagements/migrations/0009_alter_engagementstrategy_description.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.0.4 on 2024-09-10 12:51
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("engagements", "0008_alter_engagementstrategy_options_and_more"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="engagementstrategy",
+ name="description",
+ field=models.TextField(blank=True, max_length=1024, null=True),
+ ),
+ ]
diff --git a/engagements/migrations/0010_remove_engagementstrategy_name.py b/engagements/migrations/0010_remove_engagementstrategy_name.py
new file mode 100644
index 0000000..45aa795
--- /dev/null
+++ b/engagements/migrations/0010_remove_engagementstrategy_name.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.0.4 on 2024-09-10 13:07
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("engagements", "0009_alter_engagementstrategy_description"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="engagementstrategy",
+ name="name",
+ ),
+ ]
diff --git a/engagements/models.py b/engagements/models.py
index 20bed8d..45d8e69 100644
--- a/engagements/models.py
+++ b/engagements/models.py
@@ -5,6 +5,8 @@ from django.utils.text import slugify
from myuser.models import TeamUser
+ES_YEAR_LENGTH = 3
+
class Common(models.Model):
date_created = models.DateTimeField(auto_now_add=True)
@@ -27,6 +29,7 @@ class RegulatoryRole(Common):
class Person(Common):
"External person, rather than MOD at this point."
+
first_name = models.CharField(max_length=64, null=False, blank=False)
last_name = models.CharField(max_length=64, null=False, blank=False)
organisation = models.ForeignKey("Organisation", null=False, blank=False, on_delete=models.CASCADE)
@@ -154,6 +157,9 @@ class Engagement(Common):
"L4RIF": "L4 RIF",
}
+ def get_officers(self):
+ return [" ".join([x.first_name, x.last_name]) for x in self.officers.all()]
+
def total_planning_effort(self):
p_effort = self.engagementeffort_set.all().filter(is_planned=True)
return sum([x.effort_total_hours() for x in p_effort])
@@ -173,7 +179,7 @@ class Engagement(Common):
def dscs(self):
"Return all declared DSCs as part of REGULATION effort"
dscs = set()
- for ee in EngagementEffort.objects.filter(engagement=self, effort_type="REGULATION"):
+ for ee in EngagementEffort.objects.filter(engagement=self):
for si in ee.sub_instruments.all():
dscs.add(si)
return dscs
@@ -191,7 +197,7 @@ class EngagementEffort(Common):
("PLANNING", "Planning"),
("REGULATION", "Regulation (On-site or Remote)"),
("DISCUSSION", "Discussion"),
- ("REPORT", "Reporting"),
+ ("REPORTING", "Reporting"),
)
is_planned = models.BooleanField(null=True, blank=True, verbose_name="Planned", default=True)
effort_type = models.CharField(max_length=32, choices=choices, verbose_name="Effort Type")
@@ -206,6 +212,9 @@ class EngagementEffort(Common):
verbose_name_plural = "Engagement Effort"
ordering = ("proposed_start_date",)
+ def get_officers(self):
+ return [" ".join([x.first_name, x.last_name]) for x in self.officers.all()]
+
def effort_total_hours(self):
"Returns total effort for this engagement."
delta = self.proposed_end_date - self.proposed_start_date
@@ -234,3 +243,55 @@ class EngagementEffort(Common):
def __str__(self):
return f"{self.effort_type} effort for {self.engagement}: {self.proposed_end_date - self.proposed_start_date}"
+
+
+class RegulatoryCycle(Common):
+ start_date = models.DateField(null=False, blank=False)
+ end_date = models.DateField(null=False, blank=False)
+ description = models.TextField(max_length=1024, null=True, blank=True)
+
+ def get_year(self):
+ return str(self.start_date.year)
+
+ def __str__(self):
+ return f"Regulatory Cycle: {self.get_year()}"
+
+
+class EngagementStrategy(Common):
+ STATUS = (
+ ("DRAFT", "Draft"),
+ ("SUBMITTED", "Submitted"),
+ ("APPROVED", "Approved"),
+ ("REJECTED", "Rejected"),
+ )
+
+ organisation = models.ForeignKey(Organisation, on_delete=models.CASCADE)
+ start_year = models.ForeignKey(RegulatoryCycle, on_delete=models.CASCADE, related_name="start_year")
+ end_year = models.ForeignKey(RegulatoryCycle, on_delete=models.CASCADE, related_name="end_year")
+ description = models.TextField(max_length=1024, null=True, blank=True)
+ inspector_sign_off = models.DateField(null=True, blank=True)
+ owned_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="owned_engagement_strategies"
+ )
+ reviewed_by = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ null=True,
+ blank=True,
+ on_delete=models.CASCADE,
+ related_name="reviewed_engagement_strategies",
+ )
+ management_sign_off = models.DateField(null=True, blank=True)
+ status = models.CharField(max_length=32, choices=STATUS, default=STATUS[0][0])
+
+ def get_end_year(self) -> int:
+ return self.start_year.start_date.year + (ES_YEAR_LENGTH - 1)
+
+ def __str__(self):
+ return (
+ f"Engagement Strategy ({self.start_year.start_date.year}-{self.get_end_year()}) - {self.organisation.name}"
+ )
+
+ class Meta:
+ ordering = ("start_year",)
+ unique_together = ("start_year", "organisation")
+ verbose_name_plural = "Engagement Strategies"
diff --git a/engagements/templates/engagements/engagement_detail.html b/engagements/templates/engagements/engagement_detail.html
index c75523b..3acabf6 100644
--- a/engagements/templates/engagements/engagement_detail.html
+++ b/engagements/templates/engagements/engagement_detail.html
@@ -1,113 +1,226 @@
{% extends "core/base.html" %}
{% load table_extras %}
+{% load static %}
{% block title %}{{ engagement }}{% endblock title %}
{% block content %}
<div class="container mx-auto px-4 py-8">
- <div class="bg-white shadow-md rounded-lg overflow-hidden">
- <header class="bg-blue-100 p-4">
- <h2 class="text-3xl font-bold text-center">{{ engagement.friendly_type }}
- at {{ engagement.external_party }}</h2>
- </header>
- <div class="p-4">
- <div class="mb-4">
- <a href="{% url 'engagements:edit' engagement.pk %}" class="text-blue-600 hover:underline">Edit
- Engagement</a>
+ <h3 class="font-semibold text-3xl">{{ engagement.friendly_type }}
+ at {{ engagement.external_party }}</h3>
+ <h4 class="text-gray-600">{{ engagement.proposed_start_date|date:"l j M Y" }}</h4>
+ <p class="py-2">{% lorem %}</p>
+
+ <h3 class="text-2xl">Executive summary</h3>
+
+ <blockquote>
+ <p class="mt-4 px-5">
+ <q>{% lorem %}</q>
+ </p>
+ </blockquote>
+
+ <div class="flex flex-col md:flex-row gap-8">
+ <div class="bg-white shadow overflow-hidden sm:rounded-lg mt-8 w-full border border-gray-400">
+ <div class="px-4 py-2 sm:px-6 text-white bg-blue-500">
+ <h3 class="font-semibold">{{ engagement.engagement_type|lower|capfirst }} Data</h3>
</div>
+ <table class="border-collapse border rounded border-slate-400 min-w-full divide-y divide-gray-200">
+ <thead class="bg-gray-50">
+ <tr>
+ <th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">
+ DSC
+ </th>
+ <th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">
+ Outcome
+ </th>
+ <th class="px-6 py-3 text-left text-xs font-bold text-gray-500 uppercase tracking-wider">
+ Comments
+ </th>
+ </tr>
+ </thead>
+ <tbody class="bg-white divide-y divide-gray-200">
+ <tr>
+ <td class="px-6 py-2 whitespace-nowrap text-sm font-bold text-gray-900">DSC 1</td>
+ <td class="px-6 py-2 whitespace-nowrap text-sm font-medium text-white bg-green-500">ADEQUATE</td>
+ <td class="px-6 py-2 text-sm font-medium text-gray-900">{% lorem 30 w random %}</td>
+ </tr>
+ <tr>
+ <td class="px-6 py-2 whitespace-nowrap text-sm font-bold text-gray-900">DSC 5</td>
+ <td class="px-6 py-2 whitespace-nowrap text-sm font-medium text-gray-900 bg-yellow-300">BELOW ADEQUATE</td>
+ <td class="px-6 py-2 text-sm font-medium text-gray-900">{% lorem %}</td>
+ </tr>
+ <tr>
+ <td class="px-6 py-2 whitespace-nowrap text-sm font-bold text-gray-900">DSC 12</td>
+ <td class="px-6 py-2 whitespace-nowrap text-sm font-medium text-white bg-red-500">UNSUSTAINABLE</td>
+ <td class="px-6 py-2 text-sm font-medium text-gray-900">{% lorem 50 w random %}</td>
+ </tr>
+ <tr>
+ <td class="px-6 py-2 whitespace-nowrap text-sm font-bold text-gray-900">DSC 16</td>
+ <td class="px-6 py-2 whitespace-nowrap text-sm font-medium text-white bg-yellow-500">NEAR ADEQUATE</td>
+ <td class="px-6 py-2 text-sm font-medium text-gray-900">{% lorem 50 w random %}</td>
+ </tr>
+ </tbody>
+ </table>
- <h3 class="text-2xl font-semibold mb-4">Details</h3>
- <div class="bg-white shadow-sm rounded-lg overflow-hidden">
- <table class="min-w-full divide-y divide-gray-200 text-sm">
- <tbody>
- <tr class="bg-gray-50">
- <td class="px-4 py-2 font-semibold text-gray-700">Date</td>
- <td class="px-4 py-2 text-gray-900">
- {{ engagement.proposed_start_date|date:'l' }} -
- {{ engagement.proposed_start_date|date:'j M Y' }}
- </td>
- </tr>
- <tr>
- <td class="px-4 py-2 font-semibold text-gray-700">Site/Operation</td>
- <td class="px-4 py-2 text-gray-900">{{ engagement.external_party }}</td>
- </tr>
- <tr class="bg-gray-50">
- <td class="px-4 py-2 font-semibold text-gray-700">Subject of Activity</td>
- <td class="px-4 py-2 text-gray-900">
- <p class="mb-2">Summary text</p>
- <table class="min-w-full divide-y divide-gray-200">
- <thead class="bg-gray-50">
- <tr>
- <th scope="col"
- class="px-3 py-2 text-left text-sm font-medium text-gray-700 uppercase tracking-wider">
- DSC
- </th>
- </tr>
- </thead>
- <tbody class="bg-white divide-y divide-gray-200">
- {% for t in dscs %}
- <tr>
- <td class="px-3 py-2 whitespace-nowrap text-base">
- <a href='#' class="text-blue-600 hover:underline">{{ t }}</a>
- </td>
- </tr>
- {% endfor %}
- </tbody>
- </table>
- </td>
- </tr>
- <tr>
- <td class="px-4 py-2 font-semibold text-gray-700">Inspectors</td>
- <td class="px-4 py-2 text-gray-900">{{ engagement.officers.all|commalist }}</td>
- </tr>
- <tr class="bg-gray-50">
- <td class="px-4 py-2 font-semibold text-gray-700">Planned Effort</td>
- <td class="px-4 py-2 text-gray-900">{{ effort_planned|floatformat }} hrs</td>
- </tr>
- <tr>
- <td class="px-4 py-2 font-semibold text-gray-700">Actual Effort</td>
- <td class="px-4 py-2 text-gray-900">{{ effort_actual|floatformat }} hrs</td>
- </tr>
- <tr class="bg-gray-50">
- <td class="px-4 py-2 font-semibold text-gray-700">Total Effort</td>
- <td class="px-4 py-2 text-gray-900">{{ effort_total|floatformat }} hrs</td>
- </tr>
- </tbody>
- </table>
+ </div>
+ </div>
+
+ <div class="flex flex-col md:flex-row gap-8">
+ <div class="bg-white shadow overflow-hidden sm:rounded-lg mt-8 md:w-1/3 border border-gray-400">
+ <div class="px-4 py-2 sm:px-6 text-white bg-blue-500">
+ <h3 class="font-semibold">Summary</h3>
</div>
+ <div class="border-t border-gray-200">
+ <div class="px-4 py-5 sm:p-2">
+ <div class="w-full p-4 space-y-4">
+ <!-- Date -->
+ <div class="flex items-center space-x-2">
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor" class="size-6 mr-2">
+ <path stroke-linecap="round" stroke-linejoin="round"
+ d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z"/>
+ </svg>
+ <span class="text-black">Tuesday - 10 May 2022</span>
+ </div>
- <div class="bg-white shadow overflow-hidden sm:rounded-lg mt-8">
- <div class="px-4 py-5 sm:px-6 bg-blue-100">
- <h3 class="text-2xl font-semibold text-black">Effort for this engagement</h3>
- </div>
- <div class="border-t border-gray-200">
- <div class="px-4 py-5 sm:p-6">
- <div class="mb-4">
- <span class="font-medium">Add:</span>
- <a href="{% url 'engagements:effort_create' engagement.pk 'TRAVEL' %}"
- class="text-blue-600 hover:underline ml-2">Travel</a>
- <a href="{% url 'engagements:effort_create' engagement.pk 'PLANNING' %}" class="text-blue-600 hover:underline ml-2">Planning</a>
- <a href="{% url 'engagements:effort_create' engagement.pk "REGULATION" %}"
- class="text-blue-600 hover:underline ml-2">Regulation</a>
- <a href="{% url 'engagements:effort_create' engagement.pk "REPORTING" %}"
- class="text-blue-600 hover:underline ml-2">Reporting</a>
+ <!-- Site/Operation -->
+ <div class="flex items-center space-x-2">
+ <svg id="map-pin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="size-6 mr-2">
+ <path stroke-linecap="round" stroke-linejoin="round"
+ d="M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"/>
+ <path stroke-linecap="round" stroke-linejoin="round"
+ d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z"/>
+ </svg>
+
+ <span class="text-black">Smith, Short and Brookes</span>
+ </div>
+
+ <!-- Subject of Activity -->
+ <div class="flex items-center space-x-2">
+
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
+ stroke-width="1.5" stroke="currentColor" class="size-6 mr-2">
+ <path stroke-linecap="round" stroke-linejoin="round"
+ d="M11.35 3.836c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m8.9-4.414c.376.023.75.05 1.124.08 1.131.094 1.976 1.057 1.976 2.192V16.5A2.25 2.25 0 0 1 18 18.75h-2.25m-7.5-10.5H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V18.75m-7.5-10.5h6.375c.621 0 1.125.504 1.125 1.125v9.375m-8.25-3 1.5 1.5 3-3.75"/>
+ </svg>
+
+ <span class="text-black">{{ engagement.dscs|join:", " }}</span>
+ </div>
+
+ <!-- Inspectors -->
+ <div class="flex items-center space-x-2">
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
+ stroke-width="1.5" stroke="currentColor" class="size-6 mr-2">
+ <path stroke-linecap="round" stroke-linejoin="round"
+ d="M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0ZM3 19.235v-.11a6.375 6.375 0 0 1 12.75 0v.109A12.318 12.318 0 0 1 9.374 21c-2.331 0-4.512-.645-6.374-1.766Z"/>
+ </svg>
+
+ <span class="text-black">{{ engagement.get_officers|join:", " }}</span>
+ </div>
+
+ <div>
+ <a href="{% url 'engagements:edit' engagement.pk %}"
+ class="text-blue-600 hover:underline">Edit
+ Engagement</a>
</div>
+ </div>
+ </div>
+ </div>
+ </div>
- {% if effort %}
- <div class="space-y-4">
- {% for e in effort.all %}
- <div id="planned_swap_{{ e.id }}" class="bg-gray-50 p-4 rounded-lg">
- {% include "engagements/snippets/effort_summary_panel.html" with e=e %}
- </div>
- {% endfor %}
- </div>
- {% else %}
- <p class="text-gray-500">No effort records found for this engagement.</p>
- {% endif %}
+ <div class="bg-white shadow overflow-hidden sm:rounded-lg mt-8 md:w-2/3 border border-gray-400">
+ <div class="px-4 py-2 sm:px-6 text-white bg-blue-500">
+ <h3 class="font-semibold">Effort</h3>
+ </div>
+ <div class="border-t border-gray-200">
+ <div class="px-4 py-5 sm:p-6">
+ <div class="mb-4">
+ <span class="font-medium">Add:</span>
+ <a href="{% url 'engagements:effort_create' engagement.pk 'TRAVEL' %}"
+ class="text-blue-600 hover:underline ml-2">Travel</a>
+ <a href="{% url 'engagements:effort_create' engagement.pk 'PLANNING' %}"
+ class="text-blue-600 hover:underline ml-2">Planning</a>
+ <a href="{% url 'engagements:effort_create' engagement.pk "REGULATION" %}"
+ class="text-blue-600 hover:underline ml-2">Regulation</a>
+ <a href="{% url 'engagements:effort_create' engagement.pk "REPORTING" %}"
+ class="text-blue-600 hover:underline ml-2">Reporting</a>
</div>
+
+ {% if effort %}
+ <table class="border-collapse border border-slate-400 min-w-full divide-y divide-gray-200">
+ <thead class="bg-gray-50">
+ <tr>
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
+ Type
+ </th>
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
+ Date
+ </th>
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
+ Planned Hours
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
+ Actual Hours
+ </th>
+ <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
+ Actions
+ </th>
+ </tr>
+ </thead>
+ <tbody class="bg-white divide-y divide-gray-200">
+ {% for e in effort.all %}
+ <tr>
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ e.effort_type|lower|capfirst }}</td>
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
+ {{ e.proposed_start_date|date:"d M Y" }}</td>
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ e.effort_ }}</td>
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ e.effort_actual }}</td>
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
+ <button class="text-indigo-600 hover:text-indigo-900"
+ hx-get="{% url 'engagements:effort_detail' e.id %}"
+ hx-target="#effort-detail-{{ e.id }}"
+ hx-swap="outerHTML"
+ hx-trigger="click">
+ View Details
+ </button>
+
+ </td>
+ </tr>
+ <tr>
+ <td colspan="5">
+ <div id="effort-detail-{{ e.id }}"
+ class="hidden bg-gray-100 p-4"></div>
+ </td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ <!-- Total Effort -->
+ <div class="flex items-center space-x-2 my-2">
+ <span class="text-black text-sm font-semibold">Total Actual Effort: {{ effort_total }} hrs</span>
+ </div>
+
+ {% else %}
+ <p class="text-gray-500">No effort records found for this engagement.</p>
+ {% endif %}
</div>
</div>
</div>
</div>
</div>
+ <script>
+ function toggleEffortDetail(button, effortId) {
+ const detailDiv = document.getElementById(`effort-detail-${effortId}`);
+ if (detailDiv.classList.contains('hidden')) {
+ button.textContent = 'Hide Details';
+ } else {
+ button.textContent = 'Show Details';
+ detailDiv.classList.add('hidden');
+ }
+ }
+
+ </script>
{% endblock content %}
diff --git a/engagements/templates/engagements/engagement_effort_create.html b/engagements/templates/engagements/engagement_effort_create.html
index f4edc8d..06cf513 100644
--- a/engagements/templates/engagements/engagement_effort_create.html
+++ b/engagements/templates/engagements/engagement_effort_create.html
@@ -7,7 +7,7 @@
<div class="container mx-auto max-w-2xl mt-8">
<div class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4 relative">
- <a href="{{ request.META.HTTP_REFERER }}" class="absolute top-2 right-2">
+ <a href="{{ request.META.HTTP_REFERER }}" class="absolute top-2 right-2 p-1 rounded-full hover:bg-gray-200 transition-colors duration-200">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
diff --git a/engagements/templates/engagements/engagement_form.html b/engagements/templates/engagements/engagement_form.html
index a0cdea2..89f6b0e 100644
--- a/engagements/templates/engagements/engagement_form.html
+++ b/engagements/templates/engagements/engagement_form.html
@@ -44,7 +44,7 @@
{% endfor %}
</div>
</div>
-
+
<div>
{{ form.proposed_start_date.label_tag }}
<div class="my-2">
@@ -57,6 +57,9 @@
<div class="my-2">
{% render_field form.proposed_end_date class+="block w-full rounded-md border-0 p-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" %}
</div>
+ {% for error in form.proposed_end_date.errors %}
+ <p class="text-red-500 text-sm">{{ error }}</p>
+ {% endfor %}
</div>
<div>
diff --git a/engagements/templates/engagements/engagement_strategy_form.html b/engagements/templates/engagements/engagement_strategy_form.html
new file mode 100644
index 0000000..f0eb414
--- /dev/null
+++ b/engagements/templates/engagements/engagement_strategy_form.html
@@ -0,0 +1,55 @@
+{% extends "core/base.html" %}}
+{% load widget_tweaks %}
+
+{% block title %}Create new Engagement Strategy{% endblock title %}
+
+{% block content %}
+
+<div class="container mx-auto py-8">
+ <div class="bg-white mx-auto shadow-md rounded px-8 pt-6 mb-4 relative w-2/3">
+ <a href="{{ request.META.HTTP_REFERER }}" class="absolute top-2 right-2 p-1 rounded-full hover:bg-gray-200 transition-colors duration-200">
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
+ stroke="currentColor" class="w-6 h-6">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
+ </svg>
+ </a>
+ <h2 class="text-2xl font-bold mb-6">Create new Engagement Strategy</h2>
+ <form target="{% url "engagements:es-create" %}" method="post" class="space-y-6">
+ {% csrf_token %}
+ {% for field in form %}
+ <div class="my-2">
+ <div>
+ <label for="{{ field.id_for_label }}" class="block text-sm font-bold text-gray-700 mb-2">
+ {{ field.label }}
+ </label>
+ </div>
+ <div>
+ {% render_field field class+="block w-full rounded-md border-0 p-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" %}
+ {% for error in field.errors %}
+ {{ field }}
+ {% endfor %}
+ </div>
+ {% if field.help_text %}
+ <div class="flex items-end mb-2">
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
+ stroke-width="1.0" stroke="currentColor" class="size-4 text-gray-500 mr-2">
+ <path stroke-linecap="round" stroke-linejoin="round"
+ d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"/>
+ </svg>
+ <p class="mt-2 text-xs text-gray-500">{{ field.help_text }}</p>
+ </div>
+ {% endif %}
+ </div>
+ {% endfor %}
+ <div class="mt-6">
+ <button type="submit"
+ class="w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
+ Submit
+ </button>
+ </div>
+
+ </form>
+ </div>
+</div>
+
+ {% endblock content %}
diff --git a/engagements/templates/engagements/snippets/effort_detail.html b/engagements/templates/engagements/snippets/effort_detail.html
new file mode 100644
index 0000000..bfa6fc5
--- /dev/null
+++ b/engagements/templates/engagements/snippets/effort_detail.html
@@ -0,0 +1,67 @@
+<div id="effort-detail-{{ effort.id }}" class="bg-gray-100 p-4 w-full">
+ <div class="flex justify-between items-center mb-2">
+ {{ effort_total }}
+ <h4 class="text-lg font-semibold">Details</h4>
+ <button onclick="closeEffortDetail({{ effort.id }})" class="text-gray-500 hover:text-gray-700">
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"
+ xmlns="http://www.w3.org/2000/svg">
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
+ </svg>
+ </button>
+ </div>
+
+ <!-- Add w-full and min-w-full to enforce full width -->
+ <div class="relative z-10 w-full">
+ <div class="bg-white border border-gray-300 rounded-lg shadow-sm p-4 w-full min-w-full">
+ <table class="w-full table-sm">
+ <tbody>
+ <tr>
+ <td class="py-0 font-bold text-gray-600">Type:</td>
+ <td class="py-0 text-gray-800">{{ effort.effort_type|lower|capfirst }}</td>
+ </tr>
+ <tr>
+ <td class="py-0 font-bold text-gray-600">Start:</td>
+ <td class="py-0 text-gray-800">{{ effort.proposed_start_date|date:"H:i - l j M Y" }}</td>
+ </tr>
+ <tr>
+ <td class="py-0 font-bold text-gray-600">End:</td>
+ <td class="py-0 text-gray-800">{{ effort.proposed_end_date|date:"H:i - l j M Y" }}</td>
+ </tr>
+ <tr>
+ <td class="py-0 font-bold text-gray-600">Planned Hours:</td>
+ <td class="py-0 text-gray-800">{{ effort.effort_total_planned_hours|floatformat:1 }}</td>
+ </tr>
+ <tr>
+ <td class="py-0 font-bold text-gray-600">Actual Hours:</td>
+ <td class="py-0 text-gray-800">{{ effort.effort_actual|floatformat:1 }}</td>
+ </tr>
+ <tr>
+ <td class="py-0 font-bold text-gray-600">Hours per Inspector:</td>
+ <td class="py-0 text-gray-800">{{ effort.effort_per_officer_hours|floatformat:1 }}</td>
+ </tr>
+ <tr>
+ <td class="py-0 font-bold text-gray-600">Inspectors:</td>
+ <td class="py-0 text-gray-800">{% if effort.get_officers %}{{ effort.get_officers|join:", " }} {% else %} None listed {% endif %}</td>
+ </tr>
+ <tr>
+ <td class="py-0 font-bold text-gray-600">Notes:</td>
+ <td class="py-0 text-gray-800">No notes available</td>
+ </tr>
+ </tbody>
+ </table>
+
+ <hr class="my-4 border-t border-gray-300">
+ <div class="mt-4 flex justify-between">
+ <a href="#" class="text-blue-500 hover:text-blue-700">Edit</a>
+ <a href="#" class="text-red-500 hover:text-red-700">Delete</a>
+ </div>
+ </div>
+ </div>
+</div>
+
+<script>
+ function closeEffortDetail(effortId) {
+ const detailDiv = document.getElementById(`effort-detail-${effortId}`);
+ detailDiv.classList.add('hidden');
+ }
+</script> \ No newline at end of file
diff --git a/engagements/tests/conftest.py b/engagements/tests/conftest.py
new file mode 100644
index 0000000..7ccb9ad
--- /dev/null
+++ b/engagements/tests/conftest.py
@@ -0,0 +1,68 @@
+from datetime import date
+
+import pytest
+
+from engagements.models import Engagement, EngagementStrategy, EngagementType, Organisation, RegulatoryCycle
+from myuser.models import TeamUser
+
+
+@pytest.fixture
+def regulatory_cycles():
+ RegulatoryCycle.objects.create(start_date="2024-01-01", end_date="2024-12-31")
+ RegulatoryCycle.objects.create(start_date="2023-01-01", end_date="2023-12-31")
+ RegulatoryCycle.objects.create(start_date="2022-01-01", end_date="2022-12-31")
+
+
+@pytest.fixture
+def org():
+ return Organisation.objects.create(name="MOD", is_regulated_entity=False)
+
+
+@pytest.fixture
+def engagement_strategy(regulatory_cycles, org, user):
+ es = EngagementStrategy.objects.create(
+ description="ES1 description",
+ start_year=RegulatoryCycle.objects.get(start_date="2022-01-01"),
+ end_year=RegulatoryCycle.objects.get(start_date="2024-01-01"),
+ organisation=org,
+ owned_by=user,
+ reviewed_by=user,
+ management_sign_off="2022-02-10",
+ inspector_sign_off="2022-01-10",
+ status="DRAFT",
+ )
+
+
+@pytest.fixture
+def engagement():
+ data = {
+ "proposed_start_date": "2022-10-01",
+ "engagement_type": EngagementType.objects.create(name="ET1"),
+ "external_party": Organisation.objects.create(name="O1"),
+ }
+ return Engagement.objects.create(**data)
+
+
+@pytest.fixture
+def user():
+ return TeamUser.objects.create_user(email="ming@ming.com")
+
+
+@pytest.fixture
+def engagement_type():
+ return EngagementType.objects.create(name="ET2")
+
+
+@pytest.fixture
+def external_party():
+ return Organisation.objects.create(name="O2")
+
+
+@pytest.fixture
+def user1():
+ return TeamUser.objects.create_user(email="user1@example.com")
+
+
+@pytest.fixture
+def user2():
+ return TeamUser.objects.create_user(email="user2@example.com")
diff --git a/engagements/tests/test_forms.py b/engagements/tests/test_forms.py
index b6aab9a..851f986 100644
--- a/engagements/tests/test_forms.py
+++ b/engagements/tests/test_forms.py
@@ -1,45 +1,131 @@
-from django.test import TestCase
+import pytest
+from django.forms import DateInput, Select, SelectMultiple
-from engagements.forms import EngagementEffortCreateForm
-from engagements.models import Engagement, EngagementType, Organisation
-from myuser.models import TeamUser
+from engagements.forms import EngagementCreateForm, EngagementEffortCreateForm, EngagementStrategyCreateForm
+from engagements.models import EngagementType, RegulatoryCycle
+pytestmark = pytest.mark.django_db
-class EngagementEffortCreate(TestCase):
- def setUp(self):
- data = {
- "proposed_start_date": "2022-10-01",
- "engagement_type": EngagementType.objects.create(name="ET1"),
- "external_party": Organisation.objects.create(name="O1"),
+
+def test_basic_validation(engagement, user):
+ form = EngagementEffortCreateForm(
+ data={
+ "is_planned": True,
+ "proposed_start_date": "2022-10-10 10:00",
+ "proposed_end_date": "2022-10-10 12:00",
+ "engagement": engagement,
+ "effort_type": "PLANNING",
+ "officers": [user],
+ }
+ )
+ assert not form.errors
+
+
+def test_basic_validation_on_bad_entry(engagement, user):
+ form = EngagementEffortCreateForm(
+ data={
+ "is_planned": True,
+ "proposed_start_date": "20240-10-10 10:00",
+ "proposed_end_date": "2022-10-10 12:00",
+ "engagement": engagement,
+ "effort_type": "bobbins",
+ "officers": [user],
+ "sub_instruments": [""],
}
- self.e = Engagement.objects.create(**data)
- self.user = TeamUser.objects.create_user(email="ming@ming.com")
-
- def test_basic_validation(self):
- form = EngagementEffortCreateForm(
- data={
- "is_planned": True,
- "proposed_start_date": "2022-10-10 10:00",
- "proposed_end_date": "2022-10-10 12:00",
- "engagement": self.e,
- "effort_type": "PLANNING",
- "officers": [self.user],
- }
- )
- self.assertFalse(form.errors)
-
- def test_basic_validation_on_bad_entry(self):
- form = EngagementEffortCreateForm(
- data={
- "is_planned": True,
- "proposed_start_date": "20240-10-10 10:00",
- "proposed_end_date": "2022-10-10 12:00",
- "engagement": self.e,
- "effort_type": "bobbins",
- "officers": [self.user],
- "sub_instruments": [""],
- }
- )
- self.assertTrue(form.errors["effort_type"])
- self.assertTrue(form.errors["proposed_start_date"])
- self.assertTrue(form.errors["sub_instruments"])
+ )
+ assert "effort_type" in form.errors
+ assert "proposed_start_date" in form.errors
+
+
+def test_form_fields():
+ form = EngagementCreateForm()
+ expected_fields = [
+ "proposed_start_date",
+ "proposed_end_date",
+ "engagement_type",
+ "external_party",
+ "officers",
+ ]
+ assert list(form.fields.keys()) == expected_fields
+
+
+def test_form_labels():
+ form = EngagementCreateForm()
+ assert form.fields["officers"].label == "Inspectors"
+
+
+def test_form_help_texts():
+ form = EngagementCreateForm()
+ assert form.fields["proposed_start_date"].help_text == "<small><em>YYYY-MM-DD</em></small>"
+ assert form.fields["proposed_end_date"].help_text == "<small><em>YYYY-MM-DD</em></small>"
+
+
+def test_form_widgets():
+ form = EngagementCreateForm()
+ assert isinstance(form.fields["proposed_start_date"].widget, DateInput)
+ assert isinstance(form.fields["proposed_end_date"].widget, DateInput)
+ assert isinstance(form.fields["engagement_type"].widget, Select)
+ assert isinstance(form.fields["officers"].widget, SelectMultiple)
+
+
+def test_form_valid_data(engagement_type, external_party, user1, user2):
+ form_data = {
+ "proposed_start_date": "2023-01-01",
+ "proposed_end_date": "2023-01-31",
+ "engagement_type": engagement_type.id,
+ "external_party": external_party.id,
+ "officers": [user1.id, user2.id],
+ }
+ form = EngagementCreateForm(data=form_data)
+ assert form.is_valid()
+
+
+def test_form_invalid_dates(engagement_type, external_party, user1):
+ form_data = {
+ "proposed_start_date": "2023-01-31",
+ "proposed_end_date": "2023-01-01",
+ "engagement_type": engagement_type.id,
+ "external_party": external_party.id,
+ "officers": [user1.id],
+ }
+ form = EngagementCreateForm(data=form_data)
+ assert not form.is_valid()
+
+
+def test_form_missing_required_fields():
+ form_data = {
+ "proposed_start_date": "2023-01-01",
+ }
+ form = EngagementCreateForm(data=form_data)
+ assert not form.is_valid()
+ assert "engagement_type" in form.errors
+ assert "external_party" in form.errors
+
+
+def test_form_engagement_type_queryset(engagement_type):
+ initial_data = {"engagement_type": EngagementType.objects.all()}
+ form = EngagementCreateForm(initial=initial_data)
+ assert form.fields["engagement_type"].queryset.first().name == initial_data["engagement_type"].first().name
+
+
+def test_form_engagement_type_queryset_without_initial():
+ form = EngagementCreateForm()
+ assert list(form.fields["engagement_type"].queryset) == list(EngagementType.objects.all())
+
+
+def test_create_engagement_strategy_form(org, user, regulatory_cycles):
+ sy = RegulatoryCycle.objects.get(start_date="2022-01-01")
+ ey = RegulatoryCycle.objects.get(start_date="2024-01-01")
+ form_data = {
+ "organisation": org,
+ "start_year": sy,
+ "end_year": ey,
+ "description": "Example description",
+ "inspector_sign_off": "2022-01-10",
+ "owned_by": user,
+ "reviewed_by": user,
+ "management_sign_off": "2022-02-10",
+ "status": "DRAFT",
+ }
+ form = EngagementStrategyCreateForm(data=form_data)
+ assert form.is_valid()
diff --git a/engagements/tests/test_models.py b/engagements/tests/test_models.py
index 08c5169..70ae31a 100644
--- a/engagements/tests/test_models.py
+++ b/engagements/tests/test_models.py
@@ -1,30 +1,45 @@
import pytest
-from django.test import TestCase
+from engagements.models import EngagementStrategy, RegulatoryCycle
from engagements.utils import populate_database
+pytestmark = pytest.mark.django_db
-class TestModels(TestCase):
- @classmethod
- def setUpTestData(cls):
- cls.data = populate_database()
-
- @pytest.mark.django_db
- def test_check_all_dcs(self):
- dscs = self.data.get("sub_instruments")
- self.assertEqual(dscs[0].title, "DSC 1 - Title 1")
-
- @pytest.mark.django_db
- def test_effort_by_type(self):
- e = self.data["engagements"][0]
- total_planning = sum([x.effort_total_planned_hours() for x in e.effort.filter(effort_type="PLANNING")])
- total_travel = sum([x.effort_total_planned_hours() for x in e.effort.filter(effort_type="TRAVEL")])
- total_regulation = sum([x.effort_total_planned_hours() for x in e.effort.filter(effort_type="REGULATION")])
- assert total_planning == 4.25
- assert total_regulation == 0
- assert total_travel == 1
-
- # TODO finish this test!
- def test_total_effort_for_engagement(self):
- e = self.data["engagements"][0]
- assert e.total_effort() == 5.25
+
+@pytest.fixture
+def data():
+ return populate_database()
+
+
+def test_check_all_dcs(data):
+ dscs = data.get("sub_instruments")
+ assert dscs[0].title == "DSC 1 - Title 1"
+
+
+def test_effort_by_type(data):
+ e = data["engagements"][0]
+ total_planning = sum([x.effort_total_planned_hours() for x in e.effort.filter(effort_type="PLANNING")])
+ total_travel = sum([x.effort_total_planned_hours() for x in e.effort.filter(effort_type="TRAVEL")])
+ total_regulation = sum([x.effort_total_planned_hours() for x in e.effort.filter(effort_type="REGULATION")])
+ assert total_planning == 4.25
+ assert total_regulation == 0
+ assert total_travel == 1
+
+
+@pytest.mark.skip(reason="Not implemented until I get my head round effort calculations")
+def test_total_effort_for_engagement(data):
+ e = data["engagements"][0]
+ assert e.total_effort() == 5.25
+
+
+def test_regulatory_cycle_model(regulatory_cycles):
+ rc = RegulatoryCycle.objects.first()
+ rc2023 = RegulatoryCycle.objects.get(start_date="2023-01-01")
+ assert str(rc) == "Regulatory Cycle: 2024"
+ assert str(rc2023) == "Regulatory Cycle: 2023"
+
+
+def test_engagement_strategy_model(engagement_strategy):
+ es1 = EngagementStrategy.objects.first()
+ assert es1.organisation.name == "MOD"
+ assert str(es1) == "Engagement Strategy (2022-2024) - MOD"
diff --git a/engagements/tests/test_views.py b/engagements/tests/test_views.py
index f25eb3a..017b96f 100644
--- a/engagements/tests/test_views.py
+++ b/engagements/tests/test_views.py
@@ -1,91 +1,104 @@
import datetime
from http import HTTPStatus
-from django.test import RequestFactory, TestCase
+import pytest
+from django.test import RequestFactory
from django.urls import reverse
from engagements import models, views
+from engagements.models import EngagementStrategy, RegulatoryCycle, Organisation
from engagements.utils import populate_database
+pytestmark = pytest.mark.django_db
-class TestModels(TestCase):
- @classmethod
- def setUpTestData(cls):
- cls.request = RequestFactory() # for use in _ep_request_factory test
- cls.data = populate_database()
-
- def test_dscs_for_ep(self):
- org = self.data["orgs"][0]
- # we set up an engagement and effort for this org
- et = models.EngagementType.objects.get(name="INSPECTION")
- si = self.data["sub_instruments"][0]
- si2 = self.data["sub_instruments"][2]
- si3 = self.data["sub_instruments"][3]
- # si_not = self.data["sub_instruments"][1]
- engagement = models.Engagement.objects.create(
- proposed_start_date=datetime.date(2022, 10, 10),
- proposed_end_date=datetime.date(2022, 10, 10),
- engagement_type=et,
- external_party=org,
- )
- ef1 = models.EngagementEffort.objects.create(
- is_planned=True,
- effort_type="REGULATION",
- proposed_start_date=datetime.date(2022, 10, 10),
- proposed_end_date=datetime.date(2022, 10, 10),
- engagement=engagement,
- )
- ef1.sub_instruments.add(si) # DSC 1
- ef1.sub_instruments.add(si2) # DSC 3
- ef1.sub_instruments.add(si3) # DSC 4
- ef1.save()
- url = reverse("engagements:plan_for_org", kwargs={"orgslug": org.slug})
- self.client.force_login(self.data["superuser"])
- response = self.client.get(url)
- self.assertEqual(response.status_code, HTTPStatus.OK)
- self.assertTrue(response.context["entity"])
- self.assertEqual(response.context["entity"].name, org.name)
- self.assertIn(si, response.context["dscs"])
- self.assertIn(si2, response.context["dscs"])
- self.assertIn(si3, response.context["dscs"])
- self.assertEqual(response.context["dscs"].count(), 3)
- # self.assertNotIn(si_not, response.context["dscs"])
-
- def test_dscs_for_ep_request_factory(self):
- """
- On the EP page, we expect to see a list of all DSCs related to effort
- for this organisation.
-
- Included this here for reference
- """
- org = self.data["orgs"][0]
- url = reverse("engagements:plan_for_org", kwargs={"orgslug": org.slug})
- request = self.request.get(url)
- request.user = self.data["superuser"]
- response = views.engagement_plan_for(request, org.slug)
- self.assertEqual(response.status_code, HTTPStatus.OK)
-
-
-class TestEngagementEffortView(TestCase):
- @classmethod
- def setUpTestData(cls):
- cls.request = RequestFactory() # for use in _ep_request_factory test
- cls.data = populate_database()
-
- def test_get_blank_form(self):
- url = reverse("engagements:effort_create", kwargs={"eid": 1, "etype": "PLANNING"})
- self.client.force_login(self.data["superuser"])
- request = self.request.get(url)
- request.user = self.data["superuser"]
- response = views.engagement_effort_create(request, eid=1, etype="PLANNING")
- self.assertEqual(response.status_code, HTTPStatus.OK)
-
- # def test_post_data(self):
- # url = reverse(
- # "engagements:effort_create", kwargs={"eid": 1, "etype": "PLANNING"}
- # )
- # self.client.force_login(self.data["superuser"])
- # request = self.request.post(url, {"proposed_start_date": "toss"})
- # request.user = self.data["superuser"]
- # response = views.engagement_effort_create(request, eid=1, etype="PLANNING")
- # self.assertEqual(response.status_code, HTTPStatus.OK)
+
+@pytest.fixture
+def test_data():
+ return populate_database()
+
+
+@pytest.fixture
+def request_factory():
+ return RequestFactory()
+
+
+def test_dscs_for_ep(client, test_data, request_factory):
+ org = test_data["orgs"][0]
+ et = models.EngagementType.objects.get(name="INSPECTION")
+ si = test_data["sub_instruments"][0]
+ si2 = test_data["sub_instruments"][2]
+ si3 = test_data["sub_instruments"][3]
+
+ engagement = models.Engagement.objects.create(
+ proposed_start_date=datetime.date(2022, 10, 10),
+ proposed_end_date=datetime.date(2022, 10, 10),
+ engagement_type=et,
+ external_party=org,
+ )
+ ef1 = models.EngagementEffort.objects.create(
+ is_planned=True,
+ effort_type="REGULATION",
+ proposed_start_date=datetime.date(2022, 10, 10),
+ proposed_end_date=datetime.date(2022, 10, 10),
+ engagement=engagement,
+ )
+ ef1.sub_instruments.add(si, si2, si3)
+
+ url = reverse("engagements:plan_for_org", kwargs={"orgslug": org.slug})
+ client.force_login(test_data["superuser"])
+ response = client.get(url)
+
+ assert response.status_code == HTTPStatus.OK
+ assert response.context["entity"]
+ assert response.context["entity"].name == org.name
+ assert si in response.context["dscs"]
+ assert si2 in response.context["dscs"]
+ assert si3 in response.context["dscs"]
+
+
+def test_get_blank_form(client, test_data, request_factory):
+ url = reverse("engagements:effort_create", kwargs={"eid": 1, "etype": "PLANNING"})
+ client.force_login(test_data["superuser"])
+ request = request_factory.get(url)
+ request.user = test_data["superuser"]
+ response = views.engagement_effort_create(request, eid=1, etype="PLANNING")
+ assert response.status_code == HTTPStatus.OK
+
+
+# def test_get_form_to_create_engagement_strategy(client, request_factory):
+# url = reverse("engagements:es-create")
+# client.force_login(test_data["superuser"])
+# request = request_factory.get(url)
+# request.user = test_data["superuser"]
+# response = views.CreateEngagementStrategy()
+# assert response.status_code == HTTPStatus.OK
+
+
+def test_create_engagement_strategy(client, user, org, regulatory_cycles):
+ # Define the URL for the create view
+ url = reverse("engagements:es-create")
+ client.force_login(user)
+ mod = Organisation.objects.create(name="MOD", is_regulated_entity=False)
+
+ sy = RegulatoryCycle.objects.get(start_date__year="2022")
+ ey = RegulatoryCycle.objects.get(start_date__year="2024")
+
+ # Define sample data for the form
+ data = {
+ "organisation": mod.pk,
+ "start_year": sy.pk, # Use the pk of the regulatory cycle instead of the object itself
+ "end_year": ey.pk,
+ "description": "Example description",
+ "inspector_sign_off": "2022-01-10",
+ "owned_by": user.pk, # Same here
+ "reviewed_by": user.pk,
+ "management_sign_off": "2022-02-10",
+ "status": "DRAFT",
+ }
+
+ # Send a POST request to the view
+ response = client.post(url, data)
+
+ # Check that the response redirects (status code 302) after successful creation
+ assert response.status_code == 302
+ assert EngagementStrategy.objects.count() == 1
diff --git a/engagements/urls.py b/engagements/urls.py
index 7b6ddd3..a199b34 100644
--- a/engagements/urls.py
+++ b/engagements/urls.py
@@ -13,8 +13,10 @@ urlpatterns = [
name="regulatedentities",
),
path("edit/<int:eid>", views.engagement_edit, name="edit"),
+ path("effort/<int:effort_id>/detail/", views.effort_detail, name="effort_detail"),
path("create/<slug:slug>/", views.engagement_create, name="create"),
path("create/<slug:slug>/<str:reg>", views.engagement_create, name="create"),
+ path("es-create/", views.CreateEngagementStrategy.as_view(), name="es-create"),
path(
"effort/create/<int:eid>/<str:etype>",
views.engagement_effort_create,
@@ -22,8 +24,6 @@ urlpatterns = [
),
]
-htmx_urls = [
- path("htmx-effort-planned/<int:effid>", views.htmx_effort_planned, name="htmx-effort-planned")
-]
+htmx_urls = [path("htmx-effort-planned/<int:effid>", views.htmx_effort_planned, name="htmx-effort-planned")]
urlpatterns = urlpatterns + htmx_urls
diff --git a/engagements/utils.py b/engagements/utils.py
index 2c4ff72..3b414e9 100644
--- a/engagements/utils.py
+++ b/engagements/utils.py
@@ -11,6 +11,7 @@ from engagements.models import (
Organisation,
Person,
RegulatedEntityType,
+ RegulatoryCycle,
RegulatoryRole,
)
from instruments.models import Instrument, SubInstrument
@@ -88,6 +89,11 @@ def populate_database():
designator="JSP628",
owner=o1,
)
+ # Some Regulatory Cycles
+ RegulatoryCycle.objects.create(start_date="2022-01-01", end_date="2022-12-31")
+ RegulatoryCycle.objects.create(start_date="2023-01-01", end_date="2023-12-31")
+ RegulatoryCycle.objects.create(start_date="2024-01-01", end_date="2024-12-31")
+
# Create the DSCs
Faker.seed(0)
SubInstrument.objects.all().delete()
diff --git a/engagements/views.py b/engagements/views.py
index ae1abfd..2257a98 100644
--- a/engagements/views.py
+++ b/engagements/views.py
@@ -1,8 +1,11 @@
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Q
+from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
-from django.views.generic import ListView
+from django.template.loader import render_to_string
+from django.urls import reverse_lazy
+from django.views.generic import CreateView, ListView
from instruments.models import SubInstrument
@@ -12,8 +15,15 @@ from .forms import (
EngagementEffortRegulationCreateForm,
EngagementEffortReportingCreateForm,
EngagementEffortTravelCreateForm,
+ EngagementStrategyCreateForm,
)
-from .models import Engagement, EngagementEffort, EngagementType, Organisation
+from .models import Engagement, EngagementEffort, EngagementStrategy, EngagementType, Organisation
+
+
+def effort_detail(request, effort_id):
+ effort = get_object_or_404(EngagementEffort, id=effort_id)
+ html = render_to_string("engagements/snippets/effort_detail.html", {"effort": effort})
+ return HttpResponse(html)
class RegulatedEntitiesView(LoginRequiredMixin, ListView):
@@ -40,7 +50,7 @@ def htmx_effort_planned(request, effid):
else:
effort.is_planned = True
effort.save()
- return render(request, "engagements/snippets/effort_summary_panel.html", {"e" : effort})
+ return render(request, "engagements/snippets/effort_summary_panel.html", {"e": effort})
@login_required
@@ -147,6 +157,12 @@ def engagement_create(request, slug, reg=None):
ef.external_party = Organisation.objects.get(slug=slug)
ef.save()
return redirect("engagements:plan_for_org", orgslug=slug)
+ else:
+ return render(
+ request,
+ "engagements/engagement_form.html",
+ {"form": form, "title": f"Create Engagement for {slug}", "errors": form.errors},
+ )
else:
if reg:
form = EngagementCreateForm(
@@ -180,3 +196,10 @@ def engagement_create(request, slug, reg=None):
"engagements/engagement_form.html",
{"form": form, "title": "Add New Engagement"},
)
+
+
+class CreateEngagementStrategy(LoginRequiredMixin, CreateView):
+ model = EngagementStrategy
+ form_class = EngagementStrategyCreateForm
+ template_name = "engagements/engagement_strategy_form.html"
+ success_url = reverse_lazy("engagements:home")
diff --git a/instruments/tests/test_models.py b/instruments/tests/test_models.py
index 0d7eafa..7762dde 100644
--- a/instruments/tests/test_models.py
+++ b/instruments/tests/test_models.py
@@ -1,29 +1,30 @@
import datetime
import pytest
-from django.test import TestCase
from engagements import models
from engagements.utils import populate_database
+pytestmark = pytest.mark.django_db
-class TestModels(TestCase):
- @classmethod
- def setUpTestData(cls):
- cls.data = populate_database()
- def test_check_all_dcs(self):
- dscs = self.data.get("sub_instruments")
- self.assertEqual(dscs[0].title, "DSC 1 - Title 1")
+@pytest.fixture
+def test_data():
+ return populate_database()
- @pytest.mark.django_db
- def test_get_hours_of_effort_for_dsc_for_org(self):
- org = self.data["orgs"][0]
+
+class TestModels:
+ def test_check_all_dcs(self, test_data):
+ dscs = test_data.get("sub_instruments")
+ assert dscs[0].title == "DSC 1 - Title 1"
+
+ def test_get_hours_of_effort_for_dsc_for_org(self, test_data):
+ org = test_data["orgs"][0]
et = models.EngagementType.objects.get(name="INSPECTION")
- si = self.data["sub_instruments"][0]
- si2 = self.data["sub_instruments"][2]
- si3 = self.data["sub_instruments"][3]
- # si_not = self.data["sub_instruments"][1]
+ si = test_data["sub_instruments"][0]
+ si2 = test_data["sub_instruments"][2]
+ si3 = test_data["sub_instruments"][3]
+ # si_not = test_data["sub_instruments"][1]
engagement = models.Engagement.objects.create(
proposed_start_date=datetime.date(2022, 10, 10),
proposed_end_date=datetime.date(2022, 10, 10),
@@ -41,6 +42,6 @@ class TestModels(TestCase):
ef1.sub_instruments.add(si2) # DSC 3
ef1.sub_instruments.add(si3) # DSC 4
ef1.save()
- self.assertEqual(si.effort_for_org(org), 2)
- self.assertEqual(si2.effort_for_org(org), 2)
- self.assertEqual(si3.effort_for_org(org), 2)
+ assert si.effort_for_org(org) == 2
+ assert si2.effort_for_org(org) == 2
+ assert si3.effort_for_org(org) == 2