diff --git a/PyRIGS/tests/base.py b/PyRIGS/tests/base.py new file mode 100644 index 00000000..ecdacd21 --- /dev/null +++ b/PyRIGS/tests/base.py @@ -0,0 +1,36 @@ +from django.test import LiveServerTestCase +from selenium import webdriver +from RIGS import models as rigsmodels +from . import pages +import os + + +def create_browser(): + options = webdriver.ChromeOptions() + options.add_argument("--window-size=1920,1080") + if os.environ.get('CI', False): + options.add_argument("--headless") + options.add_argument("--no-sandbox") + driver = webdriver.Chrome(chrome_options=options) + return driver + + +class BaseTest(LiveServerTestCase): + def setUp(self): + super().setUpClass() + self.driver = create_browser() + + def tearDown(self): + super().tearDown() + self.driver.quit() + + +class AutoLoginTest(BaseTest): + def setUp(self): + super().setUp() + self.profile = rigsmodels.Profile( + username="EventTest", first_name="Event", last_name="Test", initials="ETU", is_superuser=True) + self.profile.set_password("EventTestPassword") + self.profile.save() + loginPage = pages.LoginPage(self.driver, self.live_server_url).open() + loginPage.login("EventTest", "EventTestPassword") diff --git a/PyRIGS/tests/pages.py b/PyRIGS/tests/pages.py new file mode 100644 index 00000000..4bf34a6d --- /dev/null +++ b/PyRIGS/tests/pages.py @@ -0,0 +1,86 @@ +from pypom import Page, Region +from selenium.webdriver.common.by import By +from selenium.webdriver import Chrome +from selenium.common.exceptions import NoSuchElementException + + +class BasePage(Page): + form_items = {} + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def __getattr__(self, name): + if name in self.form_items: + element = self.form_items[name] + form_element = element[0](self, self.find_element(*element[1])) + return form_element.value + else: + return super().__getattribute__(name) + + def __setattr__(self, name, value): + if name in self.form_items: + element = self.form_items[name] + form_element = element[0](self, self.find_element(*element[1])) + form_element.set_value(value) + else: + self.__dict__[name] = value + + +class FormPage(BasePage): + _errors_selector = (By.CLASS_NAME, "alert-danger") + + def remove_all_required(self): + self.driver.execute_script("Array.from(document.getElementsByTagName(\"input\")).forEach(function (el, ind, arr) { el.removeAttribute(\"required\")});") + self.driver.execute_script("Array.from(document.getElementsByTagName(\"select\")).forEach(function (el, ind, arr) { el.removeAttribute(\"required\")});") + + @property + def errors(self): + try: + error_page = self.ErrorPage(self, self.find_element(*self._errors_selector)) + return error_page.errors + except NoSuchElementException: + return None + + class ErrorPage(Region): + _error_item_selector = (By.CSS_SELECTOR, "dl>span") + + class ErrorItem(Region): + _field_selector = (By.CSS_SELECTOR, "dt") + _error_selector = (By.CSS_SELECTOR, "dd>ul>li") + + @property + def field_name(self): + return self.find_element(*self._field_selector).text + + @property + def errors(self): + return [x.text for x in self.find_elements(*self._error_selector)] + + @property + def errors(self): + error_items = [self.ErrorItem(self, x) for x in self.find_elements(*self._error_item_selector)] + errors = {} + for error in error_items: + errors[error.field_name] = error.errors + return errors + + +class LoginPage(BasePage): + URL_TEMPLATE = '/user/login' + + _username_locator = (By.ID, 'id_username') + _password_locator = (By.ID, 'id_password') + _submit_locator = (By.ID, 'id_submit') + _error_locator = (By.CSS_SELECTOR, '.errorlist>li') + + def login(self, username, password): + username_element = self.find_element(*self._username_locator) + username_element.clear() + username_element.send_keys(username) + + password_element = self.find_element(*self._password_locator) + password_element.clear() + password_element.send_keys(password) + + self.find_element(*self._submit_locator).click() diff --git a/PyRIGS/tests/regions.py b/PyRIGS/tests/regions.py new file mode 100644 index 00000000..5dd364ff --- /dev/null +++ b/PyRIGS/tests/regions.py @@ -0,0 +1,133 @@ +from pypom import Region +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions +from selenium.webdriver.remote.webelement import WebElement +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support.select import Select +import datetime + + +def parse_bool_from_string(string): + # Used to convert from attribute strings to boolean values, written after I found this: + # >>> bool("false") + # True + if string == "true": + return True + else: + return False + + +class BootstrapSelectElement(Region): + _main_button_locator = (By.CSS_SELECTOR, 'button.dropdown-toggle') + _option_box_locator = (By.CSS_SELECTOR, 'ul.dropdown-menu') + _option_locator = (By.CSS_SELECTOR, 'ul.dropdown-menu.inner>li>a[role=option]') + _select_all_locator = (By.CLASS_NAME, 'bs-select-all') + _deselect_all_locator = (By.CLASS_NAME, 'bs-deselect-all') + _search_locator = (By.CSS_SELECTOR, '.bs-searchbox>input') + _status_locator = (By.CLASS_NAME, 'status') + + @property + def is_open(self): + return parse_bool_from_string(self.find_element(*self._main_button_locator).get_attribute("aria-expanded")) + + def toggle(self): + original_state = self.is_open + return self.find_element(*self._main_button_locator).click() + option_box = self.find_element(*self._option_box_locator) + if original_state: + self.wait.until(expected_conditions.invisibility_of_element_located(option_box)) + else: + self.wait.until(expected_conditions.visibility_of_element_located(option_box)) + + def open(self): + if not self.is_open: + self.toggle() + + def close(self): + if self.is_open: + self.toggle() + + def select_all(self): + self.find_element(*self._select_all_locator).click() + + def deselect_all(self): + self.find_element(*self._deselect_all_locator).click() + + def search(self, query): + search_box = self.find_element(*self._search_locator) + search_box.clear() + search_box.send_keys(query) + status_text = self.find_element(*self._status_locator) + self.wait.until(expected_conditions.invisibility_of_element_located(self._status_locator)) + + @property + def options(self): + options = list(self.find_elements(*self._option_locator)) + return [self.BootstrapSelectOption(self, i) for i in options] + + def set_option(self, name, selected): + options = list((x for x in self.options if x.name == name)) + assert len(options) == 1 + options[0].set_selected(selected) + + class BootstrapSelectOption(Region): + _text_locator = (By.CLASS_NAME, 'text') + + @property + def selected(self): + return parse_bool_from_string(self.root.get_attribute("aria-selected")) + + def toggle(self): + self.root.click() + + def set_selected(self, selected): + if self.selected != selected: + self.toggle() + + @property + def name(self): + return self.find_element(*self._text_locator).text + + +class TextBox(Region): + @property + def value(self): + return self.root.get_attribute("value") + + def set_value(self, value): + self.root.clear() + self.root.send_keys(value) + + +class CheckBox(Region): + def toggle(self): + self.root.click() + + @property + def value(self): + return parse_bool_from_string(self.root.get_attribute("checked")) + + def set_value(self, value): + if value != self.value: + self.toggle() + + +class DatePicker(Region): + @property + def value(self): + return datetime.datetime.strptime(self.root.get_attribute("value"), "%Y-%m-%d") + + def set_value(self, value): + self.root.clear() + self.root.send_keys(value.strftime("%d%m%Y")) + + +class SingleSelectPicker(Region): + @property + def value(self): + picker = Select(self.root) + return picker.first_selected_option.text + + def set_value(self, value): + picker = Select(self.root) + picker.select_by_visible_text(value) diff --git a/RIGS/templates/RIGS/organisation_detail.html b/RIGS/templates/RIGS/organisation_detail.html index b12b6391..ca16aaa6 100644 --- a/RIGS/templates/RIGS/organisation_detail.html +++ b/RIGS/templates/RIGS/organisation_detail.html @@ -1,4 +1,4 @@ -{% extends request.is_ajax|yesno:"base_ajax.html,base_rigs.html" %} + {% extends request.is_ajax|yesno:"base_ajax.html,base_rigs.html" %} {% load widget_tweaks %} {% block title %}Organisation | {{ object.name }}{% endblock %} diff --git a/RIGS/test_functional.py b/RIGS/test_functional.py index 8ea2481b..691f5294 100644 --- a/RIGS/test_functional.py +++ b/RIGS/test_functional.py @@ -20,15 +20,13 @@ from selenium.webdriver.support.ui import WebDriverWait from RIGS import models +from reversion import revisions as reversion +from django.urls import reverse +from django.core import mail, signing +from PyRIGS.tests.base import create_browser +from django.conf import settings -def create_browser(): - options = webdriver.ChromeOptions() - options.add_argument("--window-size=1920,1080") - if os.environ.get('CI', False): - options.add_argument("--headless") - options.add_argument("--no-sandbox") - driver = webdriver.Chrome(chrome_options=options) - return driver +import sys class UserRegistrationTest(LiveServerTestCase): diff --git a/RIGS/test_models.py b/RIGS/test_models.py index 424e2f06..d35cbbe2 100644 --- a/RIGS/test_models.py +++ b/RIGS/test_models.py @@ -1,5 +1,3 @@ - - import pytz from reversion import revisions as reversion from django.conf import settings @@ -8,6 +6,7 @@ from django.test import TestCase from RIGS import models, versioning from datetime import date, timedelta, datetime, time from decimal import * +from PyRIGS.tests.base import create_browser class ProfileTestCase(TestCase): diff --git a/assets/apps.py b/assets/apps.py deleted file mode 100644 index 5569d303..00000000 --- a/assets/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class AssetsConfig(AppConfig): - name = 'assets' diff --git a/assets/filters.py b/assets/filters.py deleted file mode 100644 index b7cc8ffa..00000000 --- a/assets/filters.py +++ /dev/null @@ -1,9 +0,0 @@ -import django_filters - -from assets import models - - -class AssetFilter(django_filters.FilterSet): - class Meta: - model = models.Asset - fields = ['asset_id', 'description', 'serial_number', 'category', 'status'] diff --git a/assets/management/commands/import_old_db.py b/assets/management/commands/import_old_db.py deleted file mode 100644 index 0fcff787..00000000 --- a/assets/management/commands/import_old_db.py +++ /dev/null @@ -1,229 +0,0 @@ -import os -import datetime -import xml.etree.ElementTree as ET -from django.core.management.base import BaseCommand -from django.conf import settings - -from assets import models - - -class Command(BaseCommand): - help = 'Imports old db from XML dump' - - epoch = datetime.date(1970, 1, 1) - - def handle(self, *args, **options): - self.import_categories() - self.import_statuses() - self.import_suppliers() - self.import_collections() - self.import_assets() - self.import_cables() - - @staticmethod - def xml_path(file): - return os.path.join(settings.BASE_DIR, 'data/DB_Dump/{}'.format(file)) - - @staticmethod - def parse_xml(file): - tree = ET.parse(file) - - return tree.getroot() - - def import_categories(self): - # 0: updated, 1: created - tally = [0, 0] - root = self.parse_xml(self.xml_path('TEC_Asset_Categories.xml')) - - for child in root: - obj, created = models.AssetCategory.objects.update_or_create( - pk=int(child.find('AssetCategoryID').text), - name=child.find('AssetCategory').text - ) - - if created: - tally[1] += 1 - else: - tally[0] += 1 - - print('Categories - Updated: {}, Created: {}'.format(tally[0], tally[1])) - - def import_statuses(self): - # 0: updated, 1: created - tally = [0, 0] - root = self.parse_xml(self.xml_path('TEC_Asset_Status_new.xml')) - - for child in root: - obj, created = models.AssetStatus.objects.update_or_create( - pk=int(child.find('StatusID').text), - name=child.find('Status').text - ) - - if created: - tally[1] += 1 - else: - tally[0] += 1 - - print('Statuses - Updated: {}, Created: {}'.format(tally[0], tally[1])) - - def import_suppliers(self): - # 0: updated, 1: created - tally = [0, 0] - root = self.parse_xml(self.xml_path('TEC_Asset_Suppliers_new.xml')) - - for child in root: - obj, created = models.Supplier.objects.update_or_create( - pk=int(child.find('Supplier_x0020_Id').text), - name=child.find('Supplier_x0020_Name').text - ) - - if created: - tally[1] += 1 - else: - tally[0] += 1 - - print('Suppliers - Updated: {}, Created: {}'.format(tally[0], tally[1])) - - def import_assets(self): - # 0: updated, 1: created - tally = [0, 0] - root = self.parse_xml(self.xml_path('TEC_Assets.xml')) - - for child in root: - defaults = dict() - - # defaults['pk'] = int(child.find('ID').text) - defaults['asset_id'] = child.find('AssetID').text - - try: - defaults['description'] = child.find('AssetDescription').text - except AttributeError: - defaults['description'] = 'None' - - defaults['category'] = models.AssetCategory.objects.get(pk=int(child.find('AssetCategoryID').text)) - defaults['status'] = models.AssetStatus.objects.get(pk=int(child.find('StatusID').text)) - - try: - defaults['serial_number'] = child.find('SerialNumber').text - except AttributeError: - pass - - try: - defaults['purchased_from'] = models.Supplier.objects.get(pk=int(child.find('Supplier_x0020_Id').text)) - except AttributeError: - pass - - try: - defaults['date_acquired'] = datetime.datetime.strptime(child.find('DateAcquired').text, '%d/%m/%Y').date() - except AttributeError: - defaults['date_acquired'] = self.epoch - - try: - defaults['date_sold'] = datetime.datetime.strptime(child.find('DateSold').text, '%d/%m/%Y').date() - except AttributeError: - pass - - try: - defaults['purchase_price'] = float(child.find('Replacement_x0020_Value').text) - except AttributeError: - pass - - try: - defaults['salvage_value'] = float(child.find('SalvageValue').text) - except AttributeError: - pass - - try: - defaults['comments'] = child.find('Comments').text - except AttributeError: - pass - - try: - date = child.find('NextSchedMaint').text.split('T')[0] - defaults['next_sched_maint'] = datetime.datetime.strptime(date, '%Y-%m-%d').date() - except AttributeError: - pass - - print(defaults) - - obj, created = models.Asset.objects.update_or_create(**defaults) - - if created: - tally[1] += 1 - else: - tally[0] += 1 - - print('Assets - Updated: {}, Created: {}'.format(tally[0], tally[1])) - - def import_collections(self): - tally = [0, 0] - root = self.parse_xml(self.xml_path('TEC_Cable_Collections.xml')) - - for child in root: - defaults = dict() - - defaults['pk'] = int(child.find('ID').text) - defaults['name'] = child.find('Cable_x0020_Trunk').text - - obj, created = models.Collection.objects.update_or_create(**defaults) - - if created: - tally[1] += 1 - else: - tally[0] += 1 - - print('Collections - Updated: {}, Created: {}'.format(tally[0], tally[1])) - - def import_cables(self): - tally = [0, 0] - root = self.parse_xml(self.xml_path('TEC_Cables.xml')) - - for child in root: - defaults = dict() - - defaults['asset_id'] = child.find('Asset_x0020_Number').text - - try: - defaults['description'] = child.find('Type_x0020_of_x0020_Cable').text - except AttributeError: - defaults['description'] = 'None' - - defaults['is_cable'] = True - defaults['category'] = models.AssetCategory.objects.get(pk=9) - - try: - defaults['length'] = child.find('Length_x0020__x0028_m_x0029_').text - except AttributeError: - pass - - defaults['status'] = models.AssetStatus.objects.get(pk=int(child.find('Status').text)) - - try: - defaults['comments'] = child.find('Comments').text - except AttributeError: - pass - - try: - collection_id = int(child.find('Collection').text) - if collection_id != 0: - defaults['collection'] = models.Collection.objects.get(pk=collection_id) - except AttributeError: - pass - - try: - defaults['purchase_price'] = float(child.find('Purchase_x0020_Price').text) - except AttributeError: - pass - - defaults['date_acquired'] = self.epoch - - print(defaults) - - obj, created = models.Asset.objects.update_or_create(**defaults) - - if created: - tally[1] += 1 - else: - tally[0] += 1 - - print('Collections - Updated: {}, Created: {}'.format(tally[0], tally[1])) diff --git a/assets/management/commands/update_old_db_file.py b/assets/management/commands/update_old_db_file.py deleted file mode 100644 index bff0fe22..00000000 --- a/assets/management/commands/update_old_db_file.py +++ /dev/null @@ -1,110 +0,0 @@ -import os -import datetime -import xml.etree.ElementTree as ET -from django.core.management.base import BaseCommand -from django.conf import settings - - -class Command(BaseCommand): - help = 'Imports old db from XML dump' - - epoch = datetime.date(1970, 1, 1) - - def handle(self, *args, **options): - # self.update_statuses() - # self.update_suppliers() - self.update_cable_statuses() - - @staticmethod - def xml_path(file): - return os.path.join(settings.BASE_DIR, 'data/DB_Dump/{}'.format(file)) - - @staticmethod - def parse_xml(file): - tree = ET.parse(file) - - return tree.getroot() - - def update_statuses(self): - file = self.xml_path('TEC_Assets.xml') - tree = ET.parse(file) - root = tree.getroot() - - # map old status pk to new status pk - status_map = { - 2: 2, - 3: 4, - 4: 3, - 5: 5, - 6: 1 - } - - for child in root: - status = int(child.find('StatusID').text) - child.find('StatusID').text = str(status_map[status]) - - tree.write(file) - - def update_suppliers(self): - old_file = self.xml_path('TEC_Asset_Suppliers.xml') - old_tree = ET.parse(old_file) - old_root = old_tree.getroot() - - new_file = self.xml_path('TEC_Asset_Suppliers_new.xml') - new_tree = ET.parse(new_file) - new_root = new_tree.getroot() - - # map old supplier pk to new supplier pk - supplier_map = dict() - - def find_in_old(name, root): - for child in root: - found_id = child.find('Supplier_x0020_Id').text - found_name = child.find('Supplier_x0020_Name').text - - if found_name == name: - return found_id - - for new_child in new_root: - new_id = new_child.find('Supplier_x0020_Id').text - new_name = new_child.find('Supplier_x0020_Name').text - - old_id = find_in_old(new_name, old_root) - - supplier_map[int(old_id)] = int(new_id) - - file = self.xml_path('TEC_Assets.xml') - tree = ET.parse(file) - root = tree.getroot() - - for child in root: - try: - supplier = int(child.find('Supplier_x0020_Id').text) - child.find('Supplier_x0020_Id').text = str(supplier_map[supplier]) - except AttributeError: - pass - - tree.write(file) - - def update_cable_statuses(self): - file = self.xml_path('TEC_Cables.xml') - tree = ET.parse(file) - root = tree.getroot() - - # map old status pk to new status pk - status_map = { - 0: 7, - 1: 3, - 3: 2, - 4: 5, - 6: 6, - 7: 1, - 8: 4, - 9: 2, - } - - for child in root: - status = int(child.find('Status').text) - child.find('Status').text = str(status_map[status]) - - tree.write(file) diff --git a/assets/migrations/0010_auto_20200207_1737.py b/assets/migrations/0010_auto_20200207_1737.py new file mode 100644 index 00000000..6ffeb824 --- /dev/null +++ b/assets/migrations/0010_auto_20200207_1737.py @@ -0,0 +1,17 @@ +# Generated by Django 2.0.13 on 2020-02-07 17:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0009_auto_20200103_2215'), + ] + + operations = [ + migrations.AlterModelOptions( + name='supplier', + options={'ordering': ['name'], 'permissions': (('view_supplier', 'Can view a supplier'),)}, + ), + ] diff --git a/assets/models.py b/assets/models.py index 122fdcb2..5bf830bc 100644 --- a/assets/models.py +++ b/assets/models.py @@ -44,6 +44,7 @@ class Supplier(models.Model, RevisionMixin): name = models.CharField(max_length=80) class Meta: + ordering = ['name'] permissions = ( ('view_supplier', 'Can view a supplier'), ) diff --git a/assets/templates/asset_create.html b/assets/templates/asset_create.html index bc953d2d..14b4b66a 100644 --- a/assets/templates/asset_create.html +++ b/assets/templates/asset_create.html @@ -1,6 +1,5 @@ {% extends 'base_assets.html' %} {% load widget_tweaks %} -{% load asset_templatetags %} {% block title %}Asset {{ object.asset_id }}{% endblock %} {% block content %} diff --git a/assets/templates/asset_list.html b/assets/templates/asset_list.html index 90bb2346..cbd27b28 100644 --- a/assets/templates/asset_list.html +++ b/assets/templates/asset_list.html @@ -17,16 +17,16 @@
-
+
{% render_field form.category|attr:'multiple'|add_class:'form-control selectpicker' data-none-selected-text="Categories" data-header="Categories" data-actions-box="true" %}
-
+
{% render_field form.status|attr:'multiple'|add_class:'form-control selectpicker' data-none-selected-text="Statuses" data-header="Statuses" data-actions-box="true" %}
- +
diff --git a/assets/templates/asset_update.html b/assets/templates/asset_update.html index 6e950887..4f576130 100644 --- a/assets/templates/asset_update.html +++ b/assets/templates/asset_update.html @@ -1,6 +1,5 @@ {% extends 'base_assets.html' %} {% load widget_tweaks %} -{% load asset_templatetags %} {% block title %}Asset {{ object.asset_id }}{% endblock %} {% block content %} @@ -40,7 +39,6 @@
{% include 'partials/asset_buttons.html' %} -
diff --git a/assets/templates/partials/asset_buttons.html b/assets/templates/partials/asset_buttons.html index 39402cbe..3c99225f 100644 --- a/assets/templates/partials/asset_buttons.html +++ b/assets/templates/partials/asset_buttons.html @@ -4,7 +4,7 @@ Duplicate {% elif duplicate %} - + {% elif create %} diff --git a/assets/templates/partials/asset_form.html b/assets/templates/partials/asset_form.html index 08f82d18..45424992 100644 --- a/assets/templates/partials/asset_form.html +++ b/assets/templates/partials/asset_form.html @@ -1,5 +1,4 @@ {% load widget_tweaks %} -{% load asset_templatetags %}
Asset Details diff --git a/assets/templates/partials/asset_list_table_body.html b/assets/templates/partials/asset_list_table_body.html index c952159d..352d15db 100644 --- a/assets/templates/partials/asset_list_table_body.html +++ b/assets/templates/partials/asset_list_table_body.html @@ -1,10 +1,11 @@ {% for item in object_list %} {#
  • {{ item.asset_id }} - {{ item.description }}
  • #} - - {{ item.asset_id }} - {{ item.description }} - {{ item.category }} - {{ item.status }} + + + {{ item.asset_id }} + {{ item.description }} + {{ item.category }} + {{ item.status }}
    View diff --git a/assets/templates/partials/cable_form.html b/assets/templates/partials/cable_form.html index 9390a73c..e5deb006 100644 --- a/assets/templates/partials/cable_form.html +++ b/assets/templates/partials/cable_form.html @@ -1,5 +1,4 @@ {% load widget_tweaks %} -{% load asset_templatetags %}
    Cable Details diff --git a/assets/templates/partials/parent_form.html b/assets/templates/partials/parent_form.html index 5f29240d..f252db36 100644 --- a/assets/templates/partials/parent_form.html +++ b/assets/templates/partials/parent_form.html @@ -1,12 +1,11 @@ {% load widget_tweaks %} -{% load asset_templatetags %}
    Collection Details
    {% if create or edit or duplicate %} -
    +
    {% include 'partials/asset_picker.html' %}
    diff --git a/assets/templates/partials/purchasedetails_form.html b/assets/templates/partials/purchasedetails_form.html index 0e2f1aa6..a19dfa1f 100644 --- a/assets/templates/partials/purchasedetails_form.html +++ b/assets/templates/partials/purchasedetails_form.html @@ -1,5 +1,4 @@ {% load widget_tweaks %} -{% load asset_templatetags %} {% load static %} @@ -23,7 +22,7 @@
    {% if create or edit or duplicate %} -
    +
    {% endblock %} diff --git a/assets/templatetags/asset_templatetags.py b/assets/templatetags/asset_templatetags.py deleted file mode 100644 index 905f9ce2..00000000 --- a/assets/templatetags/asset_templatetags.py +++ /dev/null @@ -1,21 +0,0 @@ -from django import template -from django.template.defaultfilters import stringfilter -from django.utils.safestring import SafeData, mark_safe -from django.utils.text import normalize_newlines -from django.utils.html import escape - -register = template.Library() - - -@register.filter(is_safe=True, needs_autoescape=True) -@stringfilter -def linebreaksn(value, autoescape=True): - """ - Convert all newlines in a piece of plain text to jQuery line breaks - (`\n`). - """ - autoescape = autoescape and not isinstance(value, SafeData) - value = normalize_newlines(value) - if autoescape: - value = escape(value) - return mark_safe(value.replace('\n', '\\n')) diff --git a/assets/templatetags/__init__.py b/assets/tests/__init__.py similarity index 100% rename from assets/templatetags/__init__.py rename to assets/tests/__init__.py diff --git a/assets/tests/pages.py b/assets/tests/pages.py new file mode 100644 index 00000000..e31859e1 --- /dev/null +++ b/assets/tests/pages.py @@ -0,0 +1,188 @@ +# Collection of page object models for use within tests. +from pypom import Page, Region +from selenium.webdriver.common.by import By +from selenium.webdriver.support import expected_conditions +from selenium.webdriver import Chrome +from django.urls import reverse +from PyRIGS.tests import regions +from PyRIGS.tests.pages import BasePage, FormPage +import pdb + + +class AssetList(BasePage): + URL_TEMPLATE = '/assets/asset/list' + + _asset_item_locator = (By.CLASS_NAME, 'assetRow') + _search_text_locator = (By.ID, 'id_query') + _status_select_locator = (By.CSS_SELECTOR, 'div#status-group>div.bootstrap-select') + _category_select_locator = (By.CSS_SELECTOR, 'div#category-group>div.bootstrap-select') + _go_button_locator = (By.ID, 'filter-submit') + + class AssetListRow(Region): + _asset_id_locator = (By.CLASS_NAME, "assetID") + _asset_description_locator = (By.CLASS_NAME, "assetDesc") + _asset_category_locator = (By.CLASS_NAME, "assetCategory") + _asset_status_locator = (By.CLASS_NAME, "assetStatus") + + @property + def id(self): + return self.find_element(*self._asset_id_locator).text + + @property + def description(self): + return self.find_element(*self._asset_description_locator).text + + @property + def category(self): + return self.find_element(*self._asset_category_locator).text + + @property + def status(self): + return self.find_element(*self._asset_status_locator).text + + @property + def assets(self): + return [self.AssetListRow(self, i) for i in self.find_elements(*self._asset_item_locator)] + + @property + def query(self): + return self.find_element(*self._search_text_locator).text + + def set_query(self, queryString): + element = self.find_element(*self._search_text_locator) + element.clear() + element.send_keys(queryString) + + def search(self): + self.find_element(*self._go_button_locator).click() + + @property + def status_selector(self): + return regions.BootstrapSelectElement(self, self.find_element(*self._status_select_locator)) + + @property + def category_selector(self): + return regions.BootstrapSelectElement(self, self.find_element(*self._category_select_locator)) + + +class AssetForm(FormPage): + _purchased_from_select_locator = (By.CSS_SELECTOR, 'div#purchased-from-group>div.bootstrap-select') + _parent_select_locator = (By.CSS_SELECTOR, 'div#parent-group>div.bootstrap-select') + _submit_locator = (By.CLASS_NAME, 'btn-success') + form_items = { + 'asset_id': (regions.TextBox, (By.ID, 'id_asset_id')), + 'description': (regions.TextBox, (By.ID, 'id_description')), + 'is_cable': (regions.CheckBox, (By.ID, 'id_is_cable')), + 'serial_number': (regions.TextBox, (By.ID, 'id_serial_number')), + 'comments': (regions.TextBox, (By.ID, 'id_comments')), + 'purchase_price': (regions.TextBox, (By.ID, 'id_purchase_price')), + 'salvage_value': (regions.TextBox, (By.ID, 'id_salvage_value')), + 'date_acquired': (regions.DatePicker, (By.ID, 'id_date_acquired')), + 'date_sold': (regions.DatePicker, (By.ID, 'id_date_sold')), + 'category': (regions.SingleSelectPicker, (By.ID, 'id_category')), + 'status': (regions.SingleSelectPicker, (By.ID, 'id_status')), + + 'plug': (regions.SingleSelectPicker, (By.ID, 'id_plug')), + 'socket': (regions.SingleSelectPicker, (By.ID, 'id_socket')), + 'length': (regions.TextBox, (By.ID, 'id_length')), + 'csa': (regions.TextBox, (By.ID, 'id_csa')), + 'circuits': (regions.TextBox, (By.ID, 'id_circuits')), + 'cores': (regions.TextBox, (By.ID, 'id_cores')) + } + + @property + def purchased_from_selector(self): + return regions.BootstrapSelectElement(self, self.find_element(*self._purchased_from_select_locator)) + + @property + def parent_selector(self): + return regions.BootstrapSelectElement(self, self.find_element(*self._parent_select_locator)) + + def submit(self): + previous_errors = self.errors + self.find_element(*self._submit_locator).click() + self.wait.until(lambda x: self.errors != previous_errors or self.success) + + +class AssetEdit(AssetForm): + URL_TEMPLATE = '/assets/asset/id/{asset_id}/edit/' + + @property + def success(self): + return '/edit' not in self.driver.current_url + + +class AssetCreate(AssetForm): + URL_TEMPLATE = '/assets/asset/create/' + + @property + def success(self): + return '/create' not in self.driver.current_url + + +class AssetDuplicate(AssetForm): + URL_TEMPLATE = '/assets/asset/id/{asset_id}/duplicate' + + @property + def success(self): + return '/duplicate' not in self.driver.current_url + + +class SupplierList(BasePage): + URL_TEMPLATE = reverse('supplier_list') + + _supplier_item_locator = (By.CLASS_NAME, 'supplierRow') + _search_text_locator = (By.ID, 'id_query') + _go_button_locator = (By.ID, 'id_search') + + class SupplierListRow(Region): + _name_locator = (By.CLASS_NAME, "supplierName") + + @property + def name(self): + return self.find_element(*self._name_locator).text + + @property + def suppliers(self): + return [self.SupplierListRow(self, i) for i in self.find_elements(*self._supplier_item_locator)] + + @property + def query(self): + return self.find_element(*self._search_text_locator).text + + def set_query(self, queryString): + element = self.find_element(*self._search_text_locator) + element.clear() + element.send_keys(queryString) + + def search(self): + self.find_element(*self._go_button_locator).click() + + +class SupplierForm(FormPage): + _submit_locator = (By.CLASS_NAME, 'btn-success') + form_items = { + 'name': (regions.TextBox, (By.ID, 'id_name')), + } + + def submit(self): + previous_errors = self.errors + self.find_element(*self._submit_locator).click() + self.wait.until(lambda x: self.errors != previous_errors or self.success) + + +class SupplierCreate(SupplierForm): + URL_TEMPLATE = reverse('supplier_create') + + @property + def success(self): + return '/create' not in self.driver.current_url + + +class SupplierEdit(SupplierForm): + # TODO This should be using reverse + URL_TEMPLATE = '/assets/supplier/{supplier_id}/edit' + + @property + def success(self): + return '/edit' not in self.driver.current_url diff --git a/assets/tests/test_assets.py b/assets/tests/test_assets.py new file mode 100644 index 00000000..9a96fcbe --- /dev/null +++ b/assets/tests/test_assets.py @@ -0,0 +1,583 @@ +from . import pages +from django.core.management import call_command +from django.test import TestCase +from assets import models +from django.test.utils import override_settings +from django.urls import reverse +from urllib.parse import urlparse +from RIGS import models as rigsmodels +from PyRIGS.tests.base import BaseTest, AutoLoginTest +from assets import models, urls +from reversion import revisions as reversion +from selenium.webdriver.common.keys import Keys +import datetime + + +class TestAssetList(AutoLoginTest): + def setUp(self): + super().setUp() + sound = models.AssetCategory.objects.create(name="Sound") + lighting = models.AssetCategory.objects.create(name="Lighting") + + working = models.AssetStatus.objects.create(name="Working", should_show=True) + broken = models.AssetStatus.objects.create(name="Broken", should_show=False) + + models.Asset.objects.create(asset_id="1", description="Broken XLR", status=broken, category=sound, date_acquired=datetime.date(2020, 2, 1)) + models.Asset.objects.create(asset_id="10", description="Working Mic", status=working, category=sound, date_acquired=datetime.date(2020, 2, 1)) + models.Asset.objects.create(asset_id="2", description="A light", status=working, category=lighting, date_acquired=datetime.date(2020, 2, 1)) + models.Asset.objects.create(asset_id="C1", description="The pearl", status=broken, category=lighting, date_acquired=datetime.date(2020, 2, 1)) + self.page = pages.AssetList(self.driver, self.live_server_url).open() + + def test_default_statuses_applied(self): + # Only the working stuff should be shown initially + assetDescriptions = list(map(lambda x: x.description, self.page.assets)) + self.assertEqual(2, len(assetDescriptions)) + self.assertIn("A light", assetDescriptions) + self.assertIn("Working Mic", assetDescriptions) + + def test_asset_order(self): + # Only the working stuff should be shown initially + self.page.status_selector.open() + self.page.status_selector.set_option("Broken", True) + self.page.status_selector.close() + + self.page.search() + + assetIDs = list(map(lambda x: x.id, self.page.assets)) + self.assertEqual("1", assetIDs[0]) + self.assertEqual("2", assetIDs[1]) + self.assertEqual("10", assetIDs[2]) + self.assertEqual("C1", assetIDs[3]) + + def test_search(self): + self.page.set_query("10") + self.page.search() + self.assertTrue(len(self.page.assets) == 1) + self.assertEqual("Working Mic", self.page.assets[0].description) + self.assertEqual("10", self.page.assets[0].id) + + self.page.set_query("light") + self.page.search() + self.assertTrue(len(self.page.assets) == 1) + self.assertEqual("A light", self.page.assets[0].description) + + self.page.set_query("Random string") + self.page.search() + self.assertTrue(len(self.page.assets) == 0) + + self.page.set_query("") + self.page.search() + # Only working stuff shown by default + self.assertTrue(len(self.page.assets) == 2) + + self.page.status_selector.toggle() + self.assertTrue(self.page.status_selector.is_open) + self.page.status_selector.select_all() + self.page.status_selector.toggle() + self.assertFalse(self.page.status_selector.is_open) + self.page.search() + self.assertTrue(len(self.page.assets) == 4) + + self.page.category_selector.toggle() + self.assertTrue(self.page.category_selector.is_open) + self.page.category_selector.set_option("Sound", True) + self.page.category_selector.close() + self.assertFalse(self.page.category_selector.is_open) + self.page.search() + self.assertTrue(len(self.page.assets) == 2) + assetIDs = list(map(lambda x: x.id, self.page.assets)) + self.assertEqual("1", assetIDs[0]) + self.assertEqual("10", assetIDs[1]) + + +class TestAssetForm(AutoLoginTest): + def setUp(self): + super().setUp() + self.category = models.AssetCategory.objects.create(name="Health & Safety") + self.status = models.AssetStatus.objects.create(name="O.K.", should_show=True) + self.supplier = models.Supplier.objects.create(name="Fullmetal Heavy Industry") + self.parent = models.Asset.objects.create(asset_id="9000", description="Shelf", status=self.status, category=self.category, date_acquired=datetime.date(2000, 1, 1)) + self.connector = models.Connector.objects.create(description="IEC", current_rating=10, voltage_rating=240, num_pins=3) + self.page = pages.AssetCreate(self.driver, self.live_server_url).open() + + def test_asset_create(self): + # Test that ID is automatically assigned and properly incremented + self.assertIn(self.page.asset_id, "9001") + + self.page.remove_all_required() + self.page.asset_id = "XX$X" + self.page.submit() + self.assertFalse(self.page.success) + self.assertIn("An Asset ID can only consist of letters and numbers, with a final number", self.page.errors["Asset id"]) + self.assertIn("This field is required.", self.page.errors["Description"]) + + self.page.open() + + self.page.description = "Bodge Lead" + self.page.category = "Health & Safety" + self.page.status = "O.K." + self.page.serial_number = "0124567890-SAUSAGE" + self.page.comments = "This is actually a sledgehammer, not a cable..." + + self.page.purchased_from_selector.toggle() + self.assertTrue(self.page.purchased_from_selector.is_open) + self.page.purchased_from_selector.search(self.supplier.name[:-8]) + self.page.purchased_from_selector.set_option(self.supplier.name, True) + self.assertFalse(self.page.purchased_from_selector.is_open) + self.page.purchase_price = "12.99" + self.page.salvage_value = "99.12" + self.date_acquired = "05022020" + + self.page.parent_selector.toggle() + self.assertTrue(self.page.parent_selector.is_open) + # Searching it by ID autoselects it + self.page.parent_selector.search(self.parent.asset_id) + # Needed here but not earlier for whatever reason + self.driver.implicitly_wait(1) + # self.page.parent_selector.set_option(self.parent.asset_id + " | " + self.parent.description, True) + # Need to explicitly close as we haven't selected anything to trigger the auto close + self.page.parent_selector.search(Keys.ESCAPE) + self.assertFalse(self.page.parent_selector.is_open) + self.assertTrue(self.page.parent_selector.options[0].selected) + + self.assertFalse(self.driver.find_element_by_id('cable-table').is_displayed()) + + self.page.submit() + self.assertTrue(self.page.success) + + def test_cable_create(self): + self.page.description = "IEC -> IEC" + self.page.category = "Health & Safety" + self.page.status = "O.K." + self.page.serial_number = "MELON-MELON-MELON" + self.page.comments = "You might need that" + self.page.is_cable = True + + self.assertTrue(self.driver.find_element_by_id('cable-table').is_displayed()) + self.page.plug = "IEC" + self.page.socket = "IEC" + self.page.length = 10 + self.page.csa = "1.5" + self.page.circuits = 1 + self.page.cores = 3 + + self.page.submit() + self.assertTrue(self.page.success) + + def test_asset_edit(self): + self.page = pages.AssetEdit(self.driver, self.live_server_url, asset_id=self.parent.asset_id).open() + + self.assertTrue(self.driver.find_element_by_id('id_asset_id').get_attribute('readonly') is not None) + + new_description = "Big Shelf" + self.page.description = new_description + + self.page.submit() + self.assertTrue(self.page.success) + + self.assertEqual(models.Asset.objects.get(asset_id=self.parent.asset_id).description, new_description) + + def test_asset_duplicate(self): + self.page = pages.AssetDuplicate(self.driver, self.live_server_url, asset_id=self.parent.asset_id).open() + + self.assertNotEqual(self.parent.asset_id, self.page.asset_id) + self.assertEqual(self.parent.description, self.page.description) + self.assertEqual(self.parent.status.name, self.page.status) + self.assertEqual(self.parent.category.name, self.page.category) + self.assertEqual(self.parent.date_acquired, self.page.date_acquired.date()) + + self.page.submit() + self.assertTrue(self.page.success) + self.assertEqual(models.Asset.objects.last().description, self.parent.description) + + +class TestSupplierList(AutoLoginTest): + def setUp(self): + super().setUp() + models.Supplier.objects.create(name="Fullmetal Heavy Industry") + models.Supplier.objects.create(name="Acme.") + models.Supplier.objects.create(name="TEC PA & Lighting") + models.Supplier.objects.create(name="Caterpillar Inc.") + models.Supplier.objects.create(name="N.E.R.D") + models.Supplier.objects.create(name="Khumalo") + models.Supplier.objects.create(name="1984 Incorporated") + self.page = pages.SupplierList(self.driver, self.live_server_url).open() + + # Should be sorted alphabetically + def test_order(self): + names = list(map(lambda x: x.name, self.page.suppliers)) + self.assertEqual("1984 Incorporated", names[0]) + self.assertEqual("Acme.", names[1]) + self.assertEqual("Caterpillar Inc.", names[2]) + self.assertEqual("Fullmetal Heavy Industry", names[3]) + self.assertEqual("Khumalo", names[4]) + self.assertEqual("N.E.R.D", names[5]) + self.assertEqual("TEC PA & Lighting", names[6]) + + def test_search(self): + self.page.set_query("TEC") + self.page.search() + self.assertTrue(len(self.page.suppliers) == 1) + self.assertEqual("TEC PA & Lighting", self.page.suppliers[0].name) + + self.page.set_query("") + self.page.search() + self.assertTrue(len(self.page.suppliers) == 7) + + self.page.set_query("This is not a supplier") + self.page.search() + self.assertTrue(len(self.page.suppliers) == 0) + + +class TestSupplierCreateAndEdit(AutoLoginTest): + def setUp(self): + super().setUp() + self.supplier = models.Supplier.objects.create(name="Fullmetal Heavy Industry") + + def test_supplier_create(self): + self.page = pages.SupplierCreate(self.driver, self.live_server_url).open() + + self.page.remove_all_required() + self.page.submit() + self.assertFalse(self.page.success) + self.assertIn("This field is required.", self.page.errors["Name"]) + + self.page.name = "Optican Health Supplies" + self.page.submit() + self.assertTrue(self.page.success) + + def test_supplier_edit(self): + self.page = pages.SupplierEdit(self.driver, self.live_server_url, supplier_id=self.supplier.pk).open() + + self.assertEquals("Fullmetal Heavy Industry", self.page.name) + new_name = "Cyberdyne Systems" + self.page.name = new_name + self.page.submit() + self.assertTrue(self.page.success) + + +class TestSupplierValidation(TestCase): + @classmethod + def setUpTestData(cls): + cls.profile = rigsmodels.Profile.objects.create(username="SupplierValidationTest", email="SVT@test.com", is_superuser=True, is_active=True, is_staff=True) + cls.supplier = models.Supplier.objects.create(name="Gadgetron Corporation") + + def setUp(self): + self.profile.set_password('testuser') + self.profile.save() + self.assertTrue(self.client.login(username=self.profile.username, password='testuser')) + + def test_create(self): + url = reverse('supplier_create') + response = self.client.post(url) + self.assertFormError(response, 'form', 'name', 'This field is required.') + + def test_edit(self): + url = reverse('supplier_update', kwargs={'pk': self.supplier.pk}) + response = self.client.post(url, {'name': ""}) + self.assertFormError(response, 'form', 'name', 'This field is required.') + + +class Test404(TestCase): + @classmethod + def setUpTestData(cls): + cls.profile = rigsmodels.Profile.objects.create(username="404Test", email="404@test.com", is_superuser=True, is_active=True, is_staff=True) + + def setUp(self): + self.profile.set_password('testuser') + self.profile.save() + self.assertTrue(self.client.login(username=self.profile.username, password='testuser')) + + def test(self): + urls = {'asset_detail', 'asset_update', 'asset_duplicate', 'supplier_detail', 'supplier_update'} + for url_name in urls: + request_url = reverse(url_name, kwargs={'pk': "0000"}) + response = self.client.get(request_url, follow=True) + self.assertEqual(response.status_code, 404) + + +# @tag('slow') TODO: req. Django 3.0 +class TestAccessLevels(TestCase): + @override_settings(DEBUG=True) + def setUp(self): + super().setUp() + # Shortcut to create the levels - bonus side effect of testing the command (hopefully) matches production + call_command('generateSampleData') + + # Nothing should be available to the unauthenticated + def test_unauthenticated(self): + for url in urls.urlpatterns: + if url.name is not None: + pattern = str(url.pattern) + if "json" in url.name or pattern: + # TODO + pass + elif ":pk>" in pattern: + request_url = reverse(url.name, kwargs={'pk': 9}) + else: + request_url = reverse(url.name) + response = self.client.get(request_url, HTTP_HOST='example.com') + self.assertEqual(response.status_code, 302) + response = self.client.get(request_url, follow=True, HTTP_HOST='example.com') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'login') + + def test_basic_access(self): + self.assertTrue(self.client.login(username="basic", password="basic")) + + url = reverse('asset_list') + response = self.client.get(url) + # Check edit and duplicate buttons not shown in list + self.assertNotContains(response, 'Edit') + self.assertNotContains(response, 'Duplicate') + + url = reverse('asset_detail', kwargs={'pk': "9000"}) + response = self.client.get(url) + self.assertNotContains(response, 'Purchase Details') + self.assertNotContains(response, 'View Revision History') + + urls = {'asset_history', 'asset_update', 'asset_duplicate'} + for url_name in urls: + request_url = reverse(url_name, kwargs={'pk': "9000"}) + response = self.client.get(request_url, follow=True) + self.assertEqual(response.status_code, 403) + + request_url = reverse('supplier_create') + response = self.client.get(request_url, follow=True) + self.assertEqual(response.status_code, 403) + + request_url = reverse('supplier_update', kwargs={'pk': "1"}) + response = self.client.get(request_url, follow=True) + self.assertEqual(response.status_code, 403) + + def test_keyholder_access(self): + self.assertTrue(self.client.login(username="keyholder", password="keyholder")) + + url = reverse('asset_list') + response = self.client.get(url) + # Check edit and duplicate buttons shown in list + self.assertContains(response, 'Edit') + self.assertContains(response, 'Duplicate') + + url = reverse('asset_detail', kwargs={'pk': "9000"}) + response = self.client.get(url) + self.assertContains(response, 'Purchase Details') + self.assertContains(response, 'View Revision History') + + # def test_finance_access(self): Level not used in assets currently + + +class TestFormValidation(TestCase): + @classmethod + def setUpTestData(cls): + cls.profile = rigsmodels.Profile.objects.create(username="AssetCreateValidationTest", email="acvt@test.com", is_superuser=True, is_active=True, is_staff=True) + cls.category = models.AssetCategory.objects.create(name="Sound") + cls.status = models.AssetStatus.objects.create(name="Broken", should_show=True) + cls.asset = models.Asset.objects.create(asset_id="9999", description="The Office", status=cls.status, category=cls.category, date_acquired=datetime.date(2018, 6, 15)) + cls.connector = models.Connector.objects.create(description="16A IEC", current_rating=16, voltage_rating=240, num_pins=3) + cls.cable_asset = models.Asset.objects.create(asset_id="666", description="125A -> Jack", comments="The cable from Hell...", status=cls.status, category=cls.category, date_acquired=datetime.date(2006, 6, 6), is_cable=True, plug=cls.connector, socket=cls.connector, length=10, csa="1.5", circuits=1, cores=3) + + def setUp(self): + self.profile.set_password('testuser') + self.profile.save() + self.assertTrue(self.client.login(username=self.profile.username, password='testuser')) + + def test_asset_create(self): + url = reverse('asset_create') + response = self.client.post(url, {'date_sold': '2000-01-01', 'date_acquired': '2020-01-01', 'purchase_price': '-30', 'salvage_value': '-30'}) + self.assertFormError(response, 'form', 'asset_id', 'This field is required.') + self.assertFormError(response, 'form', 'description', 'This field is required.') + self.assertFormError(response, 'form', 'status', 'This field is required.') + self.assertFormError(response, 'form', 'category', 'This field is required.') + + self.assertFormError(response, 'form', 'date_sold', 'Cannot sell an item before it is acquired') + self.assertFormError(response, 'form', 'purchase_price', 'A price cannot be negative') + self.assertFormError(response, 'form', 'salvage_value', 'A price cannot be negative') + + def test_cable_create(self): + url = reverse('asset_create') + response = self.client.post(url, {'asset_id': 'X$%A', 'is_cable': True}) + self.assertFormError(response, 'form', 'asset_id', 'An Asset ID can only consist of letters and numbers, with a final number') + + self.assertFormError(response, 'form', 'plug', 'A cable must have a plug') + self.assertFormError(response, 'form', 'socket', 'A cable must have a socket') + self.assertFormError(response, 'form', 'length', 'The length of a cable must be more than 0') + self.assertFormError(response, 'form', 'csa', 'The CSA of a cable must be more than 0') + self.assertFormError(response, 'form', 'circuits', 'There must be at least one circuit in a cable') + self.assertFormError(response, 'form', 'cores', 'There must be at least one core in a cable') + + # Given that validation is done at model level it *shouldn't* need retesting...gonna do it anyway! + def test_asset_edit(self): + url = reverse('asset_update', kwargs={'pk': self.asset.asset_id}) + response = self.client.post(url, {'date_sold': '2000-12-01', 'date_acquired': '2020-12-01', 'purchase_price': '-50', 'salvage_value': '-50', 'description': "", 'status': "", 'category': ""}) + # self.assertFormError(response, 'form', 'asset_id', 'This field is required.') + self.assertFormError(response, 'form', 'description', 'This field is required.') + self.assertFormError(response, 'form', 'status', 'This field is required.') + self.assertFormError(response, 'form', 'category', 'This field is required.') + + self.assertFormError(response, 'form', 'date_sold', 'Cannot sell an item before it is acquired') + self.assertFormError(response, 'form', 'purchase_price', 'A price cannot be negative') + self.assertFormError(response, 'form', 'salvage_value', 'A price cannot be negative') + + def test_cable_edit(self): + url = reverse('asset_update', kwargs={'pk': self.cable_asset.asset_id}) + # TODO Why do I have to send is_cable=True here? + response = self.client.post(url, {'is_cable': True, 'length': -3, 'csa': -3, 'circuits': -4, 'cores': -8}) + + # Can't figure out how to select the 'none' option... + # self.assertFormError(response, 'form', 'plug', 'A cable must have a plug') + # self.assertFormError(response, 'form', 'socket', 'A cable must have a socket') + self.assertFormError(response, 'form', 'length', 'The length of a cable must be more than 0') + self.assertFormError(response, 'form', 'csa', 'The CSA of a cable must be more than 0') + self.assertFormError(response, 'form', 'circuits', 'There must be at least one circuit in a cable') + self.assertFormError(response, 'form', 'cores', 'There must be at least one core in a cable') + + def test_asset_duplicate(self): + url = reverse('asset_duplicate', kwargs={'pk': self.cable_asset.asset_id}) + response = self.client.post(url, {'is_cable': True, 'length': 0, 'csa': 0, 'circuits': 0, 'cores': 0}) + + self.assertFormError(response, 'form', 'length', 'The length of a cable must be more than 0') + self.assertFormError(response, 'form', 'csa', 'The CSA of a cable must be more than 0') + self.assertFormError(response, 'form', 'circuits', 'There must be at least one circuit in a cable') + self.assertFormError(response, 'form', 'cores', 'There must be at least one core in a cable') + + +class TestSampleDataGenerator(TestCase): + @override_settings(DEBUG=True) + def test_generate_sample_data(self): + # Run the management command and check there are no exceptions + call_command('generateSampleAssetsData') + + # Check there are lots + self.assertTrue(models.Asset.objects.all().count() > 50) + self.assertTrue(models.Supplier.objects.all().count() > 50) + + @override_settings(DEBUG=True) + def test_delete_sample_data(self): + call_command('deleteSampleData') + + self.assertTrue(models.Asset.objects.all().count() == 0) + self.assertTrue(models.Supplier.objects.all().count() == 0) + + def test_production_exception(self): + from django.core.management.base import CommandError + + self.assertRaisesRegex(CommandError, ".*production", call_command, 'generateSampleAssetsData') + self.assertRaisesRegex(CommandError, ".*production", call_command, 'deleteSampleData') + + +class TestVersioningViews(TestCase): + @classmethod + def setUpTestData(cls): + cls.profile = rigsmodels.Profile.objects.create(username="VersionTest", email="version@test.com", is_superuser=True, is_active=True, is_staff=True) + + working = models.AssetStatus.objects.create(name="Working", should_show=True) + broken = models.AssetStatus.objects.create(name="Broken", should_show=False) + general = models.AssetCategory.objects.create(name="General") + lighting = models.AssetCategory.objects.create(name="Lighting") + + cls.assets = {} + + with reversion.create_revision(): + reversion.set_user(cls.profile) + cls.assets[1] = models.Asset.objects.create(asset_id="1991", description="Spaceflower", status=broken, category=lighting, date_acquired=datetime.date(1991, 12, 26)) + + with reversion.create_revision(): + reversion.set_user(cls.profile) + cls.assets[2] = models.Asset.objects.create(asset_id="0001", description="Virgil", status=working, category=lighting, date_acquired=datetime.date(2015, 1, 1)) + + with reversion.create_revision(): + reversion.set_user(cls.profile) + cls.assets[1].status = working + cls.assets[1].save() + + def setUp(self): + self.profile.set_password('testuser') + self.profile.save() + self.assertTrue(self.client.login(username=self.profile.username, password='testuser')) + + def test_history_loads_successfully(self): + request_url = reverse('asset_history', kwargs={'pk': self.assets[1].asset_id}) + + response = self.client.get(request_url, follow=True) + self.assertEqual(response.status_code, 200) + + def test_activity_table_loads_successfully(self): + request_url = reverse('asset_activity_table') + + response = self.client.get(request_url, follow=True) + self.assertEqual(response.status_code, 200) + + +class TestEmbeddedViews(TestCase): + @classmethod + def setUpTestData(cls): + cls.profile = rigsmodels.Profile.objects.create(username="EmbeddedViewsTest", email="embedded@test.com", is_superuser=True, is_active=True, is_staff=True) + + working = models.AssetStatus.objects.create(name="Working", should_show=True) + lighting = models.AssetCategory.objects.create(name="Lighting") + + cls.assets = { + 1: models.Asset.objects.create(asset_id="1991", description="Spaceflower", status=working, category=lighting, date_acquired=datetime.date(1991, 12, 26)) + } + + def setUp(self): + self.profile.set_password('testuser') + self.profile.save() + + def testLoginRedirect(self): + request_url = reverse('asset_embed', kwargs={'pk': self.assets[1].asset_id}) + expected_url = "{0}?next={1}".format(reverse('login_embed'), request_url) + + # Request the page and check it redirects + response = self.client.get(request_url, follow=True) + self.assertRedirects(response, expected_url, status_code=302, target_status_code=200) + + # Now login + self.assertTrue(self.client.login(username=self.profile.username, password='testuser')) + + # And check that it no longer redirects + response = self.client.get(request_url, follow=True) + self.assertEqual(len(response.redirect_chain), 0) + + def testLoginCookieWarning(self): + login_url = reverse('login_embed') + response = self.client.post(login_url, follow=True) + self.assertContains(response, "Cookies do not seem to be enabled") + + def testXFrameHeaders(self): + asset_url = reverse('asset_embed', kwargs={'pk': self.assets[1].asset_id}) + login_url = reverse('login_embed') + + self.assertTrue(self.client.login(username=self.profile.username, password='testuser')) + + response = self.client.get(asset_url, follow=True) + with self.assertRaises(KeyError): + response._headers["X-Frame-Options"] + + response = self.client.get(login_url, follow=True) + with self.assertRaises(KeyError): + response._headers["X-Frame-Options"] + + def testOEmbed(self): + asset_url = reverse('asset_detail', kwargs={'pk': self.assets[1].asset_id}) + asset_embed_url = reverse('asset_embed', kwargs={'pk': self.assets[1].asset_id}) + oembed_url = reverse('asset_oembed', kwargs={'pk': self.assets[1].asset_id}) + + alt_oembed_url = reverse('asset_oembed', kwargs={'pk': 999}) + alt_asset_embed_url = reverse('asset_embed', kwargs={'pk': 999}) + + # Test the meta tag is in place + response = self.client.get(asset_url, follow=True, HTTP_HOST='example.com') + self.assertContains(response, '/', has_oembed(oembed_view="asset_oembed")(views.AssetDetail.as_view()), name='asset_detail'), path('asset/create/', permission_required_with_403('assets.add_asset') (views.AssetCreate.as_view()), name='asset_create'), @@ -38,7 +37,7 @@ urlpatterns = [ (views.SupplierCreate.as_view()), name='supplier_create'), path('supplier//edit', permission_required_with_403('assets.change_supplier') (views.SupplierUpdate.as_view()), name='supplier_update'), - path('supplier//history/', views.SupplierVersionHistory.as_view(), + path('supplier//history/', views.SupplierVersionHistory.as_view(), name='supplier_history', kwargs={'model': models.Supplier}), path('supplier/search/', views.SupplierSearch.as_view(), name='supplier_search_json'), diff --git a/assets/views.py b/assets/views.py index 96e8940d..29eaa7aa 100644 --- a/assets/views.py +++ b/assets/views.py @@ -1,6 +1,6 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.http import JsonResponse -from django.http import HttpResponse +from django.http import HttpResponse, Http404 from django.views import generic from django.views.decorators.csrf import csrf_exempt from django.utils.decorators import method_decorator @@ -87,8 +87,7 @@ class AssetIDUrlMixin: # Get the single item from the filtered queryset obj = queryset.get() except queryset.model.DoesNotExist: - raise Http404(_("No %(verbose_name)s found matching the query") % - {'verbose_name': queryset.model._meta.verbose_name}) + raise Http404("No assets found matching the query") return obj @@ -213,7 +212,6 @@ class SupplierSearch(SupplierList): for supplier in context["object_list"]: result.append({"id": supplier.pk, "name": supplier.name}) - return JsonResponse(result, safe=False) diff --git a/requirements.txt b/requirements.txt index df9d354a..48634106 100644 --- a/requirements.txt +++ b/requirements.txt @@ -38,3 +38,4 @@ z3c.rml==3.5.0 zope.event==4.3.0 zope.interface==4.5.0 zope.schema==4.5.0 +pypom==2.2.0 \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 53238def..a902a7ab 100644 --- a/templates/base.html +++ b/templates/base.html @@ -52,7 +52,7 @@ {% endblock %}