Merge remote-tracking branch 'inventree/master'

This commit is contained in:
Oliver Walters 2019-05-05 11:49:58 +10:00
commit c203c3542f
21 changed files with 331 additions and 40 deletions

View File

@ -328,11 +328,15 @@ class IndexView(TemplateView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(TemplateView, self).get_context_data(**kwargs) context = super(TemplateView, self).get_context_data(**kwargs)
context['starred'] = [star.part for star in self.request.user.starred_parts.all()]
# Generate a list of orderable parts which have stock below their minimum values # Generate a list of orderable parts which have stock below their minimum values
# TODO - Is there a less expensive way to get these from the database
context['to_order'] = [part for part in Part.objects.filter(purchaseable=True) if part.need_to_restock()] context['to_order'] = [part for part in Part.objects.filter(purchaseable=True) if part.need_to_restock()]
# Generate a list of buildable parts which have stock below their minimum values # Generate a list of buildable parts which have stock below their minimum values
# TODO - Is there a less expensive way to get these from the database
context['to_build'] = [part for part in Part.objects.filter(buildable=True) if part.need_to_restock()] context['to_build'] = [part for part in Part.objects.filter(buildable=True) if part.need_to_restock()]
return context return context

View File

@ -2,7 +2,7 @@ from django.contrib import admin
from import_export.admin import ImportExportModelAdmin from import_export.admin import ImportExportModelAdmin
from .models import PartCategory, Part from .models import PartCategory, Part
from .models import PartAttachment from .models import PartAttachment, PartStar
from .models import SupplierPart from .models import SupplierPart
from .models import BomItem from .models import BomItem
@ -22,6 +22,11 @@ class PartAttachmentAdmin(admin.ModelAdmin):
list_display = ('part', 'attachment', 'comment') list_display = ('part', 'attachment', 'comment')
class PartStarAdmin(admin.ModelAdmin):
list_display = ('part', 'user')
class BomItemAdmin(ImportExportModelAdmin): class BomItemAdmin(ImportExportModelAdmin):
list_display = ('part', 'sub_part', 'quantity') list_display = ('part', 'sub_part', 'quantity')
@ -42,5 +47,6 @@ class ParameterAdmin(admin.ModelAdmin):
admin.site.register(Part, PartAdmin) admin.site.register(Part, PartAdmin)
admin.site.register(PartCategory, PartCategoryAdmin) admin.site.register(PartCategory, PartCategoryAdmin)
admin.site.register(PartAttachment, PartAttachmentAdmin) admin.site.register(PartAttachment, PartAttachmentAdmin)
admin.site.register(PartStar, PartStarAdmin)
admin.site.register(BomItem, BomItemAdmin) admin.site.register(BomItem, BomItemAdmin)
admin.site.register(SupplierPart, SupplierPartAdmin) admin.site.register(SupplierPart, SupplierPartAdmin)

View File

@ -6,18 +6,22 @@ Provides a JSON API for the Part app
from __future__ import unicode_literals from __future__ import unicode_literals
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import status
from rest_framework.response import Response
from rest_framework import filters from rest_framework import filters
from rest_framework import generics, permissions from rest_framework import generics, permissions
from django.db.models import Q from django.db.models import Q
from django.conf.urls import url, include from django.conf.urls import url, include
from .models import Part, PartCategory, BomItem from .models import Part, PartCategory, BomItem, PartStar
from .models import SupplierPart, SupplierPriceBreak from .models import SupplierPart, SupplierPriceBreak
from .serializers import PartSerializer, BomItemSerializer from .serializers import PartSerializer, BomItemSerializer
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
from .serializers import CategorySerializer from .serializers import CategorySerializer
from .serializers import PartStarSerializer
from InvenTree.views import TreeSerializer from InvenTree.views import TreeSerializer
@ -150,8 +154,57 @@ class PartList(generics.ListCreateAPIView):
] ]
class PartStarDetail(generics.RetrieveDestroyAPIView):
""" API endpoint for viewing or removing a PartStar object """
queryset = PartStar.objects.all()
serializer_class = PartStarSerializer
class PartStarList(generics.ListCreateAPIView):
""" API endpoint for accessing a list of PartStar objects.
- GET: Return list of PartStar objects
- POST: Create a new PartStar object
"""
queryset = PartStar.objects.all()
serializer_class = PartStarSerializer
def create(self, request, *args, **kwargs):
# Override the user field (with the logged-in user)
data = request.data.copy()
data['user'] = str(request.user.id)
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
permission_classes = [
permissions.IsAuthenticatedOrReadOnly,
]
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter
]
filter_fields = [
'part',
'user',
]
search_fields = [
'partname'
]
class BomList(generics.ListCreateAPIView): class BomList(generics.ListCreateAPIView):
""" API endpoing for accessing a list of BomItem objects """ API endpoint for accessing a list of BomItem objects.
- GET: Return list of BomItem objects - GET: Return list of BomItem objects
- POST: Create a new BomItem object - POST: Create a new BomItem object
@ -267,12 +320,21 @@ supplier_part_api_urls = [
url(r'^.*$', SupplierPartList.as_view(), name='api-part-supplier-list'), url(r'^.*$', SupplierPartList.as_view(), name='api-part-supplier-list'),
] ]
part_star_api_urls = [
url(r'^(?P<pk>\d+)/?', PartStarDetail.as_view(), name='api-part-star-detail'),
# Catchall
url(r'^.*$', PartStarList.as_view(), name='api-part-star-list'),
]
part_api_urls = [ part_api_urls = [
url(r'^tree/?', PartCategoryTree.as_view(), name='api-part-tree'), url(r'^tree/?', PartCategoryTree.as_view(), name='api-part-tree'),
url(r'^category/', include(cat_api_urls)), url(r'^category/', include(cat_api_urls)),
url(r'^supplier/', include(supplier_part_api_urls)), url(r'^supplier/', include(supplier_part_api_urls)),
url(r'^star/', include(part_star_api_urls)),
url(r'^price-break/?', SupplierPriceBreakList.as_view(), name='api-part-supplier-price'), url(r'^price-break/?', SupplierPriceBreakList.as_view(), name='api-part-supplier-price'),
url(r'^(?P<pk>\d+)/', PartDetail.as_view(), name='api-part-detail'), url(r'^(?P<pk>\d+)/', PartDetail.as_view(), name='api-part-detail'),

View File

@ -0,0 +1,24 @@
# Generated by Django 2.2 on 2019-05-04 22:45
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('part', '0015_partcategory_default_location'),
]
operations = [
migrations.CreateModel(
name='PartStar',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='starred_users', to='part.Part')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='starred_parts', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 2.2 on 2019-05-04 22:48
from django.conf import settings
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('part', '0016_partstar'),
]
operations = [
migrations.AlterUniqueTogether(
name='partstar',
unique_together={('part', 'user')},
),
]

View File

@ -18,6 +18,7 @@ from django.urls import reverse
from django.db import models from django.db import models
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
from django.contrib.auth.models import User
from django.db.models.signals import pre_delete from django.db.models.signals import pre_delete
from django.dispatch import receiver from django.dispatch import receiver
@ -246,6 +247,15 @@ class Part(models.Model):
return total return total
def isStarredBy(self, user):
""" Return True if this part has been starred by a particular user """
try:
PartStar.objects.get(part=self, user=user)
return True
except PartStar.DoesNotExist:
return False
def need_to_restock(self): def need_to_restock(self):
""" Return True if this part needs to be restocked """ Return True if this part needs to be restocked
(either by purchasing or building). (either by purchasing or building).
@ -427,6 +437,21 @@ class PartAttachment(models.Model):
return os.path.basename(self.attachment.name) return os.path.basename(self.attachment.name)
class PartStar(models.Model):
""" A PartStar object creates a relationship between a User and a Part.
It is used to designate a Part as 'starred' (or favourited) for a given User,
so that the user can track a list of their favourite parts.
"""
part = models.ForeignKey(Part, on_delete=models.CASCADE, related_name='starred_users')
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='starred_parts')
class Meta:
unique_together = ['part', 'user']
class BomItem(models.Model): class BomItem(models.Model):
""" A BomItem links a part to its component items. """ A BomItem links a part to its component items.
A part can have a BOM (bill of materials) which defines A part can have a BOM (bill of materials) which defines

