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

@ -329,10 +329,14 @@ class IndexView(TemplateView):
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
# 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()]
# 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()]
return context

View File

@ -2,7 +2,7 @@ from django.contrib import admin
from import_export.admin import ImportExportModelAdmin
from .models import PartCategory, Part
from .models import PartAttachment
from .models import PartAttachment, PartStar
from .models import SupplierPart
from .models import BomItem
@ -22,6 +22,11 @@ class PartAttachmentAdmin(admin.ModelAdmin):
list_display = ('part', 'attachment', 'comment')
class PartStarAdmin(admin.ModelAdmin):
list_display = ('part', 'user')
class BomItemAdmin(ImportExportModelAdmin):
list_display = ('part', 'sub_part', 'quantity')
@ -42,5 +47,6 @@ class ParameterAdmin(admin.ModelAdmin):
admin.site.register(Part, PartAdmin)
admin.site.register(PartCategory, PartCategoryAdmin)
admin.site.register(PartAttachment, PartAttachmentAdmin)
admin.site.register(PartStar, PartStarAdmin)
admin.site.register(BomItem, BomItemAdmin)
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 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 generics, permissions
from django.db.models import Q
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 .serializers import PartSerializer, BomItemSerializer
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
from .serializers import CategorySerializer
from .serializers import PartStarSerializer
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):
""" API endpoing for accessing a list of BomItem objects
""" API endpoint for accessing a list of BomItem objects.
- GET: Return list of BomItem objects
- POST: Create a new BomItem object
@ -267,12 +320,21 @@ supplier_part_api_urls = [
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 = [
url(r'^tree/?', PartCategoryTree.as_view(), name='api-part-tree'),
url(r'^category/', include(cat_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'^(?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.core.validators import MinValueValidator
from django.contrib.auth.models import User
from django.db.models.signals import pre_delete
from django.dispatch import receiver
@ -246,6 +247,15 @@ class Part(models.Model):
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):
""" Return True if this part needs to be restocked
(either by purchasing or building).
@ -427,6 +437,21 @@ class PartAttachment(models.Model):
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):
""" A BomItem links a part to its component items.
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 .models import Part, PartCategory, BomItem
from .models import Part, PartStar
from .models import SupplierPart, SupplierPriceBreak
from .models import PartCategory
from .models import BomItem
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):
""" Serializer for BomItem object """

View File

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

View File

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

View File

@ -21,8 +21,19 @@
{% endif %}/>
</div>
<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>
<div class='btn-group'>
{% include "qr_button.html" %}
<button type='button' class='btn btn-default btn-glyph' id='toggle-starred' title='Star this part'>
<span id='part-star-icon' class='starred-part glyphicon {% if starred %}glyphicon-star{% else %}glyphicon-star-empty{% endif %}'/>
</button>
</div>
</p>
<table class='table table-condensed'>
{% if part.IPN %}
<tr>
<td>IPN</td>
@ -35,6 +46,9 @@
<td><a href="{{ part.URL }}">{{ part.URL }}</a></td>
</tr>
{% endif %}
<tr>
</tr>
</table>
</div>
</div>
</div>
@ -82,6 +96,26 @@
{% block js_ready %}
{{ 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() {
launchModalForm(
"{% url 'part-image' part.id %}",

View File

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

View File

@ -2,6 +2,21 @@
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 {
float: right;
background-color: #777;

View File

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

View File

@ -17,3 +17,59 @@ function getPartList(filters={}, options={}) {
function getBomList(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'>
<h3>Stock Item Details</h3>
<p><i>{{ item.quantity }} &times {{ item.part.name }}</i></p>
<p>
<div class='btn-group'>
{% include "qr_button.html" %}
</div>
</p>
</div>
<div class='col-sm-6'>
<h3>
@ -23,9 +28,6 @@
<li><a href='#' id='stock-stocktake' title='Count stock'>Stocktake</a></li>
{% endif %}
<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>
</h3>
@ -145,7 +147,7 @@
});
});
$("#item-qr-code").click(function() {
$("#show-qr-code").click(function() {
launchModalForm("{% url 'stock-item-qr' item.id %}",
{
no_post: true,

View File

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

View File

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

View File

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

View File

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