Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2019-05-14 18:54:23 +10:00
commit e3a8bb23c1
21 changed files with 332 additions and 65 deletions

View File

@ -7,6 +7,7 @@ from __future__ import unicode_literals
from django import forms from django import forms
from crispy_forms.helper import FormHelper from crispy_forms.helper import FormHelper
from django.contrib.auth.models import User
class HelperForm(forms.ModelForm): class HelperForm(forms.ModelForm):
@ -33,3 +34,42 @@ class DeleteForm(forms.Form):
fields = [ fields = [
'confirm_delete' 'confirm_delete'
] ]
class EditUserForm(HelperForm):
""" Form for editing user information
"""
class Meta:
model = User
fields = [
'first_name',
'last_name',
'email'
]
class SetPasswordForm(HelperForm):
""" Form for setting user password
"""
enter_password = forms.CharField(max_length=100,
min_length=8,
required=True,
initial='',
widget=forms.PasswordInput(attrs={'autocomplete': 'off'}),
help_text='Enter new password')
confirm_password = forms.CharField(max_length=100,
min_length=8,
required=True,
initial='',
widget=forms.PasswordInput(attrs={'autocomplete': 'off'}),
help_text='Confirm new password')
class Meta:
model = User
fields = [
'enter_password',
'confirm_password'
]

View File

