diff --git a/PyRIGS/settings.py b/PyRIGS/settings.py index 5877ad74..b787bdd1 100644 --- a/PyRIGS/settings.py +++ b/PyRIGS/settings.py @@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/1.7/ref/settings/ import os import raven import secrets +import datetime BASE_DIR = os.path.dirname(os.path.dirname(__file__)) @@ -44,9 +45,9 @@ if not DEBUG: INTERNAL_IPS = ['127.0.0.1'] -ADMINS = ( - ('Tom Price', 'tomtom5152@gmail.com') -) +ADMINS = [('Tom Price', 'tomtom5152@gmail.com'), ('IT Manager', 'it@nottinghamtec.co.uk'), ('Arona Jones', 'arona.jones@nottinghamtec.co.uk')] +if DEBUG: + ADMINS.append(('Testing Superuser', 'superuser@example.com')) # Application definition @@ -182,6 +183,8 @@ if not DEBUG or EMAILER_TEST: else: EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +EMAIL_COOLDOWN = datetime.timedelta(minutes=15) + # Internationalization # https://docs.djangoproject.com/en/1.7/topics/i18n/ 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/admin.py b/RIGS/admin.py index 49b8aa1e..846f014a 100644 --- a/RIGS/admin.py +++ b/RIGS/admin.py @@ -22,13 +22,22 @@ admin.site.register(models.Invoice) admin.site.register(models.Payment) +def approve_user(modeladmin, request, queryset): + queryset.update(is_approved=True) + + +approve_user.short_description = "Approve selected users" + + @admin.register(models.Profile) class ProfileAdmin(UserAdmin): + # Don't know how to add 'is_approved' whilst preserving the default list... + list_filter = ('is_approved', 'is_active', 'is_staff', 'is_superuser', 'groups') fieldsets = ( (None, {'fields': ('username', 'password')}), (_('Personal info'), { 'fields': ('first_name', 'last_name', 'email', 'initials', 'phone')}), - (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser', + (_('Permissions'), {'fields': ('is_approved', 'is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}), (_('Important dates'), { 'fields': ('last_login', 'date_joined')}), @@ -41,6 +50,7 @@ class ProfileAdmin(UserAdmin): ) form = forms.ProfileChangeForm add_form = forms.ProfileCreationForm + actions = [approve_user] class AssociateAdmin(VersionAdmin): diff --git a/RIGS/forms.py b/RIGS/forms.py index 47b0f062..a006bc87 100644 --- a/RIGS/forms.py +++ b/RIGS/forms.py @@ -2,8 +2,10 @@ from django import forms from django.utils import formats from django.conf import settings from django.core import serializers +from django.core.mail import EmailMessage, EmailMultiAlternatives from django.contrib.auth.forms import UserCreationForm, UserChangeForm, AuthenticationForm, PasswordResetForm from registration.forms import RegistrationFormUniqueEmail +from django.contrib.auth.forms import AuthenticationForm from captcha.fields import ReCaptchaField import simplejson @@ -33,8 +35,16 @@ class ProfileRegistrationFormUniqueEmail(RegistrationFormUniqueEmail): return self.cleaned_data['initials'] +class CheckApprovedForm(AuthenticationForm): + def confirm_login_allowed(self, user): + if user.is_approved or user.is_superuser: + return AuthenticationForm.confirm_login_allowed(self, user) + else: + raise forms.ValidationError("Your account hasn't been approved by an administrator yet. Please check back in a few minutes!") + + # Embedded Login form - remove the autofocus -class EmbeddedAuthenticationForm(AuthenticationForm): +class EmbeddedAuthenticationForm(CheckApprovedForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['username'].widget.attrs.pop('autofocus', None) diff --git a/RIGS/migrations/0036_profile_is_approved.py b/RIGS/migrations/0036_profile_is_approved.py new file mode 100644 index 00000000..3fdb5af7 --- /dev/null +++ b/RIGS/migrations/0036_profile_is_approved.py @@ -0,0 +1,23 @@ +# Generated by Django 2.0.13 on 2020-01-10 14:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('RIGS', '0035_auto_20191124_1319'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='is_approved', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='profile', + name='last_emailed', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/RIGS/migrations/0037_approve_legacy.py b/RIGS/migrations/0037_approve_legacy.py new file mode 100644 index 00000000..72348b3f --- /dev/null +++ b/RIGS/migrations/0037_approve_legacy.py @@ -0,0 +1,19 @@ +# Generated by Django 2.0.13 on 2020-01-11 18:29 +# This migration ensures that legacy Profiles from before approvals were implemented are automatically approved +from django.db import migrations + +def approve_legacy(apps, schema_editor): + Profile = apps.get_model('RIGS', 'Profile') + for person in Profile.objects.all(): + person.is_approved = True + person.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('RIGS', '0036_profile_is_approved'), + ] + + operations = [ + migrations.RunPython(approve_legacy) + ] diff --git a/RIGS/models.py b/RIGS/models.py index 937d5354..0b97ca61 100644 --- a/RIGS/models.py +++ b/RIGS/models.py @@ -27,6 +27,8 @@ class Profile(AbstractUser): initials = models.CharField(max_length=5, unique=True, null=True, blank=False) phone = models.CharField(max_length=13, null=True, blank=True) api_key = models.CharField(max_length=40, blank=True, editable=False, null=True) + is_approved = models.BooleanField(default=False) + last_emailed = models.DateTimeField(blank=True, null=True) # Currently only populated by the admin approval email. TODO: Populate it each time we send any email, might need that... @classmethod def make_api_key(cls): @@ -53,6 +55,14 @@ class Profile(AbstractUser): def latest_events(self): return self.event_mic.order_by('-start_date').select_related('person', 'organisation', 'venue', 'mic') + @classmethod + def admins(cls): + return Profile.objects.filter(email__in=[y for x in settings.ADMINS for y in x]) + + @classmethod + def users_awaiting_approval_count(cls): + return Profile.objects.filter(models.Q(is_approved=False)).count() + def __str__(self): return self.name diff --git a/RIGS/signals.py b/RIGS/signals.py index ea0395d7..5c5e6c66 100644 --- a/RIGS/signals.py +++ b/RIGS/signals.py @@ -1,3 +1,4 @@ +import datetime import re import urllib.request import urllib.error @@ -10,6 +11,9 @@ from django.conf import settings from django.contrib.staticfiles.storage import staticfiles_storage from django.core.mail import EmailMessage, EmailMultiAlternatives from django.template.loader import get_template +from django.urls import reverse +from django.utils import timezone +from registration.signals import user_activated from premailer import Premailer from z3c.rml import rml2pdf @@ -102,3 +106,35 @@ def on_revision_commit(sender, instance, created, **kwargs): post_save.connect(on_revision_commit, sender=models.EventAuthorisation) + + +def send_admin_awaiting_approval_email(user, request, **kwargs): + # Bit more controlled than just emailing all superusers + for admin in models.Profile.admins(): + # Check we've ever emailed them before and if so, if cooldown has passed. + if admin.last_emailed is None or admin.last_emailed + settings.EMAIL_COOLDOWN <= timezone.now(): + context = { + 'request': request, + 'link_suffix': reverse("admin:RIGS_profile_changelist") + '?is_approved__exact=0', + 'number_of_users': models.Profile.users_awaiting_approval_count(), + 'to_name': admin.first_name + } + + email = EmailMultiAlternatives( + "%s new users awaiting approval on RIGS" % (context['number_of_users']), + get_template("RIGS/admin_awaiting_approval.txt").render(context), + to=[admin.email], + reply_to=[user.email], + ) + css = staticfiles_storage.path('css/email.css') + html = Premailer(get_template("RIGS/admin_awaiting_approval.html").render(context), + external_styles=css).transform() + email.attach_alternative(html, 'text/html') + email.send() + + # Update last sent + admin.last_emailed = timezone.now() + admin.save() + + +user_activated.connect(send_admin_awaiting_approval_email) diff --git a/RIGS/static/fonts/glyphicons-halflings-regular.svg b/RIGS/static/fonts/glyphicons-halflings-regular.svg index 94fb5490..b17ff266 100644 --- a/RIGS/static/fonts/glyphicons-halflings-regular.svg +++ b/RIGS/static/fonts/glyphicons-halflings-regular.svg @@ -1,288 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/RIGS/static/imgs/paperwork/corner-tr-su.jpg b/RIGS/static/imgs/paperwork/corner-tr-su.jpg index 550ab40b..4cbe1298 100644 Binary files a/RIGS/static/imgs/paperwork/corner-tr-su.jpg and b/RIGS/static/imgs/paperwork/corner-tr-su.jpg differ diff --git a/RIGS/templates/RIGS/admin_awaiting_approval.html b/RIGS/templates/RIGS/admin_awaiting_approval.html new file mode 100644 index 00000000..8db5433c --- /dev/null +++ b/RIGS/templates/RIGS/admin_awaiting_approval.html @@ -0,0 +1,9 @@ +{% extends 'base_client_email.html' %} + +{% block content %} +

