Add assets test suite (#400)

* Started POM and assets test

* FEAT: Adapt unit tests from RIGS to assets

* CHORE: pep8...

* Added Asset Create and Edit forms

* Add non-cable asset creation test

* CHORE: Frickin pep8...

* Add cable asset creation test

* Basic asset create validation testing

* Asset edit tests are here

A bit dodgy in places but par for the course for me :P

* Add access level tests

* Delete unused code

Much less effort way to increase coverage stats :D

* Add delete sample data test for completeness

Chasing that sweet 100% coverage...

* Add supplier list page + tests

Also fix the supplier page not being ordered alphabetically

* Helps if I add the migration...

* Add supplier create/edit tests

* Asset duplicate tests

Also fixed some random bugs

* Asset search tests

* 404 tests and test that everything requires authentication

* Test visibility of form errors

And fix supplier form not displaying errors correctly!

* Fix broken search test


Co-authored-by: Matthew Smith <mattysmith22@googlemail.com>
This commit is contained in:
2020-02-08 13:52:07 +00:00
committed by GitHub
parent 116c497590
commit ae151ed45e
33 changed files with 1079 additions and 418 deletions

36
PyRIGS/tests/base.py Normal file
View File

@@ -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")

86
PyRIGS/tests/pages.py Normal file
View File

@@ -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()

133
PyRIGS/tests/regions.py Normal file
View File

@@ -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)

View File

@@ -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 %} {% load widget_tweaks %}
{% block title %}Organisation | {{ object.name }}{% endblock %} {% block title %}Organisation | {{ object.name }}{% endblock %}

View File

@@ -20,15 +20,13 @@ from selenium.webdriver.support.ui import WebDriverWait
from RIGS import models 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(): import sys
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 UserRegistrationTest(LiveServerTestCase): class UserRegistrationTest(LiveServerTestCase):

View File

@@ -1,5 +1,3 @@
import pytz import pytz
from reversion import revisions as reversion from reversion import revisions as reversion
from django.conf import settings from django.conf import settings
@@ -8,6 +6,7 @@ from django.test import TestCase
from RIGS import models, versioning from RIGS import models, versioning
from datetime import date, timedelta, datetime, time from datetime import date, timedelta, datetime, time
from decimal import * from decimal import *
from PyRIGS.tests.base import create_browser
class ProfileTestCase(TestCase): class ProfileTestCase(TestCase):

View File

@@ -1,5 +0,0 @@
from django.apps import AppConfig
class AssetsConfig(AppConfig):
name = 'assets'

View File

@@ -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']

View File

@@ -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]))

View File

@@ -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)

View File

@@ -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'),)},
),
]

View File

@@ -44,6 +44,7 @@ class Supplier(models.Model, RevisionMixin):
name = models.CharField(max_length=80) name = models.CharField(max_length=80)
class Meta: class Meta:
ordering = ['name']
permissions = ( permissions = (
('view_supplier', 'Can view a supplier'), ('view_supplier', 'Can view a supplier'),
) )

View File

@@ -1,6 +1,5 @@
{% extends 'base_assets.html' %} {% extends 'base_assets.html' %}
{% load widget_tweaks %} {% load widget_tweaks %}
{% load asset_templatetags %}
{% block title %}Asset {{ object.asset_id }}{% endblock %} {% block title %}Asset {{ object.asset_id }}{% endblock %}
{% block content %} {% block content %}

View File