@ -30,7 +30,7 @@ from django.conf.urls.static import static
from django.views.generic.base import RedirectView from django.views.generic.base import RedirectView
from rest_framework.documentation import include_docs_urls from rest_framework.documentation import include_docs_urls
from .views import IndexView, SearchView from .views import IndexView, SearchView, SettingsView, EditUserView, SetPasswordView
from users.urls import user_urls from users.urls import user_urls
@ -62,6 +62,11 @@ urlpatterns = [
url(r'^login/', auth_views.LoginView.as_view(), name='login'), url(r'^login/', auth_views.LoginView.as_view(), name='login'),
url(r'^logout/', auth_views.LogoutView.as_view(template_name='registration/logout.html'), name='logout'), url(r'^logout/', auth_views.LogoutView.as_view(template_name='registration/logout.html'), name='logout'),
url(r'^settings/', SettingsView.as_view(), name='settings'),
url(r'^edit-user/', EditUserView.as_view(), name='edit-user'),
url(r'^set-password/', SetPasswordView.as_view(), name='set-password'),
url(r'^admin/', admin.site.urls, name='inventree-admin'), url(r'^admin/', admin.site.urls, name='inventree-admin'),
url(r'^qr_code/', include(qr_code_urls, namespace='qr_code')), url(r'^qr_code/', include(qr_code_urls, namespace='qr_code')),

View File

@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _
def validate_part_name(value): def validate_part_name(value):
# Prevent some illegal characters in part names # Prevent some illegal characters in part names
for c in ['/', '\\', '|', '#', '$']: for c in ['|', '#', '$']:
if c in str(value): if c in str(value):
raise ValidationError( raise ValidationError(
_('Invalid character in part name') _('Invalid character in part name')

View File

@ -17,7 +17,7 @@ from django.views.generic.base import TemplateView
from part.models import Part from part.models import Part
from .forms import DeleteForm from .forms import DeleteForm, EditUserForm, SetPasswordForm
from .helpers import str2bool from .helpers import str2bool
from rest_framework import views from rest_framework import views
@ -371,6 +371,59 @@ class AjaxDeleteView(AjaxMixin, UpdateView):
return self.renderJsonResponse(request, form, data=data, context=context) return self.renderJsonResponse(request, form, data=data, context=context)
class EditUserView(AjaxUpdateView):
""" View for editing user information """
ajax_template_name = "modal_form.html"
ajax_form_title = "Edit User Information"
form_class = EditUserForm
def get_object(self):
return self.request.user
class SetPasswordView(AjaxUpdateView):
""" View for setting user password """
ajax_template_name = "InvenTree/password.html"
ajax_form_title = "Set Password"
form_class = SetPasswordForm
def get_object(self):
return self.request.user
def post(self, request, *args, **kwargs):
form = self.get_form()
valid = form.is_valid()
p1 = request.POST.get('enter_password', '')
p2 = request.POST.get('confirm_password', '')
if valid:
# Passwords must match
if not p1 == p2:
error = 'Password fields must match'
form.errors['enter_password'] = [error]
form.errors['confirm_password'] = [error]
valid = False
data = {
'form_valid': valid
}
if valid:
user = self.request.user
user.set_password(p1)
user.save()
return self.renderJsonResponse(request, form, data=data)
class IndexView(TemplateView): class IndexView(TemplateView):
""" View for InvenTree index page """ """ View for InvenTree index page """
@ -414,3 +467,10 @@ class SearchView(TemplateView):
context['query'] = query context['query'] = query
return super(TemplateView, self).render_to_response(context) return super(TemplateView, self).render_to_response(context)
class SettingsView(TemplateView):
""" View for configuring User settings
"""
template_name = "InvenTree/settings.html"

View File

@ -58,6 +58,18 @@ class CompleteBuildForm(HelperForm):
] ]
class CancelBuildForm(HelperForm):
""" Form for cancelling a build """
confirm_cancel = forms.BooleanField(required=False, help_text='Confirm build cancellation')
class Meta:
model = Build
fields = [
'confirm_cancel'
]
class EditBuildItemForm(HelperForm): class EditBuildItemForm(HelperForm):
""" Form for adding a new BuildItem to a Build """ """ Form for adding a new BuildItem to a Build """

View File

@ -22,8 +22,8 @@ Automatically allocate stock to this build?
<tr> <tr>
<td> <td>
<a class='hover-icon'> <a class='hover-icon'>
<img class='hover-img-thumb' src='{{ item.stock_item.part.image.url }}'> <img class='hover-img-thumb' src='{% if item.stock_item.part.image %}{{ item.stock_item.part.image.url }}{% endif %}'>
<img class='hover-img-large' src='{{ item.stock_item.part.image.url }}'> <img class='hover-img-large' src='{% if item.stock_item.part.image %}{{ item.stock_item.part.image.url }}{% endif %}'>
</a> </a>
</td> </td>
<td> <td>

View File

@ -1,3 +1,7 @@
{% extends "modal_form.html" %}
{% block pre_form_content %}
Are you sure you wish to cancel this build? Are you sure you wish to cancel this build?
{% include "modal_csrf.html" %} {% endblock %}

View File

@ -5,8 +5,6 @@ Django views for interacting with Build objects
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
from django.shortcuts import get_object_or_404
from django.views.generic import DetailView, ListView from django.views.generic import DetailView, ListView
from django.forms import HiddenInput from django.forms import HiddenInput
@ -15,7 +13,8 @@ from .models import Build, BuildItem
from . import forms from . import forms
from stock.models import StockLocation, StockItem from stock.models import StockLocation, StockItem
from InvenTree.views import AjaxView, AjaxUpdateView, AjaxCreateView, AjaxDeleteView from InvenTree.views import AjaxUpdateView, AjaxCreateView, AjaxDeleteView
from InvenTree.helpers import str2bool
class BuildIndex(ListView): class BuildIndex(ListView):
@ -41,31 +40,41 @@ class BuildIndex(ListView):
return context return context
class BuildCancel(AjaxView): class BuildCancel(AjaxUpdateView):
""" View to cancel a Build. """ View to cancel a Build.
Provides a cancellation information dialog Provides a cancellation information dialog
""" """
model = Build model = Build
ajax_template_name = 'build/cancel.html' ajax_template_name = 'build/cancel.html'
ajax_form_title = 'Cancel Build' ajax_form_title = 'Cancel Build'
context_object_name = 'build' context_object_name = 'build'
fields = [] form_class = forms.CancelBuildForm
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
""" Handle POST request. Mark the build status as CANCELLED """ """ Handle POST request. Mark the build status as CANCELLED """
build = get_object_or_404(Build, pk=self.kwargs['pk']) build = self.get_object()
form = self.get_form()
valid = form.is_valid()
confirm = str2bool(request.POST.get('confirm_cancel', False))
if confirm:
build.cancelBuild(request.user) build.cancelBuild(request.user)
else:
form.errors['confirm_cancel'] = ['Confirm build cancellation']
valid = False
return self.renderJsonResponse(request, None) data = {
'form_valid': valid,
def get_data(self):
""" Provide JSON context data. """
return {
'danger': 'Build was cancelled' 'danger': 'Build was cancelled'
} }
return self.renderJsonResponse(request, form, data=data)
class BuildAutoAllocate(AjaxUpdateView): class BuildAutoAllocate(AjaxUpdateView):
""" View to auto-allocate parts for a build. """ View to auto-allocate parts for a build.
@ -90,7 +99,7 @@ class BuildAutoAllocate(AjaxUpdateView):
context['build'] = build context['build'] = build
context['allocations'] = build.getAutoAllocations() context['allocations'] = build.getAutoAllocations()
except Build.DoesNotExist: except Build.DoesNotExist:
context['error'] = 'No matching buidl found' context['error'] = 'No matching build found'
return context return context
@ -217,7 +226,7 @@ class BuildComplete(AjaxUpdateView):
form = self.get_form() form = self.get_form()
confirm = request.POST.get('confirm', False) confirm = str2bool(request.POST.get('confirm', False))
loc_id = request.POST.get('location', None) loc_id = request.POST.get('location', None)

View File

@ -157,6 +157,7 @@ class PartList(generics.ListCreateAPIView):
'$name', '$name',
'description', 'description',
'$IPN', '$IPN',
'keywords',
] ]