Hi {{ to_name|default_if_none:"Administrator" }},

+ +

{{ number_of_users|default_if_none:"Some" }} new users are awaiting administrator approval on RIGS. Click here to approve them.

+ +

TEC PA & Lighting

+{% endblock %} diff --git a/RIGS/templates/RIGS/admin_awaiting_approval.txt b/RIGS/templates/RIGS/admin_awaiting_approval.txt new file mode 100644 index 00000000..e89785ee --- /dev/null +++ b/RIGS/templates/RIGS/admin_awaiting_approval.txt @@ -0,0 +1,5 @@ +Hi {{ to_name|default_if_none:"Administrator" }}, + +{{ number_of_users|default_if_none:"Some" }} new users are awaiting administrator approval on RIGS. Use this link to approve them: {{ request.scheme }}://{{ request.get_host }}/{{ link_suffix }} + +TEC PA & Lighting diff --git a/RIGS/templates/RIGS/event_detail.html b/RIGS/templates/RIGS/event_detail.html index e22dafe9..10a947fe 100644 --- a/RIGS/templates/RIGS/event_detail.html +++ b/RIGS/templates/RIGS/event_detail.html @@ -10,12 +10,14 @@ | {{ object.name }} {% if event.dry_hire %}Dry Hire{% endif %} + {% if perms.RIGS.view_event %}
{% include 'RIGS/event_detail_buttons.html' %}
+ {% endif %} {% endif %} - {% if object.is_rig %} + {% if object.is_rig and perms.RIGS.view_event %} {# only need contact details for a rig #}
@@ -72,7 +74,7 @@ {% endif %}
{% endif %} -
+
Event Info
@@ -147,7 +149,7 @@
{{ object.collector }}
{% endif %} - {% if event.is_rig and not event.internal %} + {% if event.is_rig and not event.internal and perms.RIGS.view_event %}
 
PO
{{ object.purchase_order }}
@@ -156,7 +158,7 @@
- {% if event.is_rig and event.internal %} + {% if event.is_rig and event.internal and perms.RIGS.view_event %}
{% include 'RIGS/event_detail_buttons.html' %}
@@ -222,21 +224,23 @@
Event Details
+ {% if perms.RIGS.view_event %}

Notes

{{ event.notes|linebreaksbr }}
+ {% endif %} {% include 'RIGS/item_table.html' %}
- {% if not request.is_ajax %} + {% if not request.is_ajax and perms.RIGS.view_event %}
{% include 'RIGS/event_detail_buttons.html' %}
{% endif %} {% endif %} - {% if not request.is_ajax %} + {% if not request.is_ajax and perms.RIGS.view_event %}
@@ -251,12 +255,16 @@ {% if request.is_ajax %} {% block footer %}
+ {% if perms.RIGS.view_event %}
+ {% else %} +
+ {% endif %}
Open Event Page diff --git a/RIGS/templates/RIGS/event_embed.html b/RIGS/templates/RIGS/event_embed.html index a6e3e586..78816cae 100644 --- a/RIGS/templates/RIGS/event_embed.html +++ b/RIGS/templates/RIGS/event_embed.html @@ -6,7 +6,7 @@
- + {% if object.meet_at %}

Crew meet: @@ -97,7 +97,7 @@ {{ object.description|linebreaksbr }}

{% endif %} - +
diff --git a/RIGS/templates/RIGS/event_table.html b/RIGS/templates/RIGS/event_table.html index c84b6624..f1bdb5f6 100644 --- a/RIGS/templates/RIGS/event_table.html +++ b/RIGS/templates/RIGS/event_table.html @@ -33,7 +33,7 @@

- + {{ event.name }} {% if event.venue %} diff --git a/RIGS/templates/RIGS/index.html b/RIGS/templates/RIGS/index.html index acd1a707..29b7c320 100644 --- a/RIGS/templates/RIGS/index.html +++ b/RIGS/templates/RIGS/index.html @@ -11,7 +11,7 @@

- +

Quick Links

@@ -26,10 +26,11 @@ TEC Forum TEC Wiki + {% if perms.RIGS.view_event %} Pre-Event Risk Assessment Price List Subhire Insurance Form - + {% endif %}
@@ -73,7 +74,7 @@
{% if perms.RIGS.view_event %}
- {% include 'RIGS/activity_feed.html' %} + {% include 'RIGS/activity_feed.html' %}
{% endif %}
diff --git a/RIGS/templates/RIGS/item_row.html b/RIGS/templates/RIGS/item_row.html index 656d9812..b2ff0c44 100644 --- a/RIGS/templates/RIGS/item_row.html +++ b/RIGS/templates/RIGS/item_row.html @@ -6,17 +6,21 @@ {{item.description|linebreaksbr}}
+ {% if perms.RIGS.view_event %} £ {{item.cost|floatformat:2}} + {% endif %} {{item.quantity}} + {% if perms.RIGS.view_event %} £ {{item.total_cost|floatformat:2}} + {% endif %} {% if edit %} - - diff --git a/RIGS/templates/RIGS/item_table.html b/RIGS/templates/RIGS/item_table.html index 0c652daf..9f055aa9 100644 --- a/RIGS/templates/RIGS/item_table.html +++ b/RIGS/templates/RIGS/item_table.html @@ -3,9 +3,13 @@ Item + {% if perms.RIGS.view_event %} Price + {% endif %} Quantity + {% if perms.RIGS.view_event %} Sub-total + {% endif %} {% if edit %}
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..dc7692a6 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): @@ -143,18 +141,41 @@ class UserRegistrationTest(LiveServerTestCase): self.assertEqual(password.get_attribute('placeholder'), 'Password') self.assertEqual(password.get_attribute('type'), 'password') + # Expected to fail as not approved username.send_keys('TestUsername') password.send_keys('correcthorsebatterystaple') self.browser.execute_script( "return function() {jQuery('#g-recaptcha-response').val('PASSED'); return 0}()") password.send_keys(Keys.ENTER) + # Test approval + profileObject = models.Profile.objects.all()[0] + self.assertFalse(profileObject.is_approved) + + # Read what the error is + alert = self.browser.find_element_by_css_selector( + 'div.alert-danger').text + self.assertIn("approved", alert) + + # Approve the user so we can proceed + profileObject.is_approved = True + profileObject.save() + + # Retry login + self.browser.get(self.live_server_url + '/user/login') + username = self.browser.find_element_by_id('id_username') + username.send_keys('TestUsername') + password = self.browser.find_element_by_id('id_password') + password.send_keys('correcthorsebatterystaple') + self.browser.execute_script( + "return function() {jQuery('#g-recaptcha-response').val('PASSED'); return 0}()") + password.send_keys(Keys.ENTER) + # Check we are logged in udd = self.browser.find_element_by_class_name('navbar').text self.assertIn('Hi John', udd) # Check all the data actually got saved - profileObject = models.Profile.objects.all()[0] self.assertEqual(profileObject.username, 'TestUsername') self.assertEqual(profileObject.first_name, 'John') self.assertEqual(profileObject.last_name, 'Smith') 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/RIGS/urls.py b/RIGS/urls.py index 46e70f10..ab897d85 100644 --- a/RIGS/urls.py +++ b/RIGS/urls.py @@ -6,7 +6,7 @@ from RIGS import models, views, rigboard, finance, ical, versioning, forms from django.views.generic import RedirectView from django.views.decorators.clickjacking import xframe_options_exempt -from PyRIGS.decorators import permission_required_with_403 +from PyRIGS.decorators import permission_required_with_403, has_oembed from PyRIGS.decorators import api_key_required urlpatterns = [ @@ -87,8 +87,7 @@ urlpatterns = [ permission_required_with_403('RIGS.view_event')(versioning.ActivityFeed.as_view()), name='activity_feed'), - url(r'^event/(?P\d+)/$', - permission_required_with_403('RIGS.view_event', oembed_view="event_oembed")( + url(r'^event/(?P\d+)/$', has_oembed(oembed_view="event_oembed")( rigboard.EventDetail.as_view()), name='event_detail'), url(r'^event/(?P\d+)/embed/$', diff --git a/RIGS/views.py b/RIGS/views.py index f8494e25..1faa244d 100644 --- a/RIGS/views.py +++ b/RIGS/views.py @@ -41,7 +41,7 @@ def login(request, **kwargs): else: from django.contrib.auth.views import login - return login(request) + return login(request, authentication_form=forms.CheckApprovedForm) # This view should be exempt from requiring CSRF token. 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 }}
  • #} -
    - - - - + + + + + + {% for item in object_list %} - - + +