diff --git a/DEV_TIPS.md b/DEV_TIPS.md new file mode 100644 index 00000000..a27700ce --- /dev/null +++ b/DEV_TIPS.md @@ -0,0 +1,64 @@ +# Reading Markdown Files + +To read Markdown files (.md) in VS Code, press Ctrl+Shift+V for Markdown preview. + +# Required Prerequisites + +- Python +- HTML/CSS +- Django +- Docker +- SQL +- Linux/Unix + +# Django + +Documentation can be found at https://docs.djangoproject.com/en/3.0/ + +## Testing + +In case of errors, turn on debug in settings-dev.py or by using the following command: +``` +sed -i "s/DEBUG = False/DEBUG = True/g" /opt/Services-Status/ServiceStatus/settings.py +``` + +## HTTPS to HTTP + +Django uses HTTP in testing environments, so make sure to turn SESSION_COOKIE_SECURE to False. You can do that in settings-dev.py, or by using the following command: +``` +sed -i "s/SESSION_COOKIE_SECURE = True/SESSION_COOKIE_SECURE = False/g" /opt/Services-Status/ServiceStatus/settings.py +``` + +# Unix/Linux Environment + +The application is built for Unix/Linux development, so locally developing the application on other operating systems is **not** recommended. Ask a network engineer or your lead developer to help give you access to the remote server being used to test. + +Unix/Linux documentation can be found at https://docs.kernel.org/, or by searching up common commands used. + +# Docker + +Documentation can be found at https://docs.docker.com/ + +This application uses docker-compose to build and run, so before using any commands you must be in the deploy folder. + +## Common Docker commands +``` +docker-compose build +``` +This will run the build script, pulling the data from the 3 Docker files found in the folder. +``` +docker-compose up +``` +This will run the docker container in your terminal. +``` +docker-compose up -d +``` +This will run the docker container in the background. This is especially useful if you want to debug or enter into the shell. +``` +docker-compose kill +``` +This will terminate the running container so you do not consume excess resources. This is especially important if you run **docker-compose up -d** or if you run docker-compose up and disconnect from the terminal. +``` +docker exec -it /bin/bash +``` +This will connect you into the container's bash terminal. diff --git a/README.md b/README.md new file mode 100644 index 00000000..9be0b5c8 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# Services-Status + +The Services-Status repository is used to power the status page used by network engineers at CIARA (https://status.amlight.net/) It uses Django + jQuery to run the web server, supplying templates based on what view you are currently in. + +For documentation, on tech used and useful tips, check DEV_TIPS.md + +## Recent Changes + +- Tickets are now ordered in descending order by the *begin* field (Newest first) +- Tickets in the main view now contain a latest_update field, which uses the action_description found in TicketLogs. +- Custom command added to immediately populate the database with Status objects, if they are not there already. +- Unit tests are being rolled out to test new features. +- Documentation is being rolled out on a daily basis. + +## Unit Testing + +Testing is implemented by running the following command: +``` +python3 manage.py test status.tests --settings=ServiceStatus.settings-test +``` +(If python3 does not work, use **python**) + +### Development + +To create more tests, navigate to the status/tests directory. Each test will be run since they start with *test_*, so write your tests under the test file that covers your domain. For example, if you're testing a new command, do it under *test_commands*. + +## Logging Driver + +For quicker development and testing, the logging driver was commented out from docker-compose.yml. When pushing to production remember to uncomment this. + +## Deployment Methodology + +The Service Status application uses Docker to gather its dependencies and deploy onto a remote server. It uses a .yml script to build, so this application uses docker-compose. + +To **test**, make sure to turn Debug = True and SESSION_COOKIE_COOKIE = False. You can find more information on how to do this in the DEV_TIPS.md file. + +## Unix (remote environment) + +The services-status application was created to run on a Unix environment, which is the environment used by the remote server. Currently, development is taking place on a virtual machine, so ask the networking team to help you gain access to the current VM the developers are using, or to create your own. diff --git a/ServiceStatus/settings-dev.py b/ServiceStatus/settings-dev.py index 2f4e4128..10d4a784 100644 --- a/ServiceStatus/settings-dev.py +++ b/ServiceStatus/settings-dev.py @@ -163,6 +163,7 @@ EMAIL_HOST_PASSWORD = 'XXXSMTPPASSXXX' EMAIL_PORT = 587 +# TURNED OFF FOR DEVELOPMENT. TURN ON FOR PRODUCTION. SESSION_COOKIE_SECURE = True -CSRF_COOKIE_HTTPONLY = True \ No newline at end of file +CSRF_COOKIE_HTTPONLY = True diff --git a/ServiceStatus/settings-test.py b/ServiceStatus/settings-test.py new file mode 100644 index 00000000..fb084ff6 --- /dev/null +++ b/ServiceStatus/settings-test.py @@ -0,0 +1,164 @@ +""" +SPECIAL FILE + +This settings.py file will be used strictly for testing. The +main difference between this one and settings-dev.py is the database used. This +one uses local memory and SQLite, rather than a remote docker container that +settings-dev.py uses. + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.0/ref/settings/ +""" + +import os + +from django.core.mail.utils import DNS_NAME + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +# For Django 1.10 and above you can use: +# from django.core.management.utils import get_random_secret_key +# get_random_secret_key() +SECRET_KEY = 'XXXSECRETKEYXXX' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'status.apps.StatusConfig', + 'django_extensions', + 'tinymce', + 'django_admin_listfilter_dropdown', + 'colorfield', + 'ckeditor', + 'ckeditor_uploader', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'ServiceStatus.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(BASE_DIR, 'templates')] + , + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'ServiceStatus.wsgi.application' + +# Database +# https://docs.djangoproject.com/en/3.0/ref/settings/#databases + +DATABASES = { + 'default':{ + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:' + } +} + +# Password validation +# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +# Internationalization +# https://docs.djangoproject.com/en/3.0/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'America/New_York' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.0/howto/static-files/ + +STATIC_URL = '/static/' + +PROJECT_DIR = os.path.dirname(os.path.abspath(__file__)) + +STATIC_ROOT = os.path.join(PROJECT_DIR, 'static') + +STATICFILES_DIRS = ( + os.path.join(BASE_DIR, 'static'), +) + +GRAPH_MODELS = { + 'all_applications': True, + 'group_models': True, +} + +CKEDITOR_BASEPATH = "/static/ckeditor/ckeditor/" + +CKEDITOR_UPLOAD_PATH = "/uploads/" + +SMTP_HOST = "XXXSMTPHOSTXXX" +SMTP_PORT = 587 +SMTP_USER = "XXXSMTPUSERXXX" +SMTP_PASS = "XXXSMTPPASSXXX" + +# SMTP Configuration +DNS_NAME._fqdn = 'localhost' +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_USE_TLS = True +EMAIL_HOST = 'XXXSMTPHOSTXXX' +DEFAULT_FROM_EMAIL = 'XXXSMTPUSERXXX' +EMAIL_HOST_USER = 'XXXSMTPUSERXXX' +EMAIL_HOST_PASSWORD = 'XXXSMTPPASSXXX' +EMAIL_PORT = 587 + +# TURNED ON FOR DEVELOPMENT. TURN OFF FOR PRODUCTION. +SESSION_COOKIE_SECURE = False + +CSRF_COOKIE_HTTPONLY = True \ No newline at end of file diff --git a/deploy/docker-startup.sh b/deploy/docker-startup.sh index 384f563a..4b7ac784 100755 --- a/deploy/docker-startup.sh +++ b/deploy/docker-startup.sh @@ -40,6 +40,7 @@ cd /opt/Services-Status sleep 30 python3 manage.py makemigrations python3 manage.py migrate +python3 manage.py create_initial_objects /etc/init.d/nginx start gunicorn --access-logfile - --workers 4 --user www-data --group www-data --bind 127.0.0.1:8800 --access-logformat '%(h)s/%({x-forwarded-for}i)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' ServiceStatus.wsgi:application diff --git a/status/admin.py b/status/admin.py index d53e6060..3d7aa56a 100644 --- a/status/admin.py +++ b/status/admin.py @@ -143,7 +143,7 @@ class TicketAdmin(admin.ModelAdmin): DropdownFilter), ('sub_service', RelatedDropdownFilter)) - ordering = ['end'] + ordering = ['-begin'] actions = [notify_users] diff --git a/status/forms.py b/status/forms.py index e9df6b72..31bd9195 100644 --- a/status/forms.py +++ b/status/forms.py @@ -257,6 +257,10 @@ class TicketForm(forms.ModelForm): class Meta: model = Ticket fields = '__all__' + labels = { + 'begin' : "Begin - Use UTC", + 'end' : "End - Use UTC", + } def __init__(self, *args, **kwargs): diff --git a/status/management/commands/create_initial_objects.py b/status/management/commands/create_initial_objects.py new file mode 100644 index 00000000..c5994c41 --- /dev/null +++ b/status/management/commands/create_initial_objects.py @@ -0,0 +1,22 @@ +from django.core.management.base import BaseCommand +from status.models import Status + +class Command(BaseCommand): + + help = 'Creates the initial objects required to start the service-status application' + + def handle(self, *args, **options): + + if Status.objects.count() > 0: + return + + initial_objects = [ + {"tag":"Alert", "color_name": "Orange", "color_hex":"#FC810D", "class_design": "fas fa-exclamation-circle"}, + {"tag":"In Process", "color_name": "Yellow", "color_hex":"#DBBF07", "class_design": "fas fa-tools"}, + {"tag":"No Issues", "color_name": "Green", "color_hex":"#0AC739", "class_design": "fas fa-check-circle"}, + {"tag":"Outage", "color_name": "Red", "color_hex":"#F00004", "class_design": "fas fa-times-circle"}, + {"tag":"Planned", "color_name": "Blue", "color_hex":"#041DBF", "class_design": "far fa-calendar-alt"} + ] + + for data_objects in initial_objects: + Status.objects.create(**data_objects) diff --git a/status/models.py b/status/models.py index 537bf652..be38abd0 100644 --- a/status/models.py +++ b/status/models.py @@ -290,6 +290,7 @@ class Ticket(models.Model): null=True, default=3, verbose_name='Status') begin = models.DateTimeField() end = models.DateTimeField(null=True, blank=True) + action_description = RichTextField() action_notes = RichTextField(blank=True, null=True) @@ -336,6 +337,13 @@ class TicketLog(models.Model): action_date = models.DateTimeField() action_notes = RichTextField(blank=True, null=True, verbose_name='Notes') + def description (self): + if self.action_notes is not None: + return format_html(self.action_notes) + return self.action_notes + + description.allow_tags = True + def __str__(self): queryset_list = [] for sub_service in self.ticket.sub_service.all(): diff --git a/status/templates/services_status.html b/status/templates/services_status.html index 840bda54..6c0878e0 100644 --- a/status/templates/services_status.html +++ b/status/templates/services_status.html @@ -36,7 +36,7 @@