View File

@ -4,8 +4,10 @@ JSON serializers for Part app
from rest_framework import serializers from rest_framework import serializers
from .models import Part, PartCategory, BomItem from .models import Part, PartStar
from .models import SupplierPart, SupplierPriceBreak from .models import SupplierPart, SupplierPriceBreak
from .models import PartCategory
from .models import BomItem
from InvenTree.serializers import InvenTreeModelSerializer from InvenTree.serializers import InvenTreeModelSerializer
@ -75,6 +77,23 @@ class PartSerializer(serializers.ModelSerializer):
] ]
class PartStarSerializer(InvenTreeModelSerializer):
""" Serializer for a PartStar object """
partname = serializers.CharField(source='part.name', read_only=True)
username = serializers.CharField(source='user.username', read_only=True)
class Meta:
model = PartStar
fields = [
'pk',
'part',
'partname',
'user',
'username',
]
class BomItemSerializer(InvenTreeModelSerializer): class BomItemSerializer(InvenTreeModelSerializer):
""" Serializer for BomItem object """ """ Serializer for BomItem object """

View File

@ -23,7 +23,6 @@
{% else %} {% else %}
<li><a href="#" id='activate-part' title='Activate part'>Activate</a></li> <li><a href="#" id='activate-part' title='Activate part'>Activate</a></li>
{% endif %} {% endif %}
<li><a href='#' id='show-qr-code' title='Generate QR Code'>Show QR Code</a></li>
</ul> </ul>
</div> </div>
</h3> </h3>
@ -126,15 +125,6 @@
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
$("#show-qr-code").click(function() {
launchModalForm(
"{% url 'part-qr' part.id %}",
{
no_post: true,
}
);
});
$("#duplicate-part").click(function() { $("#duplicate-part").click(function() {
launchModalForm( launchModalForm(

View File

@ -1,5 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %}
{% block sidenav %} {% block sidenav %}
<div id='part-tree'></div> <div id='part-tree'></div>
{% endblock %} {% endblock %}
@ -14,6 +16,11 @@
{% endblock %} {% endblock %}
{% block js_load %}
{{ block.super }}
<script type='text/javascript' src="{% static 'script/inventree/part.js' %}"></script>
{% endblock %}
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
loadTree("{% url 'api-part-tree' %}", loadTree("{% url 'api-part-tree' %}",

View File

@ -21,20 +21,34 @@
{% endif %}/> {% endif %}/>
</div> </div>
<div class="media-body"> <div class="media-body">
<h4>{{ part.name }}{% if part.active == False %} <i>- INACTIVE</i>{% endif %}</h4> <h4>
{{ part.name }}
</h4>
<p><i>{{ part.description }}</i></p> <p><i>{{ part.description }}</i></p>
{% if part.IPN %} <p>
<tr> <div class='btn-group'>
<td>IPN</td> {% include "qr_button.html" %}
<td>{{ part.IPN }}</td> <button type='button' class='btn btn-default btn-glyph' id='toggle-starred' title='Star this part'>
</tr> <span id='part-star-icon' class='starred-part glyphicon {% if starred %}glyphicon-star{% else %}glyphicon-star-empty{% endif %}'/>
{% endif %} </button>
{% if part.URL %} </div>
<tr> </p>
<td>URL</td> <table class='table table-condensed'>
<td><a href="{{ part.URL }}">{{ part.URL }}</a></td> {% if part.IPN %}
</tr> <tr>
{% endif %} <td>IPN</td>
<td>{{ part.IPN }}</td>
</tr>
{% endif %}
{% if part.URL %}
<tr>
<td>URL</td>
<td><a href="{{ part.URL }}">{{ part.URL }}</a></td>
</tr>
{% endif %}
<tr>
</tr>
</table>
</div> </div>
</div> </div>
</div> </div>
@ -82,6 +96,26 @@
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
$("#show-qr-code").click(function() {
launchModalForm(
"{% url 'part-qr' part.id %}",
{
no_post: true,
}
);
});
$("#toggle-starred").click(function() {
toggleStar({
part: {{ part.id }},
user: {{ user.id }},
button: '#part-star-icon'
});
});
$('#toggle-starred').click(function() {
});
$("#part-thumb").click(function() { $("#part-thumb").click(function() {
launchModalForm( launchModalForm(
"{% url 'part-image' part.id %}", "{% url 'part-image' part.id %}",

View File

@ -232,6 +232,10 @@ class PartDetail(DetailView):
else: else:
context['editing_enabled'] = 0 context['editing_enabled'] = 0
part = self.get_object()
context['starred'] = part.isStarredBy(self.request.user)
return context return context

View File

@ -2,6 +2,21 @@
float: left; float: left;
} }
.glyphicon {
font-size: 20px;
}
.starred-part {
color: #ffcc00;
}
.btn-glyph {
padding-left: 6px;
padding-right: 6px;
padding-top: 3px;
padding-bottom: 2px;
}
.badge { .badge {
float: right; float: right;
background-color: #777; background-color: #777;

View File

@ -43,9 +43,6 @@ function inventreeGet(url, filters={}, options={}) {
} }
function inventreeUpdate(url, data={}, options={}) { function inventreeUpdate(url, data={}, options={}) {
if ('final' in options && options.final) {
data["_is_final"] = true;
}
var method = options.method || 'PUT'; var method = options.method || 'PUT';
@ -63,8 +60,7 @@ function inventreeUpdate(url, data={}, options={}) {
dataType: 'json', dataType: 'json',
contentType: 'application/json', contentType: 'application/json',
success: function(response, status) { success: function(response, status) {
response['_status_code'] = status; console.log(method + ' - ' + url + ' : result = ' + status);
console.log('UPDATE object to ' + url + ' - result = ' + status);
if (options.success) { if (options.success) {
options.success(response, status); options.success(response, status);
} }

View File

@ -16,4 +16,60 @@ function getPartList(filters={}, options={}) {
function getBomList(filters={}, options={}) { function getBomList(filters={}, options={}) {
return inventreeGet('/api/bom/', filters, options); return inventreeGet('/api/bom/', filters, options);
}
function toggleStar(options) {
/* Toggle the 'starred' status of a part.
* Performs AJAX queries and updates the display on the button.
*
* options:
* - button: ID of the button (default = '#part-star-icon')
* - part: pk of the part object
* - user: pk of the user
*/
var url = '/api/part/star/';
inventreeGet(
url,
{
part: options.part,
user: options.user,
},
{
success: function(response) {
if (response.length == 0) {
// Zero length response = star does not exist
// So let's add one!
inventreeUpdate(
url,
{
part: options.part,
user: options.user,
},
{
method: 'POST',
success: function(response, status) {
$(options.button).removeClass('glyphicon-star-empty').addClass('glyphicon-star');
},
}
);
} else {
var pk = response[0].pk;
// There IS a star (delete it!)
inventreeUpdate(
url + pk + "/",
{
},
{
method: 'DELETE',
success: function(response, status) {
$(options.button).removeClass('glyphicon-star').addClass('glyphicon-star-empty');
},
}
);
}
},
}
);
} }

View File

@ -6,6 +6,11 @@
<div class='col-sm-6'> <div class='col-sm-6'>
<h3>Stock Item Details</h3> <h3>Stock Item Details</h3>
<p><i>{{ item.quantity }} &times {{ item.part.name }}</i></p> <p><i>{{ item.quantity }} &times {{ item.part.name }}</i></p>
<p>
<div class='btn-group'>
{% include "qr_button.html" %}
</div>
</p>
</div> </div>
<div class='col-sm-6'> <div class='col-sm-6'>
<h3> <h3>
@ -23,9 +28,6 @@
<li><a href='#' id='stock-stocktake' title='Count stock'>Stocktake</a></li> <li><a href='#' id='stock-stocktake' title='Count stock'>Stocktake</a></li>
{% endif %} {% endif %}
<li><a href="#" id='stock-delete' title='Delete stock item'>Delete stock item</a></li> <li><a href="#" id='stock-delete' title='Delete stock item'>Delete stock item</a></li>
<hr>
<li><a href="#" id='item-qr-code' title='Generate QR code'>Show QR code</a></li>
</ul>
</div> </div>
</div> </div>
</h3> </h3>
@ -145,7 +147,7 @@
}); });
}); });
$("#item-qr-code").click(function() { $("#show-qr-code").click(function() {
launchModalForm("{% url 'stock-item-qr' item.id %}", launchModalForm("{% url 'stock-item-qr' item.id %}",
{ {
no_post: true, no_post: true,

View File

@ -7,6 +7,11 @@
{% if location %} {% if location %}
<h3>{{ location.name }}</h3> <h3>{{ location.name }}</h3>
<p>{{ location.description }}</p> <p>{{ location.description }}</p>
<p>
<div class='btn-group'>
{% include "qr_button.html" %}
</div>
</p>
{% else %} {% else %}
<h3>Stock</h3> <h3>Stock</h3>
<p>All stock items</p> <p>All stock items</p>
@ -23,8 +28,6 @@
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a href="#" id='location-edit' title='Edit stock location'>Edit</a></li> <li><a href="#" id='location-edit' title='Edit stock location'>Edit</a></li>
<li><a href="#" id='location-delete' title='Delete stock location'>Delete</a></li> <li><a href="#" id='location-delete' title='Delete stock location'>Delete</a></li>
<hr>
<li><a href="#" id='location-qr-code' title='Generate QR code'>Show QR code</a></li>
</ul> </ul>
</div> </div>
{% endif %} {% endif %}
@ -101,7 +104,7 @@
return false; return false;
}); });
$('#location-qr-code').click(function() { $('#show-qr-code').click(function() {
launchModalForm("{% url 'stock-location-qr' location.id %}", launchModalForm("{% url 'stock-location-qr' location.id %}",
{ {
no_post: true, no_post: true,

View File

@ -3,6 +3,8 @@
{% block content %} {% block content %}
<h3>InvenTree</h3> <h3>InvenTree</h3>
{% include "InvenTree/starred_parts.html" with collapse_id="starred" %}
{% if to_order %} {% if to_order %}
{% include "InvenTree/parts_to_order.html" with collapse_id="order" %} {% include "InvenTree/parts_to_order.html" with collapse_id="order" %}
{% endif %} {% endif %}
@ -19,4 +21,9 @@
{% block js_ready %} {% block js_ready %}
{{ block.super }} {{ block.super }}
$("#to-build-table").bootstrapTable();
$("#to-order-table").bootstrapTable();
$("#starred-parts-table").bootstrapTable();
{% endblock %} {% endblock %}

View File

@ -1,5 +1,6 @@
{% extends "collapse.html" %} {% extends "collapse.html" %}
{% block collapse_title %} {% block collapse_title %}
<span class='glyphicon glyphicon-wrench'></span>
Parts to Build<span class='badge'>{{ to_build | length }}</span> Parts to Build<span class='badge'>{{ to_build | length }}</span>
{% endblock %} {% endblock %}

View File

@ -1,5 +1,6 @@
{% extends "collapse.html" %} {% extends "collapse.html" %}
{% block collapse_title %} {% block collapse_title %}
<span class='glyphicon glyphicon-shopping-cart'></span>
Parts to Order<span class='badge'>{{ to_order | length }}</span> Parts to Order<span class='badge'>{{ to_order | length }}</span>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "collapse.html" %}
{% block collapse_title %}
<span class='glyphicon glyphicon-star'></span>
Starred Parts<span class='badge'>{{ starred | length }}</span>
{% endblock %}
{% block collapse_heading %}
You have {{ starred | length }} favourite parts
{% endblock %}
{% block collapse_content %}
{% include "required_part_table.html" with parts=starred table_id="starred-parts-table" %}
{% endblock %}

View File

@ -0,0 +1 @@
<button type='button' class='btn btn-default btn-glyph' id='show-qr-code' title='Show QR code'><span class='glyphicon glyphicon-qrcode'></span></button>