Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2020-09-10 17:30:19 +10:00
commit b5409c88be
17 changed files with 3176 additions and 9 deletions

View File

@ -6,7 +6,7 @@ from InvenTree.settings import *
# Override the 'test' database
if 'test' in sys.argv:
eprint('InvenTree: Running tests - Using MySQL test database')
eprint('InvenTree: Running tests - Using PostGreSQL test database')
DATABASES['default'] = {
# Ensure postgresql backend is being used

View File

@ -9,8 +9,9 @@ from django.utils.translation import ugettext as _
from django import forms
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field
from crispy_forms.bootstrap import PrependedText, AppendedText, PrependedAppendedText
from crispy_forms.bootstrap import PrependedText, AppendedText, PrependedAppendedText, StrictButton, Div
from django.contrib.auth.models import User
from common.models import ColorTheme
class HelperForm(forms.ModelForm):
@ -161,3 +162,36 @@ class SetPasswordForm(HelperForm):
'enter_password',
'confirm_password'
]
class ColorThemeSelectForm(forms.ModelForm):
""" Form for setting color theme """
name = forms.ChoiceField(choices=(), required=False)
class Meta:
model = ColorTheme
fields = [
'name'
]
def __init__(self, *args, **kwargs):
super(ColorThemeSelectForm, self).__init__(*args, **kwargs)
# Populate color themes choices
self.fields['name'].choices = ColorTheme.get_color_themes_choices()
self.helper = FormHelper()
# Form rendering
self.helper.form_show_labels = False
self.helper.layout = Layout(
Div(
Div(Field('name'),
css_class='col-sm-6',
style='width: 200px;'),
Div(StrictButton(_('Apply Theme'), css_class='btn btn-primary', type='submit'),
css_class='col-sm-6',
style='width: auto;'),
css_class='row',
),
)

View File

@ -82,6 +82,9 @@ STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'InvenTree', 'static'),
]
# Color Themes Directory
STATIC_COLOR_THEMES_DIR = os.path.join(STATIC_ROOT, 'css', 'color-themes')
# Web URL endpoint for served media files
MEDIA_URL = '/media/'

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,42 @@
/* Color Theme: "Darker" by Radek Hladik */
.navbar-nav > li {
border-color: rgb(179, 179, 179);
}
.navbar-nav > li > a {
color:#0b2a62 !important;
}
.navbar-nav > li > a:hover {
color:#202020 !important;
}
.navbar-nav > .open > a {
color:#202020 !important;
}
.navbar {
background-color: rgb(189, 189, 189);
}
.table-condensed > tbody > tr > td {
border-top: 1px solid #062152 !important ;
}
.table-striped > tbody > tr > td {
border-top: 1px solid #92b3f1 ;
}
.table-bordered, .table-bordered > tbody > tr > td {
border: 1px solid rgb(182,182,182);
}
.table-bordered > thead > tr > th {
border: 1px solid rgb(182, 182, 182);
background-color: rgb(235, 235, 235);
}
h3 {
color:#06255d;
}

View File

@ -0,0 +1 @@
/* Color Theme: "Default" */

View File