Recent Events

Status: {{ ticket.status.tag }}

{% if ticket.status.tag == "Planned" %} -

Action Date: {{ ticket.begin }}

+

Action Date: {{ ticket.begin }} UTC

{% if ticket.end|localtime|timesince >= "1 min" %}

Current Status Information: Completed

@@ -53,6 +53,11 @@

Recent Events

{{ ticket.action_description | safe }}

+ + {% if ticket.latest_action_notes %} +

Latest Update: {{ticket.latest_action_date}} | UTC {{ticket.latest_action_notes}}

+ {% endif %} + diff --git a/status/tests/test_admin.py b/status/tests/test_admin.py new file mode 100644 index 00000000..e1b041d7 --- /dev/null +++ b/status/tests/test_admin.py @@ -0,0 +1,48 @@ +from django.test import TestCase +from django.utils import timezone +from status.models import Ticket, SubService, Service, ClientDomain, Status +from status.admin import TicketAdmin +from django.contrib.admin.sites import AdminSite +import datetime + + +class TestAdmin(TestCase): + + def test_order_by_of_tickets_in_admin (self): + ''' + Checks the ordering of Ticket objects in the admin panel. + Currently, it should be sorted based on the recency of the "begin" field. + ''' + # Create required fields + sub_serv = SubService.objects.create(name='Test_Sub', subservice_description='') + serv = Service.objects.create(name='Test_Service', service_description='', scope='Inter-Domain') + stat = Status.objects.create(tag= "Alert", color_name = "Orange", color_hex = "#FC810D", class_design = "fas fa-exclamation-circle") + client = ClientDomain.objects.create(name='Test_Client', domain_description='') + client.services.set([serv]) + + # Create ticket that started right now + ticket = Ticket.objects.create(status=stat, begin=timezone.now(), end=timezone.now(), notify_action=False) + ticket.sub_service.set([sub_serv]) + ticket.services.set([serv]) + ticket.client_domains.set([client]) + + # Create a ticket that started an hour ago + ticket2 = Ticket.objects.create(status=stat, begin=timezone.now() + datetime.timedelta(hours=-1), end=timezone.now(), notify_action=False) + ticket2.sub_service.set([sub_serv]) + ticket2.services.set([serv]) + ticket2.client_domains.set([client]) + + # Create a ticket that started two hours ago + ticket3 = Ticket.objects.create(status=stat, begin=timezone.now() + datetime.timedelta(hours=-2), end=timezone.now(), notify_action=False) + ticket3.sub_service.set([sub_serv]) + ticket3.services.set([serv]) + ticket3.client_domains.set([client]) + + admin_site = AdminSite() + ticket_admin = TicketAdmin(Ticket, admin_site) + + query_set = ticket_admin.get_queryset(None) + query_set = [str(element) for element in query_set] + + self.assertEqual(query_set, ['T000000001', 'T00000002', 'T00000003']) + \ No newline at end of file diff --git a/status/tests/test_commands.py b/status/tests/test_commands.py new file mode 100644 index 00000000..9ef60ed1 --- /dev/null +++ b/status/tests/test_commands.py @@ -0,0 +1,10 @@ +from django.test import TestCase +from django.core.management import call_command +from status.models import Status + + +class TestCommands(TestCase): + + def test_create_initial_objects_creates_five_objects (self): + call_command('create_initial_objects') + assert Status.objects.count() == 5 \ No newline at end of file diff --git a/status/tests/test_models.py b/status/tests/test_models.py index a57e3605..18574eb1 100644 --- a/status/tests/test_models.py +++ b/status/tests/test_models.py @@ -24,4 +24,3 @@ def test_service_integrity(self): Service.objects.create(name="Service test") assert Service.objects.count() == 1 - diff --git a/status/tests/test_views.py b/status/tests/test_views.py new file mode 100644 index 00000000..70a33771 --- /dev/null +++ b/status/tests/test_views.py @@ -0,0 +1,50 @@ +from status.models import Service, SubService, Status, ClientDomain, Ticket +from django.utils import timezone +from django.urls import reverse +from django.test import TestCase + +class TestTickets(TestCase): + + def test_ticket_latest_action_notes_and_date_exist (self): + + # Create all the status objects + initial_objects = [ + {"tag":"Alert", "color_name": "Orange", "color_hex":"#FC810D", "class_design": "fas fa-exclamation-circle"}, + {"tag":"In Process", "color_name": "Yellow", "color_hex":"#DBBF07", "class_design": "fas fa-tools"}, + {"tag":"No Issues", "color_name": "Green", "color_hex":"#0AC739", "class_design": "fas fa-check-circle"}, + {"tag":"Outage", "color_name": "Red", "color_hex":"#F00004", "class_design": "fas fa-times-circle"}, + {"tag":"Planned", "color_name": "Blue", "color_hex":"#041DBF", "class_design": "far fa-calendar-alt"} + ] + + for data_objects in initial_objects: + Status.objects.create(**data_objects) + + # Create the required fields + sub_serv = SubService.objects.create(name='Test_Sub', subservice_description='') + serv = Service.objects.create(name='Test_Service', service_description='', scope='Inter-Domain') + stat = Status.objects.get(tag="No Issues") + client = ClientDomain.objects.create(name='Test_Client', domain_description='') + client.services.set([serv]) + + # Create Ticket object + ticket = Ticket.objects.create(status=stat, begin=timezone.now(), end=timezone.now(), notify_action=False) + ticket.sub_service.set([sub_serv]) + ticket.services.set([serv]) + ticket.client_domains.set([client]) + + # Get the url to the main page. Reverse does this by using the name field in urls.py + url = reverse('services_status_view') + + # Get the response + response = self.client.get(url) + + # Make sure it returns a 200 response, this checks if an error has occurred generally + self.assertEqual(response.status_code, 200) + + # Get the queryset for the ticket_list + queryset = response.context['ticket_list'] + + # Make sure the attribute exists + for element in queryset: + self.assertTrue(hasattr(element, "latest_action_notes")) + self.assertTrue(hasattr(element, "latest_action_date")) diff --git a/status/views.py b/status/views.py index c819f86a..fd5a5c7a 100644 --- a/status/views.py +++ b/status/views.py @@ -98,8 +98,12 @@ def get(self, request, *args, **kwargs): .filter(action_date__range=["2012-01-01", timezone.now()]).order_by('action_date').last() if last_log is not None: ticket.latest_log = last_log.status + ticket.latest_action_notes = last_log.description() + ticket.latest_action_date = last_log.action_date else: ticket.latest_log = ticket.status + ticket.latest_action_notes = None + ticket.latest_action_date = None context = { "ticket_list": ticket_list,