View File

@ -92,9 +92,10 @@ class EditPartForm(HelperForm):
'confirm_creation', 'confirm_creation',
'category', 'category',
'name', 'name',
'IPN',
'variant', 'variant',
'description', 'description',
'IPN', 'keywords',
'URL', 'URL',
'default_location', 'default_location',
'default_supplier', 'default_supplier',
@ -118,7 +119,8 @@ class EditCategoryForm(HelperForm):
'parent', 'parent',
'name', 'name',
'description', 'description',
'default_location' 'default_location',
'default_keywords',
] ]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2 on 2019-05-14 07:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0022_auto_20190512_1246'),
]
operations = [
migrations.AddField(
model_name='part',
name='keywords',
field=models.CharField(blank=True, help_text='Part keywords to improve visibility in search results', max_length=250),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 2.2 on 2019-05-14 07:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0023_part_keywords'),
]
operations = [
migrations.AddField(
model_name='partcategory',
name='default_keywords',
field=models.CharField(blank=True, help_text='Default keywords for parts in this category', max_length=250),
),
]

View File

@ -37,6 +37,12 @@ from company.models import Company
class PartCategory(InvenTreeTree): class PartCategory(InvenTreeTree):
""" PartCategory provides hierarchical organization of Part objects. """ PartCategory provides hierarchical organization of Part objects.
Attributes:
name: Name of this category
parent: Parent category
default_location: Default storage location for parts in this category or child categories
default_keywords: Default keywords for parts created in this category
""" """
default_location = models.ForeignKey( default_location = models.ForeignKey(
@ -46,6 +52,8 @@ class PartCategory(InvenTreeTree):
help_text='Default location for parts in this category' help_text='Default location for parts in this category'
) )
default_keywords = models.CharField(blank=True, max_length=250, help_text='Default keywords for parts in this category')
def get_absolute_url(self): def get_absolute_url(self):
return reverse('category-detail', kwargs={'pk': self.id}) return reverse('category-detail', kwargs={'pk': self.id})
@ -179,8 +187,9 @@ class Part(models.Model):
Attributes: Attributes:
name: Brief name for this part name: Brief name for this part
variant: Optional variant number for this part - Must be unique for the part name variant: Optional variant number for this part - Must be unique for the part name
description: Longer form description of the part
category: The PartCategory to which this part belongs category: The PartCategory to which this part belongs
description: Longer form description of the part
keywords: Optional keywords for improving part search results
IPN: Internal part number (optional) IPN: Internal part number (optional)
URL: Link to an external page with more information about this part (e.g. internal Wiki) URL: Link to an external page with more information about this part (e.g. internal Wiki)
image: Image of this part image: Image of this part
@ -250,6 +259,8 @@ class Part(models.Model):
description = models.CharField(max_length=250, blank=False, help_text='Part description') description = models.CharField(max_length=250, blank=False, help_text='Part description')
keywords = models.CharField(max_length=250, blank=True, help_text='Part keywords to improve visibility in search results')
category = models.ForeignKey(PartCategory, related_name='parts', category = models.ForeignKey(PartCategory, related_name='parts',
null=True, blank=True, null=True, blank=True,
on_delete=models.DO_NOTHING, on_delete=models.DO_NOTHING,

View File

@ -62,15 +62,16 @@ class PartSerializer(serializers.ModelSerializer):
fields = [ fields = [
'pk', 'pk',
'url', # Link to the part detail page 'url', # Link to the part detail page
'full_name',
'name',
'variant',
'image_url',
'IPN',
'URL', # Link to an external URL (optional)
'description',
'category', 'category',
'category_name', 'category_name',
'image_url',
'full_name',
'name',
'IPN',
'variant',
'description',
'keywords',
'URL',
'total_stock', 'total_stock',
'available_stock', 'available_stock',
'units', 'units',

View File

@ -35,16 +35,22 @@
<td>Part name</td> <td>Part name</td>
<td>{{ part.full_name }}</td> <td>{{ part.full_name }}</td>
</tr> </tr>
<tr>
<td>Description</td>
<td>{{ part.description }}</td>
</tr>
{% if part.IPN %} {% if part.IPN %}
<tr> <tr>
<td>IPN</td> <td>IPN</td>
<td>{{ part.IPN }}</td> <td>{{ part.IPN }}</td>
</tr> </tr>
{% endif %} {% endif %}
<tr>
<td>Description</td>
<td>{{ part.description }}</td>
</tr>
{% if part.keywords %}
<tr>
<td>Keywords</td>
<td>{{ part.keywords }}</td>
</tr>
{% endif %}
{% if part.URL %} {% if part.URL %}
<tr> <tr>
<td>URL</td> <td>URL</td>

View File

@ -339,7 +339,9 @@ class PartCreate(AjaxCreateView):
if self.get_category_id(): if self.get_category_id():
try: try:
initials['category'] = PartCategory.objects.get(pk=self.get_category_id()) category = PartCategory.objects.get(pk=self.get_category_id())
initials['category'] = category
initials['keywords'] = category.default_keywords
except PartCategory.DoesNotExist: except PartCategory.DoesNotExist:
pass pass

View File

@ -89,11 +89,11 @@ function loadBomTable(table, options) {
// Part column // Part column
cols.push( cols.push(
{ {
field: 'sub_part_detail', field: 'sub_part_detail.full_name',
title: 'Part', title: 'Part',
sortable: true, sortable: true,
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
return imageHoverIcon(value.image_url) + renderLink(value.full_name, value.url); return imageHoverIcon(row.sub_part_detail.image_url) + renderLink(row.sub_part_detail.full_name, row.sub_part_detail.url);
} }
} }
); );
@ -116,6 +116,34 @@ function loadBomTable(table, options) {
} }
); );
if (!options.editable) {
cols.push(
{
field: 'sub_part_detail.available_stock',
title: 'Available',
searchable: false,
sortable: true,
formatter: function(value, row, index, field) {
var text = "";
if (row.quantity < row.sub_part_detail.available_stock)
{
text = "<span class='label label-success'>" + value + "</span>";
}
else
{
if (!value) {
value = 'No Stock';
}
text = "<span class='label label-warning'>" + value + "</span>";
}
return renderLink(text, row.sub_part_detail.url + "stock/");
}
}
);
}
// Part notes // Part notes
cols.push( cols.push(
{ {
@ -137,31 +165,6 @@ function loadBomTable(table, options) {
}); });
} }
else {
cols.push(
{
field: 'sub_part_detail.available_stock',
title: 'Available',
searchable: false,
sortable: true,
formatter: function(value, row, index, field) {
var text = "";
if (row.quantity < row.sub_part_detail.available_stock)
{
text = "<span class='label label-success'>" + value + "</span>";
}
else
{
text = "<span class='label label-warning'>" + value + "</span>";
}
return renderLink(text, row.sub_part.url + "stock/");
}
}
);
}
// Configure the table (bootstrap-table) // Configure the table (bootstrap-table)
table.bootstrapTable({ table.bootstrapTable({
@ -172,6 +175,7 @@ function loadBomTable(table, options) {
queryParams: function(p) { queryParams: function(p) {
return { return {
part: options.parent_id, part: options.parent_id,
ordering: 'name',
} }
}, },
columns: cols, columns: cols,

View File

@ -119,13 +119,12 @@ function loadPartTable(table, url, options={}) {
visible: false, visible: false,
}, },
{ {
field: 'name', field: 'full_name',
title: 'Part', title: 'Part',
sortable: true, sortable: true,
formatter: function(value, row, index, field) { formatter: function(value, row, index, field) {
var name = row.full_name;
var display = imageHoverIcon(row.image_url) + renderLink(name, row.url); var display = imageHoverIcon(row.image_url) + renderLink(value, row.url);
if (!row.active) { if (!row.active) {
display = display + "<span class='label label-warning' style='float: right;'>INACTIVE</span>"; display = display + "<span class='label label-warning' style='float: right;'>INACTIVE</span>";
} }
@ -160,7 +159,7 @@ function loadPartTable(table, url, options={}) {
return renderLink(value, row.url + 'stock/'); return renderLink(value, row.url + 'stock/');
} }
else { else {
return "<span class='label label-warning'>No stock</span>"; return "<span class='label label-warning'>No Stock</span>";
} }
} }
} }

View File

@ -0,0 +1,7 @@
{% extends "modal_form.html" %}
{% block pre_form_content %}
{{ block.super }}
{% endblock %}

View File

@ -0,0 +1,66 @@
{% extends "base.html" %}
{% block page_title %}
InvenTree | Settings
{% endblock %}
{% block content %}
<h3>InvenTree Settings</h3>
<hr>
<div class='row'>
<div class='col-sm-6'>
<h4>User Information</h4>
</div>
<div class='col-sm-6'>
<div class='btn-group' style='float: right;'>
<div class='btn btn-primary' type='button' id='edit-user' title='Edit User Information'>Edit</div>
<div class='btn btn-primary' type='button' id='edit-password' title='Change Password'>Set Password</div>
</div>
</div>
</div>
<table class='table table-striped table-condensed'>
<tr>
<td>First Name</td>
<td>{{ user.first_name }}</td>
</tr>
<tr>
<td>Last Name</td>
<td>{{ user.last_name }}</td>
</tr>
<tr>
<td>Email Address</td>
<td>{{ user.email }}</td>
</tr>
</table>
{% endblock %}
{% block js_load %}
{{ block.super }}
{% endblock %}
{% block js_ready %}
{{ block.super }}
$("#edit-user").on('click', function() {
launchModalForm(
"{% url 'edit-user' %}",
{
reload: true,
}
);
});
$("#edit-password").on('click', function() {
launchModalForm(
"{% url 'set-password' %}",
{
reload: true,
}
);
});
{% endblock %}

View File

@ -19,7 +19,9 @@
{% if user.is_authenticated %} {% if user.is_authenticated %}
{% if user.is_staff %} {% if user.is_staff %}
<li><a href="/admin/"><span class="glyphicon glyphicon-edit"></span> Admin</a></li> <li><a href="/admin/"><span class="glyphicon glyphicon-edit"></span> Admin</a></li>
<hr>
{% endif %} {% endif %}
<li><a href="{% url 'settings' %}"><span class="glyphicon glyphicon-cog"></span> Settings</a></li>
<li><a href="{% url 'logout' %}"><span class="glyphicon glyphicon-log-out"></span> Logout</a></li> <li><a href="{% url 'logout' %}"><span class="glyphicon glyphicon-log-out"></span> Logout</a></li>
{% else %} {% else %}
<li><a href="{% url 'login' %}"><span class="glyphicon glyphicon-log-in"></span> Login</a></li> <li><a href="{% url 'login' %}"><span class="glyphicon glyphicon-log-in"></span> Login</a></li>