@ -36,7 +36,7 @@ from django.views.generic.base import RedirectView
from rest_framework.documentation import include_docs_urls
from .views import IndexView, SearchView, DatabaseStatsView
from .views import SettingsView, EditUserView, SetPasswordView
from .views import SettingsView, EditUserView, SetPasswordView, ColorThemeSelectView
from .views import DynamicJsView
from .api import InfoView
@ -71,6 +71,7 @@ settings_urls = [
url(r'^user/?', SettingsView.as_view(template_name='InvenTree/settings/user.html'), name='settings-user'),
url(r'^currency/?', SettingsView.as_view(template_name='InvenTree/settings/currency.html'), name='settings-currency'),
url(r'^part/?', SettingsView.as_view(template_name='InvenTree/settings/part.html'), name='settings-part'),
url(r'^theme/?', ColorThemeSelectView.as_view(), name='settings-theme'),
url(r'^other/?', SettingsView.as_view(template_name='InvenTree/settings/other.html'), name='settings-other'),
# Catch any other urls

View File

@ -11,16 +11,17 @@ from __future__ import unicode_literals
from django.utils.translation import gettext_lazy as _
from django.template.loader import render_to_string
from django.http import JsonResponse, HttpResponseRedirect
from django.urls import reverse_lazy
from django.views import View
from django.views.generic import UpdateView, CreateView
from django.views.generic import UpdateView, CreateView, FormView
from django.views.generic.base import TemplateView
from part.models import Part, PartCategory
from stock.models import StockLocation, StockItem
from common.models import InvenTreeSetting
from common.models import InvenTreeSetting, ColorTheme
from .forms import DeleteForm, EditUserForm, SetPasswordForm
from .forms import DeleteForm, EditUserForm, SetPasswordForm, ColorThemeSelectForm
from .helpers import str2bool
from rest_framework import views
@ -556,6 +557,81 @@ class SettingsView(TemplateView):
return ctx
class ColorThemeSelectView(FormView):
""" View for selecting a color theme """
form_class = ColorThemeSelectForm
success_url = reverse_lazy('settings-theme')
template_name = "InvenTree/settings/theme.html"
def get_user_theme(self):
""" Get current user color theme """
try:
user_theme = ColorTheme.objects.filter(user=self.request.user).get()
except ColorTheme.DoesNotExist:
user_theme = None
return user_theme
def get_initial(self):
""" Select current user color theme as initial choice """
initial = super(ColorThemeSelectView, self).get_initial()
user_theme = self.get_user_theme()
if user_theme:
initial['name'] = user_theme.name
return initial
def get(self, request, *args, **kwargs):
""" Check if current color theme exists, else display alert box """
context = {}
form = self.get_form()
context['form'] = form
user_theme = self.get_user_theme()
if user_theme:
# Check color theme is a valid choice
if not ColorTheme.is_valid_choice(user_theme):
user_color_theme_name = user_theme.name
if not user_color_theme_name:
user_color_theme_name = 'default'
context['invalid_color_theme'] = user_color_theme_name
return self.render_to_response(context)
def post(self, request, *args, **kwargs):
""" Save user color theme selection """
form = self.get_form()
# Get current user theme
user_theme = self.get_user_theme()
# Create theme entry if user did not select one yet
if not user_theme:
user_theme = ColorTheme()
user_theme.user = request.user
if form.is_valid():
theme_selected = form.cleaned_data['name']
# Set color theme to form selection
user_theme.name = theme_selected
user_theme.save()
return self.form_valid(form)
else:
# Set color theme to default
user_theme.name = ColorTheme.default_color_theme[0]
user_theme.save()
return self.form_invalid(form)
class DatabaseStatsView(AjaxView):
""" View for displaying database statistics """

View File

@ -0,0 +1,21 @@
# Generated by Django 3.0.7 on 2020-09-09 19:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('common', '0006_auto_20200203_0951'),
]
operations = [
migrations.CreateModel(
name='ColorTheme',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(blank=True, default='', max_length=20)),
('user', models.CharField(max_length=150, unique=True)),
],
),
]

View File

@ -6,7 +6,10 @@ These models are 'generic' and do not fit a particular business logic object.
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
from django.db import models
from django.conf import settings
from django.utils.translation import ugettext as _
from django.core.validators import MinValueValidator, MaxValueValidator
from django.core.exceptions import ValidationError
@ -154,3 +157,49 @@ class Currency(models.Model):
self.value = 1.0
super().save(*args, **kwargs)
class ColorTheme(models.Model):
""" Color Theme Setting """
default_color_theme = ('', _('Default'))
name = models.CharField(max_length=20,
default='',
blank=True)
user = models.CharField(max_length=150,
unique=True)
@classmethod
def get_color_themes_choices(cls):
""" Get all color themes from static folder """
# Get files list from css/color-themes/ folder
files_list = []
for file in os.listdir(settings.STATIC_COLOR_THEMES_DIR):
files_list.append(os.path.splitext(file))
# Get color themes choices (CSS sheets)
choices = [(file_name.lower(), _(file_name.replace('-', ' ').title()))
for file_name, file_ext in files_list
if file_ext == '.css' and file_name.lower() != 'default']
# Add default option as empty option
choices.insert(0, cls.default_color_theme)
return choices
@classmethod
def is_valid_choice(cls, user_color_theme):
""" Check if color theme is valid choice """
try:
user_color_theme_name = user_color_theme.name
except AttributeError:
return False
for color_theme in cls.get_color_themes_choices():
if user_color_theme_name == color_theme[0]:
return True
return False

View File

@ -1,12 +1,13 @@
""" This module provides template tags for extra functionality
over and above the built-in Django tags.
"""
import os
from django import template
from InvenTree import version
from InvenTree import version, settings
from InvenTree.helpers import decimal2string
from common.models import InvenTreeSetting
from common.models import InvenTreeSetting, ColorTheme
register = template.Library()
@ -88,3 +89,22 @@ def inventree_docs_url(*args, **kwargs):
@register.simple_tag()
def inventree_setting(key, *args, **kwargs):
return InvenTreeSetting.get_setting(key)
@register.simple_tag()
def get_color_theme_css(username):
try:
user_theme = ColorTheme.objects.filter(user=username).get()
user_theme_name = user_theme.name
if not user_theme_name or not ColorTheme.is_valid_choice(user_theme):
user_theme_name = 'default'
except ColorTheme.DoesNotExist:
user_theme_name = 'default'
# Build path to CSS sheet
inventree_css_sheet = os.path.join('css', 'color-themes', user_theme_name + '.css')
# Build static URL
inventree_css_static_url = os.path.join(settings.STATIC_URL, inventree_css_sheet)
return inventree_css_static_url

View File