@@ -17,16 +17,16 @@
</div> </div>
<br> <br>
<div style="margin-top: 1em;" class="pull-right"> <div style="margin-top: 1em;" class="pull-right">
<div class="form-group"> <div id="category-group" class="form-group">
<label for="category" class="sr-only">Category</label> <label for="category" class="sr-only">Category</label>
{% 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.category|attr:'multiple'|add_class:'form-control selectpicker' data-none-selected-text="Categories" data-header="Categories" data-actions-box="true" %}
</div> </div>
<div class="form-group"> <div id="status-group" class="form-group">
<label for="status" class="sr-only">Status</label> <label for="status" class="sr-only">Status</label>
{% render_field form.status|attr:'multiple'|add_class:'form-control selectpicker' data-none-selected-text="Statuses" data-header="Statuses" 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" %}
</div> </div>
<!---TODO: Auto filter whenever an option is selected, instead of using a button --> <!---TODO: Auto filter whenever an option is selected, instead of using a button -->
<button type="submit" class="btn btn-default">Filter</button> <button id="filter-submit" type="submit" class="btn btn-default">Filter</button>
</div> </div>
</form> </form>

View File

@@ -1,6 +1,5 @@
{% extends 'base_assets.html' %} {% extends 'base_assets.html' %}
{% load widget_tweaks %} {% load widget_tweaks %}
{% load asset_templatetags %}
{% block title %}Asset {{ object.asset_id }}{% endblock %} {% block title %}Asset {{ object.asset_id }}{% endblock %}
{% block content %} {% block content %}
@@ -40,7 +39,6 @@
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
{% include 'partials/asset_buttons.html' %} {% include 'partials/asset_buttons.html' %}
</div>
</div> </div>
</form> </form>

View File

@@ -4,7 +4,7 @@
<a class="btn btn-default" href="{% url 'asset_duplicate' object.pk %}"><i class="glyphicon glyphicon-duplicate"></i> Duplicate</a> <a class="btn btn-default" href="{% url 'asset_duplicate' object.pk %}"><i class="glyphicon glyphicon-duplicate"></i> Duplicate</a>
{% elif duplicate %} {% elif duplicate %}
<!--duplicate--> <!--duplicate-->
<button type="submit" class="btn btn-default"><i class="glyphicon glyphicon-ok-sign"></i> Create Duplicate</button> <button type="submit" class="btn btn-success"><i class="glyphicon glyphicon-ok-sign"></i> Create Duplicate</button>
{% elif create %} {% elif create %}
<!--create--> <!--create-->
<button type="submit" class="btn btn-success"><i class="glyphicon glyphicon-floppy-disk"></i> Save</button> <button type="submit" class="btn btn-success"><i class="glyphicon glyphicon-floppy-disk"></i> Save</button>

View File

@@ -1,5 +1,4 @@
{% load widget_tweaks %} {% load widget_tweaks %}
{% load asset_templatetags %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
Asset Details Asset Details

View File

@@ -1,10 +1,11 @@
{% for item in object_list %} {% for item in object_list %}
{# <li><a href="{% url 'asset_detail' item.pk %}">{{ item.asset_id }} - {{ item.description }}</a></li>#} {# <li><a href="{% url 'asset_detail' item.pk %}">{{ item.asset_id }} - {{ item.description }}</a></li>#}
<!---TODO: When the ability to filter the list is added, remove the colours from the filter - specifically, stop greying out sold/binned stuff if it is being searched for--> <tr class={{ item.status.display_class|default:"" }}> <!---TODO: When the ability to filter the list is added, remove the colours from the filter - specifically, stop greying out sold/binned stuff if it is being searched for-->
<td style="vertical-align: middle;"><a href="{% url 'asset_detail' item.asset_id %}">{{ item.asset_id }}</a></td> <tr class="{{ item.status.display_class|default:'' }} assetRow">
<td style="vertical-align: middle; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; max-width: 25vw">{{ item.description }}</td> <td style="vertical-align: middle;"><a class="assetID" href="{% url 'asset_detail' item.asset_id %}">{{ item.asset_id }}</a></td>
<td style="vertical-align: middle;">{{ item.category }}</td> <td class="assetDesc" style="vertical-align: middle; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; max-width: 25vw">{{ item.description }}</td>
<td style="vertical-align: middle;">{{ item.status }}</td> <td class="assetCategory" style="vertical-align: middle;">{{ item.category }}</td>
<td class="assetStatus" style="vertical-align: middle;">{{ item.status }}</td>
<td class="hidden-xs"> <td class="hidden-xs">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<a type="button" class="btn btn-default btn-sm" href="{% url 'asset_detail' item.asset_id %}"><i class="glyphicon glyphicon-eye-open"></i> View</a> <a type="button" class="btn btn-default btn-sm" href="{% url 'asset_detail' item.asset_id %}"><i class="glyphicon glyphicon-eye-open"></i> View</a>

View File

@@ -1,5 +1,4 @@
{% load widget_tweaks %} {% load widget_tweaks %}
{% load asset_templatetags %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
Cable Details Cable Details

View File

@@ -1,12 +1,11 @@
{% load widget_tweaks %} {% load widget_tweaks %}
{% load asset_templatetags %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
Collection Details Collection Details
</div> </div>
<div class="panel-body"> <div class="panel-body">
{% if create or edit or duplicate %} {% if create or edit or duplicate %}
<div class="form-group"> <div class="form-group" id="parent-group">
<label for="selectpicker">Set Parent</label> <label for="selectpicker">Set Parent</label>
{% include 'partials/asset_picker.html' %} {% include 'partials/asset_picker.html' %}
</div> </div>

View File

@@ -1,5 +1,4 @@
{% load widget_tweaks %} {% load widget_tweaks %}
{% load asset_templatetags %}
{% load static %} {% load static %}
@@ -23,7 +22,7 @@
</div> </div>
<div class="panel-body"> <div class="panel-body">
{% if create or edit or duplicate %} {% if create or edit or duplicate %}
<div class="form-group"> <div class="form-group" id="purchased-from-group">
<label for="{{ form.purchased_from.id_for_label }}">Supplier</label> <label for="{{ form.purchased_from.id_for_label }}">Supplier</label>
<select id="{{ form.purchased_from.id_for_label }}" name="{{ form.purchased_from.name }}" class="form-control selectpicker" data-live-search="true" data-sourceurl="{% url 'api_secure' model='supplier' %}"> <select id="{{ form.purchased_from.id_for_label }}" name="{{ form.purchased_from.name }}" class="form-control selectpicker" data-live-search="true" data-sourceurl="{% url 'api_secure' model='supplier' %}">
{% if object.purchased_from %} {% if object.purchased_from %}

View File

@@ -14,7 +14,7 @@
<div class="input-group pull-right" style="width: auto;"> <div class="input-group pull-right" style="width: auto;">
{% render_field form.query|add_class:'form-control' placeholder='Search by Name' style="width: 250px"%} {% render_field form.query|add_class:'form-control' placeholder='Search by Name' style="width: 250px"%}
<label for="query" class="sr-only">Name:</label> <label for="query" class="sr-only">Name:</label>
<span class="input-group-btn"><button type="submit" class="btn btn-default">Search</button></span> <span class="input-group-btn"><button type="submit" class="btn btn-default" id="id_search">Search</button></span>
</div> </div>
</form> </form>
@@ -27,8 +27,8 @@
</thead> </thead>
<tbody id="asset_table_body"> <tbody id="asset_table_body">
{% for item in object_list %} {% for item in object_list %}
<tr> <tr class="supplierRow">
<td>{{ item.name }}</td> <td class="supplierName">{{ item.name }}</td>
<td> <td>
<a href="{% url 'supplier_detail' item.pk %}" class="btn btn-default"><i class="glyphicon glyphicon-eye-open"></i> View</a> <a href="{% url 'supplier_detail' item.pk %}" class="btn btn-default"><i class="glyphicon glyphicon-eye-open"></i> View</a>
<a href="{% url 'supplier_update' item.pk %}" class="btn btn-default"><i class="glyphicon glyphicon-edit"></i> Edit</a> <a href="{% url 'supplier_update' item.pk %}" class="btn btn-default"><i class="glyphicon glyphicon-edit"></i> Edit</a>

View File

@@ -13,8 +13,8 @@
</div> </div>
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ form }} {% include 'form_errors.html' %}
{{ form }}
<input type="submit" value="Save" class="btn btn-success"> <input type="submit" value="Save" class="btn btn-success">
</form> </form>
{% endblock %} {% endblock %}

View File

@@ -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'))

188
assets/tests/pages.py Normal file
View File

@@ -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

583
assets/tests/test_assets.py Normal file
View File

@@ -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, '<link rel="alternate" type="application/json+oembed"')
self.assertContains(response, oembed_url)
# Test that the JSON exists
response = self.client.get(oembed_url, follow=True, HTTP_HOST='example.com')
self.assertEqual(response.status_code, 200)
self.assertContains(response, asset_embed_url)
# Should also work for non-existant
response = self.client.get(alt_oembed_url, follow=True, HTTP_HOST='example.com')
self.assertEqual(response.status_code, 200)
self.assertContains(response, alt_asset_embed_url)

View File

@@ -8,9 +8,8 @@ from django.views.decorators.clickjacking import xframe_options_exempt
from PyRIGS.decorators import has_oembed, permission_required_with_403 from PyRIGS.decorators import has_oembed, permission_required_with_403
urlpatterns = [ urlpatterns = [
path('', views.AssetList.as_view(), name='asset_index'), path('', login_required(views.AssetList.as_view()), name='asset_index'),
path('asset/list/', views.AssetList.as_view(), name='asset_list'), path('asset/list/', login_required(views.AssetList.as_view()), name='asset_list'),
# Lazy way to enable the oembed redirect...
path('asset/id/<str:pk>/', has_oembed(oembed_view="asset_oembed")(views.AssetDetail.as_view()), name='asset_detail'), path('asset/id/<str:pk>/', has_oembed(oembed_view="asset_oembed")(views.AssetDetail.as_view()), name='asset_detail'),
path('asset/create/', permission_required_with_403('assets.add_asset') path('asset/create/', permission_required_with_403('assets.add_asset')
(views.AssetCreate.as_view()), name='asset_create'), (views.AssetCreate.as_view()), name='asset_create'),
@@ -38,7 +37,7 @@ urlpatterns = [
(views.SupplierCreate.as_view()), name='supplier_create'), (views.SupplierCreate.as_view()), name='supplier_create'),
path('supplier/<int:pk>/edit', permission_required_with_403('assets.change_supplier') path('supplier/<int:pk>/edit', permission_required_with_403('assets.change_supplier')
(views.SupplierUpdate.as_view()), name='supplier_update'), (views.SupplierUpdate.as_view()), name='supplier_update'),
path('supplier/<str:pk>/history/', views.SupplierVersionHistory.as_view(), path('supplier/<int:pk>/history/', views.SupplierVersionHistory.as_view(),
name='supplier_history', kwargs={'model': models.Supplier}), name='supplier_history', kwargs={'model': models.Supplier}),
path('supplier/search/', views.SupplierSearch.as_view(), name='supplier_search_json'), path('supplier/search/', views.SupplierSearch.as_view(), name='supplier_search_json'),

View File

@@ -1,6 +1,6 @@
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import JsonResponse from django.http import JsonResponse
from django.http import HttpResponse from django.http import HttpResponse, Http404
from django.views import generic from django.views import generic
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
@@ -87,8 +87,7 @@ class AssetIDUrlMixin:
# Get the single item from the filtered queryset # Get the single item from the filtered queryset
obj = queryset.get() obj = queryset.get()
except queryset.model.DoesNotExist: except queryset.model.DoesNotExist:
raise Http404(_("No %(verbose_name)s found matching the query") % raise Http404("No assets found matching the query")
{'verbose_name': queryset.model._meta.verbose_name})
return obj return obj
@@ -213,7 +212,6 @@ class SupplierSearch(SupplierList):
for supplier in context["object_list"]: for supplier in context["object_list"]:
result.append({"id": supplier.pk, "name": supplier.name}) result.append({"id": supplier.pk, "name": supplier.name})
return JsonResponse(result, safe=False) return JsonResponse(result, safe=False)

View File

@@ -38,3 +38,4 @@ z3c.rml==3.5.0
zope.event==4.3.0 zope.event==4.3.0
zope.interface==4.5.0 zope.interface==4.5.0
zope.schema==4.5.0 zope.schema==4.5.0
pypom==2.2.0

View File

@@ -52,7 +52,7 @@
{% endblock %} {% endblock %}
</ul> </ul>
<ul class="nav navbar-nav navbar-right"> <ul class="nav navbar-nav navbar-right">
<li class="dropdown"> <li class="dropdown" id="user">
{% if user.is_authenticated %} {% if user.is_authenticated %}
<a href="#" class="dropdown-toggle" data-toggle="dropdown"> <a href="#" class="dropdown-toggle" data-toggle="dropdown">
<span class="glyphicon glyphicon-user"></span> <span class="glyphicon glyphicon-user"></span>

View File

@@ -5,8 +5,10 @@
<dl class="dl-horizontal"> <dl class="dl-horizontal">
{% with form|nice_errors as qq %} {% with form|nice_errors as qq %}
{% for error_name,desc in qq.items %} {% for error_name,desc in qq.items %}
<span>
<dt>{{error_name}}</dt> <dt>{{error_name}}</dt>
<dd>{{desc}}</dd> <dd>{{desc}}</dd>
</span>
{% endfor %} {% endfor %}
{% endwith %} {% endwith %}
</dl> </dl>

View File

@@ -15,7 +15,7 @@
<div class="text-right"> <div class="text-right">
<a href="{% url 'registration_register' %}" class="btn">Register</a> <a href="{% url 'registration_register' %}" class="btn">Register</a>
<a href="{% url 'password_reset' %}" class="btn">Forgotten Password</a> <a href="{% url 'password_reset' %}" class="btn">Forgotten Password</a>
<input type="submit" value="Login" class="btn btn-primary"/> <input type="submit" id="id_submit" value="Login" class="btn btn-primary"/>
<input type="hidden" name="next" value="{{ next }}"/> <input type="hidden" name="next" value="{{ next }}"/>
</div> </div>
</form> </form>