diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py
index 15926715f6..6118f00fd0 100644
--- a/InvenTree/InvenTree/views.py
+++ b/InvenTree/InvenTree/views.py
@@ -328,11 +328,15 @@ class IndexView(TemplateView):
def get_context_data(self, **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
+ # 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
diff --git a/InvenTree/part/admin.py b/InvenTree/part/admin.py
index 56bf290739..11a5b2c28a 100644
--- a/InvenTree/part/admin.py
+++ b/InvenTree/part/admin.py
@@ -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)
diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py
index 27a5b24894..9db2bf102f 100644
--- a/InvenTree/part/api.py
+++ b/InvenTree/part/api.py
@@ -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\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\d+)/', PartDetail.as_view(), name='api-part-detail'),
diff --git a/InvenTree/part/migrations/0016_partstar.py b/InvenTree/part/migrations/0016_partstar.py
new file mode 100644
index 0000000000..baa5c83d5b
--- /dev/null
+++ b/InvenTree/part/migrations/0016_partstar.py
@@ -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)),
+ ],
+ ),
+ ]
diff --git a/InvenTree/part/migrations/0017_auto_20190505_0848.py b/InvenTree/part/migrations/0017_auto_20190505_0848.py
new file mode 100644
index 0000000000..90162132f9
--- /dev/null
+++ b/InvenTree/part/migrations/0017_auto_20190505_0848.py
@@ -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')},
+ ),
+ ]
diff --git a/InvenTree/part/models.py b/InvenTree/part/models.py
index d406382856..e5497faa4c 100644
--- a/InvenTree/part/models.py
+++ b/InvenTree/part/models.py
@@ -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
diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py
index e5c23b730a..847b3bb1a8 100644
--- a/InvenTree/part/serializers.py
+++ b/InvenTree/part/serializers.py
@@ -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 """
diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html
index 789a40b166..a99839d34a 100644
--- a/InvenTree/part/templates/part/detail.html
+++ b/InvenTree/part/templates/part/detail.html
@@ -23,7 +23,6 @@
{% else %}
Activate
{% endif %}
- Show QR Code
@@ -126,15 +125,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(
diff --git a/InvenTree/part/templates/part/part_app_base.html b/InvenTree/part/templates/part/part_app_base.html
index 30c9b30b6d..8c034dfa6e 100644
--- a/InvenTree/part/templates/part/part_app_base.html
+++ b/InvenTree/part/templates/part/part_app_base.html
@@ -1,5 +1,7 @@
{% extends "base.html" %}
+{% load static %}
+
{% block sidenav %}
{% endblock %}
@@ -14,6 +16,11 @@
{% endblock %}
+{% block js_load %}
+{{ block.super }}
+
+{% endblock %}
+
{% block js_ready %}
{{ block.super }}
loadTree("{% url 'api-part-tree' %}",
diff --git a/InvenTree/part/templates/part/part_base.html b/InvenTree/part/templates/part/part_base.html
index de1ab2c6e0..5dc7add027 100644
--- a/InvenTree/part/templates/part/part_base.html
+++ b/InvenTree/part/templates/part/part_base.html
@@ -21,20 +21,34 @@
{% endif %}/>
-
{{ part.name }}{% if part.active == False %} - INACTIVE{% endif %}
+
+ {{ part.name }}
+
{{ part.description }}
- {% if part.IPN %}
-
- IPN |
- {{ part.IPN }} |
-
- {% endif %}
- {% if part.URL %}
-
- URL |
- {{ part.URL }} |
-
- {% endif %}
+
+
+ {% include "qr_button.html" %}
+
+
+
+
+ {% if part.IPN %}
+
+ IPN |
+ {{ part.IPN }} |
+
+ {% endif %}
+ {% if part.URL %}
+
+ URL |
+ {{ part.URL }} |
+
+ {% endif %}
+
+
+
@@ -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 %}",
diff --git a/InvenTree/part/views.py b/InvenTree/part/views.py
index 6ba810b67d..c97808888e 100644
--- a/InvenTree/part/views.py
+++ b/InvenTree/part/views.py
@@ -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
diff --git a/InvenTree/static/css/inventree.css b/InvenTree/static/css/inventree.css
index 98a4e04de4..3a9c1be184 100644
--- a/InvenTree/static/css/inventree.css
+++ b/InvenTree/static/css/inventree.css
@@ -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;
diff --git a/InvenTree/static/script/inventree/api.js b/InvenTree/static/script/inventree/api.js
index 530e816f68..27feec8b79 100644
--- a/InvenTree/static/script/inventree/api.js
+++ b/InvenTree/static/script/inventree/api.js
@@ -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);
}
diff --git a/InvenTree/static/script/inventree/part.js b/InvenTree/static/script/inventree/part.js
index 053355113b..7682d5a6c1 100644
--- a/InvenTree/static/script/inventree/part.js
+++ b/InvenTree/static/script/inventree/part.js
@@ -16,4 +16,60 @@ 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');
+ },
+ }
+ );
+ }
+ },
+ }
+ );
}
\ No newline at end of file
diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html
index ca38ea29ef..daea4ddf24 100644
--- a/InvenTree/stock/templates/stock/item.html
+++ b/InvenTree/stock/templates/stock/item.html
@@ -6,6 +6,11 @@
Stock Item Details
{{ item.quantity }} × {{ item.part.name }}
+
+
+ {% include "qr_button.html" %}
+
+
@@ -145,7 +147,7 @@
});
});
- $("#item-qr-code").click(function() {
+ $("#show-qr-code").click(function() {
launchModalForm("{% url 'stock-item-qr' item.id %}",
{
no_post: true,
diff --git a/InvenTree/stock/templates/stock/location.html b/InvenTree/stock/templates/stock/location.html
index 20769669a2..fbb46b6e18 100644
--- a/InvenTree/stock/templates/stock/location.html
+++ b/InvenTree/stock/templates/stock/location.html
@@ -7,6 +7,11 @@
{% if location %}
{{ location.name }}
{{ location.description }}
+
+
+ {% include "qr_button.html" %}
+
+
{% else %}
Stock
All stock items
@@ -23,8 +28,6 @@
{% 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,
diff --git a/InvenTree/templates/InvenTree/index.html b/InvenTree/templates/InvenTree/index.html
index 91cac4c18c..12019ca7b0 100644
--- a/InvenTree/templates/InvenTree/index.html
+++ b/InvenTree/templates/InvenTree/index.html
@@ -3,6 +3,8 @@
{% block content %}
InvenTree
+{% 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 %}
\ No newline at end of file
diff --git a/InvenTree/templates/InvenTree/parts_to_build.html b/InvenTree/templates/InvenTree/parts_to_build.html
index e9c1dcdee1..df9199853b 100644
--- a/InvenTree/templates/InvenTree/parts_to_build.html
+++ b/InvenTree/templates/InvenTree/parts_to_build.html
@@ -1,5 +1,6 @@
{% extends "collapse.html" %}
{% block collapse_title %}
+
Parts to Build{{ to_build | length }}
{% endblock %}
diff --git a/InvenTree/templates/InvenTree/parts_to_order.html b/InvenTree/templates/InvenTree/parts_to_order.html
index b68eda7e14..c9b18606d0 100644
--- a/InvenTree/templates/InvenTree/parts_to_order.html
+++ b/InvenTree/templates/InvenTree/parts_to_order.html
@@ -1,5 +1,6 @@
{% extends "collapse.html" %}
{% block collapse_title %}
+
Parts to Order{{ to_order | length }}
{% endblock %}
diff --git a/InvenTree/templates/InvenTree/starred_parts.html b/InvenTree/templates/InvenTree/starred_parts.html
new file mode 100644
index 0000000000..eebc251666
--- /dev/null
+++ b/InvenTree/templates/InvenTree/starred_parts.html
@@ -0,0 +1,15 @@
+{% extends "collapse.html" %}
+{% block collapse_title %}
+
+Starred Parts{{ starred | length }}
+{% 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 %}
\ No newline at end of file
diff --git a/InvenTree/templates/qr_button.html b/InvenTree/templates/qr_button.html
new file mode 100644
index 0000000000..7aafd834bc
--- /dev/null
+++ b/InvenTree/templates/qr_button.html
@@ -0,0 +1 @@
+
\ No newline at end of file