@ -78,6 +78,56 @@ class PartDetailTest(PartViewTestCase):
self.assertEqual(response.status_code, 200)
self.assertTrue(response.context['editing_enabled'])
def test_part_detail_from_ipn(self):
"""
Test that we can retrieve a part detail page from part IPN:
- if no part with matching IPN -> return part index
- if unique IPN match -> return part detail page
- if multiple IPN matches -> return part index
"""
ipn_test = 'PART-000000-AA'
pk = 1
def test_ipn_match(index_result=False, detail_result=False):
index_redirect = False
detail_redirect = False
response = self.client.get(reverse('part-detail-from-ipn', args=(ipn_test,)))
# Check for PartIndex redirect
try:
if response.url == '/part/':
index_redirect = True
except AttributeError:
pass
# Check for PartDetail redirect
try:
if response.context['part'].pk == pk:
detail_redirect = True
except TypeError:
pass
self.assertEqual(index_result, index_redirect)
self.assertEqual(detail_result, detail_redirect)
# Test no match
test_ipn_match(index_result=True, detail_result=False)
# Test unique match
part = Part.objects.get(pk=pk)
part.IPN = ipn_test
part.save()
test_ipn_match(index_result=False, detail_result=True)
# Test multiple matches
part = Part.objects.get(pk=pk + 1)
part.IPN = ipn_test
part.save()
test_ipn_match(index_result=True, detail_result=False)
def test_bom_download(self):
""" Test downloading a BOM for a valid part """

View File

@ -99,7 +99,7 @@ part_urls = [
# Export data for multiple parts
url(r'^export/', views.PartExport.as_view(), name='part-export'),
# Individual part
# Individual part using pk
url(r'^(?P<pk>\d+)/', include(part_detail_urls)),
# Part category
@ -124,6 +124,9 @@ part_urls = [
# Bom Items
url(r'^bom/(?P<pk>\d+)/', include(part_bom_urls)),
# Individual part using IPN as slug
url(r'^(?P<slug>[-\w]+)/', views.PartDetailFromIPN.as_view(), name='part-detail-from-ipn'),
# Top level part list (display top level parts and categories)
url(r'^.*$', views.PartIndex.as_view(), name='part-index'),
]

View File

@ -663,6 +663,43 @@ class PartDetail(DetailView):
return context
class PartDetailFromIPN(PartDetail):
slug_field = 'IPN'
slug_url_kwarg = 'slug'
def get_object(self):
""" Return Part object which IPN field matches the slug value """
queryset = self.get_queryset()
# Get slug
slug = self.kwargs.get(self.slug_url_kwarg)
if slug is not None:
slug_field = self.get_slug_field()
# Filter by the slug value
queryset = queryset.filter(**{slug_field: slug})
try:
# Get unique part from queryset
part = queryset.get()
# Return Part object
return part
except queryset.model.MultipleObjectsReturned:
pass
except queryset.model.DoesNotExist:
pass
return None
def get(self, request, *args, **kwargs):
""" Attempt to match slug to a Part, else redirect to PartIndex view """
self.object = self.get_object()
if not self.object:
return HttpResponseRedirect(reverse('part-index'))
return super(PartDetailFromIPN, self).get(request, *args, **kwargs)
class PartQRCode(QRCodeView):
""" View for displaying a QR code for a Part object """

View File

@ -8,6 +8,9 @@
<li{% ifequal tab 'part' %} class='active'{% endifequal %}>
<a href="{% url 'settings-part' %}"><span class='fas fa-shapes'></span> Part</a>
</li>
<li{% ifequal tab 'theme' %} class='active'{% endifequal %}>
<a href="{% url 'settings-theme' %}"><span class='fas fa-fill'></span> Theme</a>
</li>
{% if user.is_staff %}
<li{% ifequal tab 'other' %} class='active'{% endifequal %}>
<a href="{% url 'settings-other' %}"><span class='fas fa-cogs'></span> Other</a>

View File

@ -0,0 +1,32 @@
{% extends "InvenTree/settings/settings.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block tabs %}
{% include "InvenTree/settings/tabs.html" with tab='theme' %}
{% endblock %}
{% block settings %}
<div class='row'>
<div class='col-sm-6'>
<h4>Color Themes</h4>
</div>
</div>
<form action="{% url 'settings-theme' %}" method="post">
{% csrf_token %}
{% load crispy_forms_tags %}
{% crispy form %}
</form>
{% if invalid_color_theme %}
<div class="alert alert-danger alert-block" role="alert" style="display: inline-block;">
{% blocktrans %}
The CSS sheet "{{invalid_color_theme}}.css" for the currently selected color theme was not found.<br>
Please select another color theme :)
{% endblocktrans %}
</div>
{% endif %}
{% endblock %}

View File

@ -1,5 +1,6 @@
{% load static %}
{% load i18n %}
{% load inventree_extras %}
<!DOCTYPE html>
<html lang="en">
@ -39,6 +40,7 @@
<link rel="stylesheet" href="{% static 'css/select2-bootstrap.css' %}">
<link rel="stylesheet" href="{% static 'css/bootstrap-toggle.css' %}">
<link rel="stylesheet" href="{% static 'css/inventree.css' %}">
<link rel="stylesheet" href="{% get_color_theme_css user.get_username %}">
{% block css %}
{% endblock %}