diff options
author | Yulqen <246857+yulqen@users.noreply.github.com> | 2024-09-10 15:58:55 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-09-10 15:58:55 +0100 |
commit | dcfe6d87098bbe064d2db268f0972865c3d412bf (patch) | |
tree | 2b82df07a5290f80f25e9780596cbce0f128454f | |
parent | 7ddc22b821f405546786977225e7b194a19b2b77 (diff) | |
parent | 15b2dc965bfe2271d73476fcf9ff636c60113908 (diff) |
Merge pull request #106 from defencedigital/postgres-migration
Adds new Engagement Strategy stuff
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 |