Merge branch 'master' into part-import

This commit is contained in:
Matthias Mair 2021-06-26 23:58:41 +02:00 committed by GitHub
commit e760aa172a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
77 changed files with 10989 additions and 8160 deletions

49
.github/workflows/python.yaml vendored Normal file
View File

@ -0,0 +1,49 @@
# Run python library tests whenever code is pushed to master
name: Python Bindings
on:
push:
branches:
- master
pull_request:
branches-ignore:
- l10*
jobs:
python:
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_NAME: './test_db.sqlite'
INVENTREE_DB_ENGINE: 'sqlite3'
INVENTREE_DEBUG: info
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
steps:
- name: Checkout Code
uses: actions/checkout@v2
- name: Install InvenTree
run: |
sudo apt-get update
sudo apt-get install python3-dev python3-pip python3-venv
pip3 install invoke
invoke install
invoke migrate
- name: Download Python Code
run: |
git clone --depth 1 https://github.com/inventree/inventree-python ./inventree-python
- name: Start Server
run: |
invoke import-records -f ./inventree-python/test/test_data.json
invoke server -a 127.0.0.1:8000 &
sleep 60
- name: Run Tests
run: |
cd inventree-python
invoke test

3
.gitignore vendored
View File

@ -35,6 +35,9 @@ local_settings.py
*.backup
*.old
# Files used for testing
dummy_image.*
# Sphinx files
docs/_build

View File

@ -73,22 +73,50 @@ class InvenTreeAPITestCase(APITestCase):
ruleset.save()
break
def get(self, url, data={}, code=200):
def get(self, url, data={}, expected_code=200):
"""
Issue a GET request
"""
response = self.client.get(url, data, format='json')
self.assertEqual(response.status_code, code)
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
return response
def post(self, url, data):
def post(self, url, data, expected_code=None):
"""
Issue a POST request
"""
response = self.client.post(url, data=data, format='json')
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
return response
def delete(self, url, expected_code=None):
"""
Issue a DELETE request
"""
response = self.client.delete(url)
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
return response
def patch(self, url, data, files=None, expected_code=None):
"""
Issue a PATCH request
"""
response = self.client.patch(url, data=data, files=files, format='json')
if expected_code is not None:
self.assertEqual(response.status_code, expected_code)
return response

View File

@ -12,7 +12,7 @@ def isInTestMode():
return False
def canAppAccessDatabase():
def canAppAccessDatabase(allow_test=False):
"""
Returns True if the apps.py file can access database records.
@ -33,11 +33,16 @@ def canAppAccessDatabase():
'createsuperuser',
'wait_for_db',
'prerender',
'rebuild',
'collectstatic',
'makemessages',
'compilemessages',
]
if not allow_test:
# Override for testing mode?
excluded_commands.append('test')
for cmd in excluded_commands:
if cmd in sys.argv:
return False

View File

@ -6,12 +6,15 @@ Serializers used in various InvenTree apps
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from rest_framework import serializers
import os
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError as DjangoValidationError
from rest_framework import serializers
from rest_framework.fields import empty
from rest_framework.exceptions import ValidationError
class UserSerializer(serializers.ModelSerializer):
@ -39,18 +42,34 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
but also ensures that the underlying model class data are checked on validation.
"""
def validate(self, data):
def run_validation(self, data=empty):
""" Perform serializer validation.
In addition to running validators on the serializer fields,
this class ensures that the underlying model is also validated.
"""
# Run any native validation checks first (may throw an ValidationError)
data = super(serializers.ModelSerializer, self).validate(data)
# Run any native validation checks first (may raise a ValidationError)
data = super().run_validation(data)
# Now ensure the underlying model is correct
if not hasattr(self, 'instance') or self.instance is None:
# No instance exists (we are creating a new one)
instance = self.Meta.model(**data)
instance.clean()
else:
# Instance already exists (we are updating!)
instance = self.instance
# Update instance fields
for attr, value in data.items():
setattr(instance, attr, value)
# Run a 'full_clean' on the model.
# Note that by default, DRF does *not* perform full model validation!
try:
instance.full_clean()
except (ValidationError, DjangoValidationError) as exc:
raise ValidationError(detail=serializers.as_serializer_error(exc))
return data
@ -82,3 +101,17 @@ class InvenTreeAttachmentSerializerField(serializers.FileField):
return None
return os.path.join(str(settings.MEDIA_URL), str(value))
class InvenTreeImageSerializerField(serializers.ImageField):
"""
Custom image serializer.
On upload, validate that the file is a valid image file
"""
def to_representation(self, value):
if not value:
return None
return os.path.join(str(settings.MEDIA_URL), str(value))

View File

@ -58,7 +58,7 @@ function inventreeFormDataUpload(url, data, options={}) {
xhr.setRequestHeader('X-CSRFToken', csrftoken);
},
url: url,
method: 'POST',
method: options.method || 'POST',
data: data,
processData: false,
contentType: false,

View File

@ -219,6 +219,7 @@ function enableDragAndDrop(element, url, options) {
data - Other form data to upload
success - Callback function in case of success
error - Callback function in case of error
method - HTTP method
*/
data = options.data || {};
@ -254,7 +255,8 @@ function enableDragAndDrop(element, url, options) {
if (options.error) {
options.error(xhr, status, error);
}
}
},
method: options.method || 'POST',
}
);
} else {

View File

@ -4,9 +4,10 @@ Provides system status functionality checks.
# -*- coding: utf-8 -*-
from django.utils.translation import ugettext_lazy as _
from django.utils import timezone
import logging
from datetime import datetime, timedelta
from datetime import timedelta
from django_q.models import Success
from django_q.monitor import Stat
@ -34,7 +35,7 @@ def is_worker_running(**kwargs):
Check to see if we have a result within the last 20 minutes
"""
now = datetime.now()
now = timezone.now()
past = now - timedelta(minutes=20)
results = Success.objects.filter(
@ -60,21 +61,21 @@ def is_email_configured():
# Display warning unless in test mode
if not settings.TESTING:
logger.warning("EMAIL_HOST is not configured")
logger.debug("EMAIL_HOST is not configured")
if not settings.EMAIL_HOST_USER:
configured = False
# Display warning unless in test mode
if not settings.TESTING:
logger.warning("EMAIL_HOST_USER is not configured")
logger.debug("EMAIL_HOST_USER is not configured")
if not settings.EMAIL_HOST_PASSWORD:
configured = False
# Display warning unless in test mode
if not settings.TESTING:
logger.warning("EMAIL_HOST_PASSWORD is not configured")
logger.debug("EMAIL_HOST_PASSWORD is not configured")
return configured

View File

@ -28,7 +28,7 @@ def schedule_task(taskname, **kwargs):
try:
from django_q.models import Schedule
except (AppRegistryNotReady):
logger.warning("Could not start background tasks - App registry not ready")
logger.info("Could not start background tasks - App registry not ready")
return
try:
@ -80,7 +80,7 @@ def heartbeat():
try:
from django_q.models import Success
logger.warning("Could not perform heartbeat task - App registry not ready")
logger.info("Could not perform heartbeat task - App registry not ready")
except AppRegistryNotReady:
return
@ -105,7 +105,7 @@ def delete_successful_tasks():
try:
from django_q.models import Success
except AppRegistryNotReady:
logger.warning("Could not perform 'delete_successful_tasks' - App registry not ready")
logger.info("Could not perform 'delete_successful_tasks' - App registry not ready")
return
threshold = datetime.now() - timedelta(days=30)
@ -126,6 +126,7 @@ def check_for_updates():
import common.models
except AppRegistryNotReady:
# Apps not yet loaded!
logger.info("Could not perform 'check_for_updates' - App registry not ready")
return
response = requests.get('https://api.github.com/repos/inventree/inventree/releases/latest')
@ -172,6 +173,7 @@ def update_exchange_rates():
from django.conf import settings
except AppRegistryNotReady:
# Apps not yet loaded!
logger.info("Could not perform 'update_exchange_rates' - App registry not ready")
return
except:
# Other error?

View File

@ -77,7 +77,7 @@ class APITests(InvenTreeAPITestCase):
self.assertIn('version', data)
self.assertIn('instance', data)
self.assertEquals('InvenTree', data['server'])
self.assertEqual('InvenTree', data['server'])
def test_role_view(self):
"""

View File

@ -10,19 +10,25 @@ import common.models
INVENTREE_SW_VERSION = "0.2.4 pre"
INVENTREE_API_VERSION = 6
"""
Increment thi API version number whenever there is a significant change to the API that any clients need to know about
v3 -> 2021-05-22:
- The updated StockItem "history tracking" now uses a different interface
v6 -> 2021-06-23
- Part and Company images can now be directly uploaded via the REST API
v5 -> 2021-06-21
- Adds API interface for manufacturer part parameters
v4 -> 2021-06-01
- BOM items can now accept "variant stock" to be assigned against them
- Many slight API tweaks were needed to get this to work properly!
"""
v3 -> 2021-05-22:
- The updated StockItem "history tracking" now uses a different interface
INVENTREE_API_VERSION = 4
"""
def inventreeInstanceName():

View File

@ -337,7 +337,7 @@ class AjaxMixin(InvenTreeRoleMixin):
# Do nothing by default
pass
def renderJsonResponse(self, request, form=None, data={}, context=None):
def renderJsonResponse(self, request, form=None, data=None, context=None):
""" Render a JSON response based on specific class context.
Args:
@ -349,6 +349,9 @@ class AjaxMixin(InvenTreeRoleMixin):
Returns:
JSON response object
"""
# a empty dict as default can be dangerous - set it here if empty
if not data:
data = {}
if not request.is_ajax():
return HttpResponseRedirect('/')

View File

@ -40,6 +40,7 @@ def assign_bom_items(apps, schema_editor):
except BomItem.DoesNotExist:
pass
if count_total > 0:
print(f"Assigned BomItem for {count_valid}/{count_total} entries")

View File

@ -212,6 +212,13 @@ class InvenTreeSetting(models.Model):
'validator': bool,
},
'PART_SHOW_PRICE_IN_FORMS': {
'name': _('Show Price in Forms'),
'description': _('Display part price in some forms'),
'default': True,
'validator': bool,
},
'PART_INTERNAL_PRICE': {
'name': _('Internal Prices'),
'description': _('Enable internal prices for parts'),

View File

@ -11,6 +11,7 @@ import import_export.widgets as widgets
from .models import Company
from .models import SupplierPart
from .models import SupplierPriceBreak
from .models import ManufacturerPart, ManufacturerPartParameter
from part.models import Part
@ -71,6 +72,92 @@ class SupplierPartAdmin(ImportExportModelAdmin):
]
class ManufacturerPartResource(ModelResource):
"""
Class for managing ManufacturerPart data import/export
"""
part = Field(attribute='part', widget=widgets.ForeignKeyWidget(Part))
part_name = Field(attribute='part__full_name', readonly=True)
manufacturer = Field(attribute='manufacturer', widget=widgets.ForeignKeyWidget(Company))
manufacturer_name = Field(attribute='manufacturer__name', readonly=True)
class Meta:
model = ManufacturerPart
skip_unchanged = True
report_skipped = True
clean_model_instances = True
class ManufacturerPartParameterInline(admin.TabularInline):
"""
Inline for editing ManufacturerPartParameter objects,
directly from the ManufacturerPart admin view.
"""
model = ManufacturerPartParameter
class SupplierPartInline(admin.TabularInline):
"""
Inline for the SupplierPart model
"""
model = SupplierPart
class ManufacturerPartAdmin(ImportExportModelAdmin):
"""
Admin class for ManufacturerPart model
"""
resource_class = ManufacturerPartResource
list_display = ('part', 'manufacturer', 'MPN')
search_fields = [
'manufacturer__name',
'part__name',
'MPN',
]
inlines = [
SupplierPartInline,
ManufacturerPartParameterInline,
]
class ManufacturerPartParameterResource(ModelResource):
"""
Class for managing ManufacturerPartParameter data import/export
"""
class Meta:
model = ManufacturerPartParameter
skip_unchanged = True
report_skipped = True
clean_model_instance = True
class ManufacturerPartParameterAdmin(ImportExportModelAdmin):
"""
Admin class for ManufacturerPartParameter model
"""
resource_class = ManufacturerPartParameterResource
list_display = ('manufacturer_part', 'name', 'value')
search_fields = [
'manufacturer_part__manufacturer__name',
'name',
'value'
]
class SupplierPriceBreakResource(ModelResource):
""" Class for managing SupplierPriceBreak data import/export """
@ -103,3 +190,6 @@ class SupplierPriceBreakAdmin(ImportExportModelAdmin):
admin.site.register(Company, CompanyAdmin)
admin.site.register(SupplierPart, SupplierPartAdmin)
admin.site.register(SupplierPriceBreak, SupplierPriceBreakAdmin)
admin.site.register(ManufacturerPart, ManufacturerPartAdmin)
admin.site.register(ManufacturerPartParameter, ManufacturerPartParameterAdmin)

View File

@ -15,11 +15,11 @@ from django.db.models import Q
from InvenTree.helpers import str2bool
from .models import Company
from .models import ManufacturerPart
from .models import ManufacturerPart, ManufacturerPartParameter
from .models import SupplierPart, SupplierPriceBreak
from .serializers import CompanySerializer
from .serializers import ManufacturerPartSerializer
from .serializers import ManufacturerPartSerializer, ManufacturerPartParameterSerializer
from .serializers import SupplierPartSerializer, SupplierPriceBreakSerializer
@ -175,6 +175,86 @@ class ManufacturerPartDetail(generics.RetrieveUpdateDestroyAPIView):
serializer_class = ManufacturerPartSerializer
class ManufacturerPartParameterList(generics.ListCreateAPIView):
"""
API endpoint for list view of ManufacturerPartParamater model.
"""
queryset = ManufacturerPartParameter.objects.all()
serializer_class = ManufacturerPartParameterSerializer
def get_serializer(self, *args, **kwargs):
# Do we wish to include any extra detail?
try:
params = self.request.query_params
optional_fields = [
'manufacturer_part_detail',
]
for key in optional_fields:
kwargs[key] = str2bool(params.get(key, None))
except AttributeError:
pass
kwargs['context'] = self.get_serializer_context()
return self.serializer_class(*args, **kwargs)
def filter_queryset(self, queryset):
"""
Custom filtering for the queryset
"""
queryset = super().filter_queryset(queryset)
params = self.request.query_params
# Filter by manufacturer?
manufacturer = params.get('manufacturer', None)
if manufacturer is not None:
queryset = queryset.filter(manufacturer_part__manufacturer=manufacturer)
# Filter by part?
part = params.get('part', None)
if part is not None:
queryset = queryset.filter(manufacturer_part__part=part)
return queryset
filter_backends = [
DjangoFilterBackend,
filters.SearchFilter,
filters.OrderingFilter,
]
filter_fields = [
'name',
'value',
'units',
'manufacturer_part',
]
search_fields = [
'name',
'value',
'units',
]
class ManufacturerPartParameterDetail(generics.RetrieveUpdateDestroyAPIView):
"""
API endpoint for detail view of ManufacturerPartParameter model
"""
queryset = ManufacturerPartParameter.objects.all()
serializer_class = ManufacturerPartParameterSerializer
class SupplierPartList(generics.ListCreateAPIView):
""" API endpoint for list view of SupplierPart object
@ -249,7 +329,7 @@ class SupplierPartList(generics.ListCreateAPIView):
params = self.request.query_params
kwargs['part_detail'] = str2bool(params.get('part_detail', None))
kwargs['supplier_detail'] = str2bool(params.get('supplier_detail', None))
kwargs['manufacturer_detail'] = str2bool(self.params.get('manufacturer_detail', None))
kwargs['manufacturer_detail'] = str2bool(params.get('manufacturer_detail', None))
kwargs['pretty'] = str2bool(params.get('pretty', None))
except AttributeError:
pass
@ -316,6 +396,13 @@ class SupplierPriceBreakList(generics.ListCreateAPIView):
manufacturer_part_api_urls = [
url(r'^parameter/', include([
url(r'^(?P<pk>\d+)/', ManufacturerPartParameterDetail.as_view(), name='api-manufacturer-part-parameter-detail'),
# Catch anything else
url(r'^.*$', ManufacturerPartParameterList.as_view(), name='api-manufacturer-part-parameter-list'),
])),
url(r'^(?P<pk>\d+)/?', ManufacturerPartDetail.as_view(), name='api-manufacturer-part-detail'),
# Catch anything else

View File

@ -44,8 +44,6 @@ class CompanyConfig(AppConfig):
company.image.render_variations(replace=False)
except FileNotFoundError:
logger.warning(f"Image file '{company.image}' missing")
company.image = None
company.save()
except UnidentifiedImageError:
logger.warning(f"Image file '{company.image}' is invalid")
except (OperationalError, ProgrammingError):

View File

@ -16,7 +16,7 @@ from djmoney.forms.fields import MoneyField
from common.settings import currency_code_default
from .models import Company
from .models import Company, ManufacturerPartParameter
from .models import ManufacturerPart
from .models import SupplierPart
from .models import SupplierPriceBreak
@ -105,6 +105,21 @@ class EditManufacturerPartForm(HelperForm):
]
class EditManufacturerPartParameterForm(HelperForm):
"""
Form for creating / editing a ManufacturerPartParameter object
"""
class Meta:
model = ManufacturerPartParameter
fields = [
'manufacturer_part',
'name',
'value',
'units',
]
class EditSupplierPartForm(HelperForm):
""" Form for editing a SupplierPart object """

View File

@ -71,6 +71,7 @@ def migrate_currencies(apps, schema_editor):
count += 1
if count > 0:
print(f"Updated {count} SupplierPriceBreak rows")
def reverse_currencies(apps, schema_editor):

View File

@ -0,0 +1,27 @@
# Generated by Django 3.2.4 on 2021-06-20 07:48
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('company', '0037_supplierpart_update_3'),
]
operations = [
migrations.CreateModel(
name='ManufacturerPartParameter',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Parameter name', max_length=500, verbose_name='Name')),
('value', models.CharField(help_text='Parameter value', max_length=500, verbose_name='Value')),
('units', models.CharField(blank=True, help_text='Parameter units', max_length=64, null=True, verbose_name='Units')),
('manufacturer_part', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parameters', to='company.manufacturerpart', verbose_name='Manufacturer Part')),
],
options={
'unique_together': {('manufacturer_part', 'name')},
},
),
]

View File

@ -371,6 +371,47 @@ class ManufacturerPart(models.Model):
return s
class ManufacturerPartParameter(models.Model):
"""
A ManufacturerPartParameter represents a key:value parameter for a MnaufacturerPart.
This is used to represent parmeters / properties for a particular manufacturer part.
Each parameter is a simple string (text) value.
"""
class Meta:
unique_together = ('manufacturer_part', 'name')
manufacturer_part = models.ForeignKey(
ManufacturerPart,
on_delete=models.CASCADE,
related_name='parameters',
verbose_name=_('Manufacturer Part'),
)
name = models.CharField(
max_length=500,
blank=False,
verbose_name=_('Name'),
help_text=_('Parameter name')
)
value = models.CharField(
max_length=500,
blank=False,
verbose_name=_('Value'),
help_text=_('Parameter value')
)
units = models.CharField(
max_length=64,
blank=True, null=True,
verbose_name=_('Units'),
help_text=_('Parameter units')
)
class SupplierPart(models.Model):
""" Represents a unique part as provided by a Supplier
Each SupplierPart is identified by a SKU (Supplier Part Number)

View File

@ -6,14 +6,15 @@ from rest_framework import serializers
from sql_util.utils import SubqueryCount
from .models import Company
from .models import ManufacturerPart
from .models import SupplierPart, SupplierPriceBreak
from InvenTree.serializers import InvenTreeModelSerializer
from InvenTree.serializers import InvenTreeImageSerializerField
from part.serializers import PartBriefSerializer
from .models import Company
from .models import ManufacturerPart, ManufacturerPartParameter
from .models import SupplierPart, SupplierPriceBreak
class CompanyBriefSerializer(InvenTreeModelSerializer):
""" Serializer for Company object (limited detail) """
@ -52,7 +53,7 @@ class CompanySerializer(InvenTreeModelSerializer):
url = serializers.CharField(source='get_absolute_url', read_only=True)
image = serializers.CharField(source='get_thumbnail_url', read_only=True)
image = InvenTreeImageSerializerField(required=False, allow_null=True)
parts_supplied = serializers.IntegerField(read_only=True)
parts_manufactured = serializers.IntegerField(read_only=True)
@ -124,6 +125,35 @@ class ManufacturerPartSerializer(InvenTreeModelSerializer):
]
class ManufacturerPartParameterSerializer(InvenTreeModelSerializer):
"""
Serializer for the ManufacturerPartParameter model
"""
manufacturer_part_detail = ManufacturerPartSerializer(source='manufacturer_part', many=False, read_only=True)
def __init__(self, *args, **kwargs):
man_detail = kwargs.pop('manufacturer_part_detail', False)
super(ManufacturerPartParameterSerializer, self).__init__(*args, **kwargs)
if not man_detail:
self.fields.pop('manufacturer_part_detail')
class Meta:
model = ManufacturerPartParameter
fields = [
'pk',
'manufacturer_part',
'manufacturer_part_detail',
'name',
'value',
'units',
]
class SupplierPartSerializer(InvenTreeModelSerializer):
""" Serializer for SupplierPart object """

View File

@ -139,13 +139,19 @@
enableDragAndDrop(
"#company-thumb",
"{% url 'company-image' company.id %}",
"{% url 'api-company-detail' company.id %}",
{
label: 'image',
method: 'PATCH',
success: function(data, status, xhr) {
if (data.image) {
$('#company-image').attr('src', data.image);
} else {
location.reload();
}
}
}
);
{% if company.image %}

View File

@ -7,7 +7,7 @@
{% endblock %}
{% block heading %}
{% trans "Supplier Parts" %}
{% trans "Suppliers" %}
{% endblock %}
{% block details %}
@ -30,9 +30,44 @@
{% endblock %}
{% block post_content_panels %}
<div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
<h4>{% trans "Parameters" %}</h4>
</div>
<div class='panel-content'>
<div id='parameter-toolbar'>
<div class='btn-group'>
<button class='btn btn-success' id='parameter-create'>
<span class='fas fa-plus-circle'></span> {% trans "New Parameter" %}
</button>
<div id='param-dropdown' class='btn-group'>
<!-- TODO -->
</div>
</div>
</div>
<table class='table table-striped table-condensed' id='parameter-table' data-toolbar='#parameter-toolbar'></table>
</div>
</div>
{% endblock %}
{% block js_ready %}
{{ block.super }}
$('#parameter-create').click(function() {
launchModalForm(
"{% url 'manufacturer-part-parameter-create' %}",
{
data: {
manufacturer_part: {{ part.id }},
}
}
);
});
$('#supplier-create').click(function () {
launchModalForm(
"{% url 'supplier-part-create' %}",
@ -84,6 +119,16 @@ loadSupplierPartTable(
}
);
loadManufacturerPartParameterTable(
"#parameter-table",
"{% url 'api-manufacturer-part-parameter-list' %}",
{
params: {
manufacturer_part: {{ part.id }},
}
}
);
linkButtonsToSelection($("#supplier-table"), ['#supplier-part-options'])
{% endblock %}

View File

@ -50,10 +50,15 @@ class CompanyTest(InvenTreeAPITestCase):
self.assertEqual(response.data['name'], 'ACME')
# Change the name of the company
# Note we should not have the correct permissions (yet)
data = response.data
data['name'] = 'ACMOO'
response = self.client.patch(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
response = self.client.patch(url, data, format='json', expected_code=400)
self.assignRole('company.change')
response = self.client.patch(url, data, format='json', expected_code=200)
self.assertEqual(response.data['name'], 'ACMOO')
def test_company_search(self):
@ -119,7 +124,9 @@ class ManufacturerTest(InvenTreeAPITestCase):
data = {
'MPN': 'MPN-TEST-123',
}
response = self.client.patch(url, data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['MPN'], 'MPN-TEST-123')

View File

@ -53,20 +53,25 @@ price_break_urls = [
url(r'^(?P<pk>\d+)/delete/', views.PriceBreakDelete.as_view(), name='price-break-delete'),
]
manufacturer_part_detail_urls = [
url(r'^edit/?', views.ManufacturerPartEdit.as_view(), name='manufacturer-part-edit'),
url(r'^suppliers/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-suppliers'),
url('^.*$', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-detail'),
]
manufacturer_part_urls = [
url(r'^new/?', views.ManufacturerPartCreate.as_view(), name='manufacturer-part-create'),
url(r'delete/', views.ManufacturerPartDelete.as_view(), name='manufacturer-part-delete'),
url(r'^delete/', views.ManufacturerPartDelete.as_view(), name='manufacturer-part-delete'),
url(r'^(?P<pk>\d+)/', include(manufacturer_part_detail_urls)),
# URLs for ManufacturerPartParameter views (create / edit / delete)
url(r'^parameter/', include([
url(r'^new/', views.ManufacturerPartParameterCreate.as_view(), name='manufacturer-part-parameter-create'),
url(r'^(?P<pk>\d)/', include([
url(r'^edit/', views.ManufacturerPartParameterEdit.as_view(), name='manufacturer-part-parameter-edit'),
url(r'^delete/', views.ManufacturerPartParameterDelete.as_view(), name='manufacturer-part-parameter-delete'),
])),
])),
url(r'^(?P<pk>\d+)/', include([
url(r'^edit/?', views.ManufacturerPartEdit.as_view(), name='manufacturer-part-edit'),
url(r'^suppliers/', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-suppliers'),
url('^.*$', views.ManufacturerPartDetail.as_view(template_name='company/manufacturer_part_suppliers.html'), name='manufacturer-part-detail'),
])),
]
supplier_part_detail_urls = [

View File

@ -23,14 +23,14 @@ from InvenTree.views import AjaxCreateView, AjaxUpdateView, AjaxDeleteView
from InvenTree.helpers import str2bool
from InvenTree.views import InvenTreeRoleMixin
from .models import Company
from .models import Company, ManufacturerPartParameter
from .models import ManufacturerPart
from .models import SupplierPart
from .models import SupplierPriceBreak
from part.models import Part
from .forms import EditCompanyForm
from .forms import EditCompanyForm, EditManufacturerPartParameterForm
from .forms import CompanyImageForm
from .forms import EditManufacturerPartForm
from .forms import EditSupplierPartForm
@ -504,6 +504,66 @@ class ManufacturerPartDelete(AjaxDeleteView):
return self.renderJsonResponse(self.request, data=data, form=self.get_form())
class ManufacturerPartParameterCreate(AjaxCreateView):
"""
View for creating a new ManufacturerPartParameter object
"""
model = ManufacturerPartParameter
form_class = EditManufacturerPartParameterForm
ajax_form_title = _('Add Manufacturer Part Parameter')
def get_form(self):
form = super().get_form()
# Hide the manufacturer_part field if specified
if form.initial.get('manufacturer_part', None):
form.fields['manufacturer_part'].widget = HiddenInput()
return form
def get_initial(self):
initials = super().get_initial().copy()
manufacturer_part = self.get_param('manufacturer_part')
if manufacturer_part:
try:
initials['manufacturer_part'] = ManufacturerPartParameter.objects.get(pk=manufacturer_part)
except (ValueError, ManufacturerPartParameter.DoesNotExist):
pass
return initials
class ManufacturerPartParameterEdit(AjaxUpdateView):
"""
View for editing a ManufacturerPartParameter object
"""
model = ManufacturerPartParameter
form_class = EditManufacturerPartParameterForm
ajax_form_title = _('Edit Manufacturer Part Parameter')
def get_form(self):
form = super().get_form()
form.fields['manufacturer_part'].widget = HiddenInput()
return form
class ManufacturerPartParameterDelete(AjaxDeleteView):
"""
View for deleting a ManufacturerPartParameter object
"""
model = ManufacturerPartParameter
class SupplierPartDetail(DetailView):
""" Detail view for SupplierPart """
model = SupplierPart
@ -563,6 +623,7 @@ class SupplierPartEdit(AjaxUpdateView):
supplier_part = self.get_object()
if supplier_part.manufacturer_part:
if supplier_part.manufacturer_part.manufacturer:
initials['manufacturer'] = supplier_part.manufacturer_part.manufacturer.id
initials['MPN'] = supplier_part.manufacturer_part.MPN

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -157,7 +157,7 @@ class POList(generics.ListCreateAPIView):
ordering = '-creation_date'
class PODetail(generics.RetrieveUpdateAPIView):
class PODetail(generics.RetrieveUpdateDestroyAPIView):
""" API endpoint for detail view of a PurchaseOrder object """
queryset = PurchaseOrder.objects.all()
@ -382,7 +382,7 @@ class SOList(generics.ListCreateAPIView):
ordering = '-creation_date'
class SODetail(generics.RetrieveUpdateAPIView):
class SODetail(generics.RetrieveUpdateDestroyAPIView):
"""
API endpoint for detail view of a SalesOrder object.
"""

View File

@ -92,8 +92,10 @@ class POSerializer(InvenTreeModelSerializer):
]
read_only_fields = [
'reference',
'status'
'issue_date',
'complete_date',
'creation_date',
]
@ -109,8 +111,9 @@ class POLineItemSerializer(InvenTreeModelSerializer):
self.fields.pop('part_detail')
self.fields.pop('supplier_part_detail')
quantity = serializers.FloatField()
received = serializers.FloatField()
# TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values
quantity = serializers.FloatField(default=1)
received = serializers.FloatField(default=0)
part_detail = PartBriefSerializer(source='get_base_part', many=False, read_only=True)
supplier_part_detail = SupplierPartSerializer(source='part', many=False, read_only=True)
@ -225,8 +228,9 @@ class SalesOrderSerializer(InvenTreeModelSerializer):
]
read_only_fields = [
'reference',
'status'
'status',
'creation_date',
'shipment_date',
]
@ -312,7 +316,9 @@ class SOLineItemSerializer(InvenTreeModelSerializer):
part_detail = PartBriefSerializer(source='part', many=False, read_only=True)
allocations = SalesOrderAllocationSerializer(many=True, read_only=True)
quantity = serializers.FloatField()
# TODO: Once https://github.com/inventree/InvenTree/issues/1687 is fixed, remove default values
quantity = serializers.FloatField(default=1)
allocated = serializers.FloatField(source='allocated_quantity', read_only=True)
fulfilled = serializers.FloatField(source='fulfilled_quantity', read_only=True)
sale_price_string = serializers.CharField(source='sale_price', read_only=True)

View File

@ -75,7 +75,7 @@ src="{% static 'img/blank_image.png' %}"
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Order Reference" %}</td>
<td>{{ order.reference }}{% include "clip.html"%}</td>
<td>{% settings_value 'PURCHASEORDER_REFERENCE_PREFIX' %}{{ order.reference }}{% include "clip.html"%}</td>
</tr>
<tr>
<td><span class='fas fa-info'></span></td>

View File

@ -4,6 +4,8 @@
{% load i18n %}
{% block form %}
{% default_currency as currency %}
{% settings_value 'PART_SHOW_PRICE_IN_FORMS' as show_price %}
<h4>
{% trans "Step 1 of 2 - Select Part Suppliers" %}
@ -49,7 +51,13 @@
<select class='select' id='id_supplier_part_{{ part.id }}' name="part-supplier-{{ part.id }}">
<option value=''>---------</option>
{% for supplier in part.supplier_parts.all %}
<option value="{{ supplier.id }}"{% if part.order_supplier == supplier.id %} selected="selected"{% endif %}>{{ supplier }}</option>
<option value="{{ supplier.id }}"{% if part.order_supplier == supplier.id %} selected="selected"{% endif %}>
{% if show_price %}
{% call_method supplier 'get_price' part.order_quantity as price %}
{% if price != None %}{% include "price.html" with price=price %}{% else %}{% trans 'No price' %}{% endif %} -
{% endif %}
{{ supplier }}
</option>
{% endfor %}
</select>
</div>

View File

@ -57,8 +57,6 @@ $("#attachment-table").on('click', '.attachment-delete-button', function() {
var url = `/order/purchase-order/attachment/${button.attr('pk')}/delete/`;
console.log("url: " + url);
launchModalForm(url, {
reload: true,
});

View File

@ -77,7 +77,7 @@ src="{% static 'img/blank_image.png' %}"
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Order Reference" %}</td>
<td>{{ order.reference }}{% include "clip.html"%}</td>
<td>{% settings_value 'SALESORDER_REFERENCE_PREFIX' %}{{ order.reference }}{% include "clip.html"%}</td>
</tr>
<tr>
<td><span class='fas fa-info'></span></td>

View File

@ -110,6 +110,96 @@ class PurchaseOrderTest(OrderTest):
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_po_operations(self):
"""
Test that we can create / edit and delete a PurchaseOrder via the API
"""
n = PurchaseOrder.objects.count()
url = reverse('api-po-list')
# Initially we do not have "add" permission for the PurchaseOrder model,
# so this POST request should return 403
response = self.post(
url,
{
'supplier': 1,
'reference': '123456789-xyz',
'description': 'PO created via the API',
},
expected_code=403
)
# And no new PurchaseOrder objects should have been created
self.assertEqual(PurchaseOrder.objects.count(), n)
# Ok, now let's give this user the correct permission
self.assignRole('purchase_order.add')
# Initially we do not have "add" permission for the PurchaseOrder model,
# so this POST request should return 403
response = self.post(
url,
{
'supplier': 1,
'reference': '123456789-xyz',
'description': 'PO created via the API',
},
expected_code=201
)
self.assertEqual(PurchaseOrder.objects.count(), n + 1)
pk = response.data['pk']
# Try to create a PO with identical reference (should fail!)
response = self.post(
url,
{
'supplier': 1,
'reference': '123456789-xyz',
'description': 'A different description',
},
expected_code=400
)
self.assertEqual(PurchaseOrder.objects.count(), n + 1)
url = reverse('api-po-detail', kwargs={'pk': pk})
# Get detail info!
response = self.get(url)
self.assertEqual(response.data['pk'], pk)
self.assertEqual(response.data['reference'], '123456789-xyz')
# Try to alter (edit) the PurchaseOrder
response = self.patch(
url,
{
'reference': '12345-abc',
},
expected_code=200
)
# Reference should have changed
self.assertEqual(response.data['reference'], '12345-abc')
# Now, let's try to delete it!
# Initially, we do *not* have the required permission!
response = self.delete(url, expected_code=403)
# Now, add the "delete" permission!
self.assignRole("purchase_order.delete")
response = self.delete(url, expected_code=204)
# Number of PurchaseOrder objects should have decreased
self.assertEqual(PurchaseOrder.objects.count(), n)
# And if we try to access the detail view again, it has gone
response = self.get(url, expected_code=404)
class SalesOrderTest(OrderTest):
"""
@ -158,8 +248,6 @@ class SalesOrderTest(OrderTest):
response = self.get(url)
self.assertEqual(response.status_code, 200)
data = response.data
self.assertEqual(data['pk'], 1)
@ -168,6 +256,87 @@ class SalesOrderTest(OrderTest):
url = reverse('api-so-attachment-list')
response = self.get(url)
self.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_so_operations(self):
"""
Test that we can create / edit and delete a SalesOrder via the API
"""
n = SalesOrder.objects.count()
url = reverse('api-so-list')
# Initially we do not have "add" permission for the SalesOrder model,
# so this POST request should return 403 (denied)
response = self.post(
url,
{
'customer': 4,
'reference': '12345',
'description': 'Sales order',
},
expected_code=403,
)
self.assignRole('sales_order.add')
# Now we should be able to create a SalesOrder via the API
response = self.post(
url,
{
'customer': 4,
'reference': '12345',
'description': 'Sales order',
},
expected_code=201
)
# Check that the new order has been created
self.assertEqual(SalesOrder.objects.count(), n + 1)
# Grab the PK for the newly created SalesOrder
pk = response.data['pk']
# Try to create a SO with identical reference (should fail)
response = self.post(
url,
{
'customer': 4,
'reference': '12345',
'description': 'Another sales order',
},
expected_code=400
)
url = reverse('api-so-detail', kwargs={'pk': pk})
# Extract detail info for the SalesOrder
response = self.get(url)
self.assertEqual(response.data['reference'], '12345')
# Try to alter (edit) the SalesOrder
response = self.patch(
url,
{
'reference': '12345-a',
},
expected_code=200
)
# Reference should have changed
self.assertEqual(response.data['reference'], '12345-a')
# Now, let's try to delete this SalesOrder
# Initially, we do not have the required permission
response = self.delete(url, expected_code=403)
self.assignRole('sales_order.delete')
response = self.delete(url, expected_code=204)
# Check that the number of sales orders has decreased
self.assertEqual(SalesOrder.objects.count(), n)
# And the resource should no longer be available
response = self.get(url, expected_code=404)

View File

@ -1010,6 +1010,15 @@ class OrderParts(AjaxView):
return ctx
def get_data(self):
""" enrich respone json data """
data = super().get_data()
# if in selection-phase, add a button to update the prices
if getattr(self, 'form_step', 'select_parts') == 'select_parts':
data['buttons'] = [{'name': 'update_price', 'title': _('Update prices')}] # set buttons
data['hideErrorMessage'] = '1' # hide the error message
return data
def get_suppliers(self):
""" Calculates a list of suppliers which the user will need to create POs for.
This is calculated AFTER the user finishes selecting the parts to order.
@ -1244,9 +1253,10 @@ class OrderParts(AjaxView):
valid = False
if form_step == 'select_parts':
# No errors? Proceed to PO selection form
if part_errors is False:
# No errors? and the price-update button was not used to submit? Proceed to PO selection form
if part_errors is False and 'act-btn_update_price' not in request.POST:
self.ajax_template_name = 'order/order_wizard/select_pos.html'
self.form_step = 'select_purchase_orders' # set step (important for get_data)
else:
self.ajax_template_name = 'order/order_wizard/select_parts.html'

View File

@ -111,6 +111,13 @@ class PartCategoryResource(ModelResource):
PartCategory.objects.rebuild()
class PartCategoryInline(admin.TabularInline):
"""
Inline for PartCategory model
"""
model = PartCategory
class PartCategoryAdmin(ImportExportModelAdmin):
resource_class = PartCategoryResource
@ -119,6 +126,10 @@ class PartCategoryAdmin(ImportExportModelAdmin):
search_fields = ('name', 'description')
inlines = [
PartCategoryInline,
]
class PartRelatedAdmin(admin.ModelAdmin):
''' Class to manage PartRelated objects '''

View File

@ -706,6 +706,7 @@ class PartList(generics.ListCreateAPIView):
'creation_date',
'IPN',
'in_stock',
'category',
]
# Default ordering

View File

@ -39,7 +39,8 @@ class PartConfig(AppConfig):
logger.debug("InvenTree: Checking Part image thumbnails")
try:
for part in Part.objects.all():
# Only check parts which have images
for part in Part.objects.exclude(image=None):
if part.image:
url = part.image.thumbnail.name
loc = os.path.join(settings.MEDIA_ROOT, url)
@ -50,8 +51,7 @@ class PartConfig(AppConfig):
part.image.render_variations(replace=False)
except FileNotFoundError:
logger.warning(f"Image file '{part.image}' missing")
part.image = None
part.save()
pass
except UnidentifiedImageError:
logger.warning(f"Image file '{part.image}' is invalid")
except (OperationalError, ProgrammingError):

View File

@ -6,7 +6,7 @@
name: 'M2x4 LPHS'
description: 'M2x4 low profile head screw'
category: 8
link: www.acme.com/parts/m2x4lphs
link: http://www.acme.com/parts/m2x4lphs
tree_id: 0
purchaseable: True
level: 0
@ -56,6 +56,7 @@
fields:
name: 'C_22N_0805'
description: '22nF capacitor in 0805 package'
purchaseable: true
category: 3
tree_id: 0
level: 0

View File

@ -71,6 +71,7 @@ def migrate_currencies(apps, schema_editor):
count += 1
if count > 0:
print(f"Updated {count} SupplierPriceBreak rows")
def reverse_currencies(apps, schema_editor):

View File

@ -0,0 +1,17 @@
# Generated by Django 3.2.4 on 2021-06-21 23:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('part', '0067_partinternalpricebreak'),
]
operations = [
migrations.AddConstraint(
model_name='part',
constraint=models.UniqueConstraint(fields=('name', 'IPN', 'revision'), name='unique_part'),
),
]

View File

@ -321,6 +321,9 @@ class Part(MPTTModel):
verbose_name = _("Part")
verbose_name_plural = _("Parts")
ordering = ['name', ]
constraints = [
UniqueConstraint(fields=['name', 'IPN', 'revision'], name='unique_part')
]
class MPTTMeta:
# For legacy reasons the 'variant_of' field is used to indicate the MPTT parent
@ -379,8 +382,7 @@ class Part(MPTTModel):
logger.info(f"Deleting unused image file '{previous.image}'")
previous.image.delete(save=False)
self.clean()
self.validate_unique()
self.full_clean()
super().save(*args, **kwargs)
@ -643,23 +645,6 @@ class Part(MPTTModel):
'IPN': _('Duplicate IPN not allowed in part settings'),
})
# Part name uniqueness should be case insensitive
try:
parts = Part.objects.exclude(id=self.id).filter(
name__iexact=self.name,
IPN__iexact=self.IPN,
revision__iexact=self.revision)
if parts.exists():
msg = _("Part must be unique for name, IPN and revision")
raise ValidationError({
"name": msg,
"IPN": msg,
"revision": msg,
})
except Part.DoesNotExist:
pass
def clean(self):
"""
Perform cleaning operations for the Part model
@ -1494,16 +1479,17 @@ class Part(MPTTModel):
return True
def get_price_info(self, quantity=1, buy=True, bom=True):
def get_price_info(self, quantity=1, buy=True, bom=True, internal=False):
""" Return a simplified pricing string for this part
Args:
quantity: Number of units to calculate price for
buy: Include supplier pricing (default = True)
bom: Include BOM pricing (default = True)
internal: Include internal pricing (default = False)
"""
price_range = self.get_price_range(quantity, buy, bom)
price_range = self.get_price_range(quantity, buy, bom, internal)
if price_range is None:
return None
@ -1591,9 +1577,10 @@ class Part(MPTTModel):
- Supplier price (if purchased from suppliers)
- BOM price (if built from other parts)
- Internal price (if set for the part)
Returns:
Minimum of the supplier price or BOM price. If no pricing available, returns None
Minimum of the supplier, BOM or internal price. If no pricing available, returns None
"""
# only get internal price if set and should be used
@ -2514,7 +2501,9 @@ class BomItem(models.Model):
def price_range(self):
""" Return the price-range for this BOM item. """
prange = self.sub_part.get_price_range(self.quantity)
# get internal price setting
use_internal = common.models.InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
prange = self.sub_part.get_price_range(self.quantity, intenal=use_internal)
if prange is None:
return prange

View File

@ -7,12 +7,15 @@ from decimal import Decimal
from django.db import models
from django.db.models import Q
from django.db.models.functions import Coalesce
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
InvenTreeModelSerializer)
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from rest_framework import serializers
from sql_util.utils import SubqueryCount, SubquerySum
from djmoney.contrib.django_rest_framework import MoneyField
from InvenTree.serializers import (InvenTreeAttachmentSerializerField,
InvenTreeImageSerializerField,
InvenTreeModelSerializer)
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
from stock.models import StockItem
from .models import (BomItem, Part, PartAttachment, PartCategory,
@ -300,7 +303,7 @@ class PartSerializer(InvenTreeModelSerializer):
stock_item_count = serializers.IntegerField(read_only=True)
suppliers = serializers.IntegerField(read_only=True)
image = serializers.CharField(source='get_image_url', read_only=True)
image = InvenTreeImageSerializerField(required=False, allow_null=True)
thumbnail = serializers.CharField(source='get_thumbnail_url', read_only=True)
starred = serializers.SerializerMethodField()

View File

@ -138,7 +138,7 @@
<hr>
<h4>{% trans 'Stock Pricing' %}<i class="fas fa-info-circle" title="Shows the purchase prices of stock for this part.
The part single price is the current purchase price for that supplier part."></i></h4>
{% if price_history|length > 1 %}
{% if price_history|length > 0 %}
<div style="max-width: 99%; min-height: 300px">
<canvas id="StockPriceChart"></canvas>
</div>

View File

@ -239,13 +239,21 @@
enableDragAndDrop(
'#part-thumb',
"{% url 'part-image-upload' part.id %}",
"{% url 'api-part-detail' part.id %}",
{
label: 'image',
method: 'PATCH',
success: function(data, status, xhr) {
// If image / thumbnail data present, live update
if (data.image) {
$('#part-image').attr('src', data.image);
} else {
// Otherwise, reload the page
location.reload();
}
}
}
);
$("#show-qr-code").click(function() {

View File

@ -5,6 +5,7 @@ over and above the built-in Django tags.
"""
import os
import sys
from django.utils.translation import ugettext_lazy as _
from django.conf import settings as djangosettings
@ -114,6 +115,14 @@ def inventree_title(*args, **kwargs):
return version.inventreeInstanceTitle()
@register.simple_tag()
def python_version(*args, **kwargs):
"""
Return the current python version
"""
return sys.version.split(' ')[0]
@register.simple_tag()
def inventree_version(*args, **kwargs):
""" Return InvenTree version string """

View File

@ -1,14 +1,19 @@
from rest_framework import status
# -*- coding: utf-8 -*-
import PIL
from django.urls import reverse
from part.models import Part
from stock.models import StockItem
from company.models import Company
from rest_framework import status
from rest_framework.test import APIClient
from InvenTree.api_tester import InvenTreeAPITestCase
from InvenTree.status_codes import StockStatus
from part.models import Part, PartCategory
from stock.models import StockItem
from company.models import Company
class PartAPITest(InvenTreeAPITestCase):
"""
@ -230,6 +235,18 @@ class PartAPITest(InvenTreeAPITestCase):
response = self.client.get(url, data={'part': 10004})
self.assertEqual(len(response.data), 7)
# Try to post a new object (missing description)
response = self.client.post(
url,
data={
'part': 10000,
'test_name': 'My very first test',
'required': False,
}
)
self.assertEqual(response.status_code, 400)
# Try to post a new object (should succeed)
response = self.client.post(
url,
@ -237,6 +254,7 @@ class PartAPITest(InvenTreeAPITestCase):
'part': 10000,
'test_name': 'New Test',
'required': True,
'description': 'a test description'
},
format='json',
)
@ -248,7 +266,8 @@ class PartAPITest(InvenTreeAPITestCase):
url,
data={
'part': 10004,
'test_name': " newtest"
'test_name': " newtest",
'description': 'dafsdf',
},
format='json',
)
@ -293,6 +312,239 @@ class PartAPITest(InvenTreeAPITestCase):
self.assertEqual(len(data['results']), n)
class PartDetailTests(InvenTreeAPITestCase):
"""
Test that we can create / edit / delete Part objects via the API
"""
fixtures = [
'category',
'part',
'location',
'bom',
'test_templates',
]
roles = [
'part.change',
'part.add',
'part.delete',
'part_category.change',
'part_category.add',
]
def setUp(self):
super().setUp()
def test_part_operations(self):
n = Part.objects.count()
# Create a part
response = self.client.post(
reverse('api-part-list'),
{
'name': 'my test api part',
'description': 'a part created with the API',
'category': 1,
}
)
self.assertEqual(response.status_code, 201)
pk = response.data['pk']
# Check that a new part has been added
self.assertEqual(Part.objects.count(), n + 1)
part = Part.objects.get(pk=pk)
self.assertEqual(part.name, 'my test api part')
# Edit the part
url = reverse('api-part-detail', kwargs={'pk': pk})
# Let's change the name of the part
response = self.client.patch(url, {
'name': 'a new better name',
})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['pk'], pk)
self.assertEqual(response.data['name'], 'a new better name')
part = Part.objects.get(pk=pk)
# Name has been altered
self.assertEqual(part.name, 'a new better name')
# Part count should not have changed
self.assertEqual(Part.objects.count(), n + 1)
# Now, try to set the name to the *same* value
# 2021-06-22 this test is to check that the "duplicate part" checks don't do strange things
response = self.client.patch(url, {
'name': 'a new better name',
})
self.assertEqual(response.status_code, 200)
# Try to remove the part
response = self.client.delete(url)
self.assertEqual(response.status_code, 204)
# Part count should have reduced
self.assertEqual(Part.objects.count(), n)
def test_duplicates(self):
"""
Check that trying to create 'duplicate' parts results in errors
"""
# Create a part
response = self.client.post(reverse('api-part-list'), {
'name': 'part',
'description': 'description',
'IPN': 'IPN-123',
'category': 1,
'revision': 'A',
})
self.assertEqual(response.status_code, 201)
n = Part.objects.count()
# Check that we cannot create a duplicate in a different category
response = self.client.post(reverse('api-part-list'), {
'name': 'part',
'description': 'description',
'IPN': 'IPN-123',
'category': 2,
'revision': 'A',
})
self.assertEqual(response.status_code, 400)
# Check that only 1 matching part exists
parts = Part.objects.filter(
name='part',
description='description',
IPN='IPN-123'
)
self.assertEqual(parts.count(), 1)
# A new part should *not* have been created
self.assertEqual(Part.objects.count(), n)
# But a different 'revision' *can* be created
response = self.client.post(reverse('api-part-list'), {
'name': 'part',
'description': 'description',
'IPN': 'IPN-123',
'category': 2,
'revision': 'B',
})
self.assertEqual(response.status_code, 201)
self.assertEqual(Part.objects.count(), n + 1)
# Now, check that we cannot *change* an existing part to conflict
pk = response.data['pk']
url = reverse('api-part-detail', kwargs={'pk': pk})
# Attempt to alter the revision code
response = self.client.patch(
url,
{
'revision': 'A',
},
format='json',
)
self.assertEqual(response.status_code, 400)
# But we *can* change it to a unique revision code
response = self.client.patch(
url,
{
'revision': 'C',
}
)
self.assertEqual(response.status_code, 200)
def test_image_upload(self):
"""
Test that we can upload an image to the part API
"""
self.assignRole('part.add')
# Create a new part
response = self.client.post(
reverse('api-part-list'),
{
'name': 'imagine',
'description': 'All the people',
'category': 1,
},
expected_code=201
)
pk = response.data['pk']
url = reverse('api-part-detail', kwargs={'pk': pk})
p = Part.objects.get(pk=pk)
# Part should not have an image!
with self.assertRaises(ValueError):
print(p.image.file)
# Create a custom APIClient for file uploads
# Ref: https://stackoverflow.com/questions/40453947/how-to-generate-a-file-upload-test-request-with-django-rest-frameworks-apireq
upload_client = APIClient()
upload_client.force_authenticate(user=self.user)
# Try to upload a non-image file
with open('dummy_image.txt', 'w') as dummy_image:
dummy_image.write('hello world')
with open('dummy_image.txt', 'rb') as dummy_image:
response = upload_client.patch(
url,
{
'image': dummy_image,
},
format='multipart',
)
self.assertEqual(response.status_code, 400)
# Now try to upload a valid image file
img = PIL.Image.new('RGB', (128, 128), color='red')
img.save('dummy_image.jpg')
with open('dummy_image.jpg', 'rb') as dummy_image:
response = upload_client.patch(
url,
{
'image': dummy_image,
},
format='multipart',
)
self.assertEqual(response.status_code, 200)
# And now check that the image has been set
p = Part.objects.get(pk=pk)
print("Image:", p.image.file)
class PartAPIAggregationTest(InvenTreeAPITestCase):
"""
Tests to ensure that the various aggregation annotations are working correctly...
@ -319,6 +571,8 @@ class PartAPIAggregationTest(InvenTreeAPITestCase):
# Add a new part
self.part = Part.objects.create(
name='Banana',
description='This is a banana',
category=PartCategory.objects.get(pk=1),
)
# Create some stock items associated with the part

View File

@ -23,7 +23,7 @@ class TestParams(TestCase):
def test_str(self):
t1 = PartParameterTemplate.objects.get(pk=1)
self.assertEquals(str(t1), 'Length (mm)')
self.assertEqual(str(t1), 'Length (mm)')
p1 = PartParameter.objects.get(pk=1)
self.assertEqual(str(p1), 'M2x4 LPHS : Length = 4mm')

View File

@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError
import os
from .models import Part, PartTestTemplate
from .models import Part, PartCategory, PartTestTemplate
from .models import rename_part_image, match_part_names
from .templatetags import inventree_extras
@ -78,6 +78,61 @@ class PartTest(TestCase):
p = Part.objects.get(pk=100)
self.assertEqual(str(p), "BOB | Bob | A2 - Can we build it?")
def test_duplicate(self):
"""
Test that we cannot create a "duplicate" Part
"""
n = Part.objects.count()
cat = PartCategory.objects.get(pk=1)
Part.objects.create(
category=cat,
name='part',
description='description',
IPN='IPN',
revision='A',
)
self.assertEqual(Part.objects.count(), n + 1)
part = Part(
category=cat,
name='part',
description='description',
IPN='IPN',
revision='A',
)
with self.assertRaises(ValidationError):
part.validate_unique()
try:
part.save()
self.assertTrue(False)
except:
pass
self.assertEqual(Part.objects.count(), n + 1)
# But we should be able to create a part with a different revision
part_2 = Part.objects.create(
category=cat,
name='part',
description='description',
IPN='IPN',
revision='B',
)
self.assertEqual(Part.objects.count(), n + 2)
# Now, check that we cannot *change* part_2 to conflict
part_2.revision = 'A'
with self.assertRaises(ValidationError):
part_2.validate_unique()
def test_metadata(self):
self.assertEqual(self.r1.name, 'R_2K2_0805')
self.assertEqual(self.r1.get_absolute_url(), '/part/3/')
@ -277,21 +332,24 @@ class PartSettingsTest(TestCase):
"""
# Create a part
Part.objects.create(name='Hello', description='A thing', IPN='IPN123')
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='A')
# Attempt to create a duplicate item (should fail)
with self.assertRaises(ValidationError):
Part.objects.create(name='Hello', description='A thing', IPN='IPN123')
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='A')
part.validate_unique()
# Attempt to create item with duplicate IPN (should be allowed by default)
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B')
# And attempt again with the same values (should fail)
with self.assertRaises(ValidationError):
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='B')
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='B')
part.validate_unique()
# Now update the settings so duplicate IPN values are *not* allowed
InvenTreeSetting.set_setting('PART_ALLOW_DUPLICATE_IPN', False, self.user)
with self.assertRaises(ValidationError):
Part.objects.create(name='Hello', description='A thing', IPN='IPN123', revision='C')
part = Part(name='Hello', description='A thing', IPN='IPN123', revision='C')
part.full_clean()

View File

@ -981,7 +981,7 @@ class PartPricingView(PartDetail):
part = self.get_part()
# Stock history
if part.total_stock > 1:
ret = []
price_history = []
stock = part.stock_entries(include_variants=False, in_stock=True) # .order_by('purchase_order__date')
stock = stock.prefetch_related('purchase_order', 'supplier_part')
@ -1008,17 +1008,19 @@ class PartPricingView(PartDetail):
line['date'] = stock_item.purchase_order.issue_date.strftime('%d.%m.%Y')
else:
line['date'] = stock_item.tracking_info.first().date.strftime('%d.%m.%Y')
ret.append(line)
price_history.append(line)
ctx['price_history'] = ret
ctx['price_history'] = price_history
# BOM Information for Pie-Chart
if part.has_bom:
# get internal price setting
use_internal = InvenTreeSetting.get_setting('PART_BOM_USE_INTERNAL_PRICE', False)
ctx_bom_parts = []
# iterate over all bom-items
for item in part.bom_items.all():
ctx_item = {'name': str(item.sub_part)}
price, qty = item.sub_part.get_price_range(quantity), item.quantity
price, qty = item.sub_part.get_price_range(quantity, internal=use_internal), item.quantity
price_min, price_max = 0, 0
if price: # check if price available

View File

@ -44,6 +44,13 @@ class LocationResource(ModelResource):
StockLocation.objects.rebuild()
class LocationInline(admin.TabularInline):
"""
Inline for sub-locations
"""
model = StockLocation
class LocationAdmin(ImportExportModelAdmin):
resource_class = LocationResource
@ -52,6 +59,10 @@ class LocationAdmin(ImportExportModelAdmin):
search_fields = ('name', 'description')
inlines = [
LocationInline,
]
class StockItemResource(ModelResource):
""" Class for managing StockItem data import/export """

View File

@ -199,6 +199,7 @@ def update_history(apps, schema_editor):
update_count += 1
if update_count > 0:
print(f"\n==========================\nUpdated {update_count} StockItemHistory entries")

View File

@ -0,0 +1,78 @@
# Generated by Django 3.2.4 on 2021-06-21 07:24
from django.db import migrations
def extract_purchase_price(apps, schema_editor):
"""
Find instances of StockItem which do *not* have a purchase price set,
but which point to a PurchaseOrder where there *is* a purchase price set.
Then, assign *that* purchase price to original StockItem.
This is to address an issue where older versions of InvenTree
did not correctly copy purchase price information cross to the StockItem objects.
Current InvenTree version (as of 2021-06-21) copy this information across correctly,
so this one-time data migration should suffice.
"""
# Required database models
StockItem = apps.get_model('stock', 'stockitem')
PurchaseOrder = apps.get_model('order', 'purchaseorder')
PurchaseOrderLineItem = apps.get_model('order', 'purchaseorderlineitem')
Part = apps.get_model('part', 'part')
# Find all the StockItem objects without a purchase_price which point to a PurchaseOrder
items = StockItem.objects.filter(purchase_price=None).exclude(purchase_order=None)
if items.count() > 0:
print(f"Found {items.count()} stock items with missing purchase price information")
update_count = 0
for item in items:
part_id = item.part
po = item.purchase_order
# Look for a matching PurchaseOrderLineItem (with a price)
lines = PurchaseOrderLineItem.objects.filter(part__part=part_id, order=po)
if lines.exists():
for line in lines:
if line.purchase_price is not None:
# Copy pricing information across
item.purchase_price = line.purchase_price
item.purchases_price_currency = line.purchase_price_currency
print(f"- Updating supplier price for {item.part.name} - {item.purchase_price} {item.purchase_price_currency}")
update_count += 1
item.save()
break
if update_count > 0:
print(f"Updated pricing for {update_count} stock items")
def reverse_operation(apps, schema_editor):
"""
DO NOTHING!
"""
pass
class Migration(migrations.Migration):
dependencies = [
('stock', '0063_auto_20210511_2343'),
]
operations = [
migrations.RunPython(extract_purchase_price, reverse_code=reverse_operation)
]

View File

@ -81,6 +81,7 @@ class StockItemSerializer(InvenTreeModelSerializer):
'belongs_to',
'build',
'customer',
'purchase_order',
'sales_order',
'supplier_part',
'supplier_part__supplier',
@ -163,6 +164,10 @@ class StockItemSerializer(InvenTreeModelSerializer):
purchase_price = serializers.SerializerMethodField()
purchase_order_reference = serializers.CharField(source='purchase_order.reference', read_only=True)
sales_order_reference = serializers.CharField(source='sales_order.reference', read_only=True)
def get_purchase_price(self, obj):
""" Return purchase_price (Money field) as string (includes currency) """
@ -208,10 +213,13 @@ class StockItemSerializer(InvenTreeModelSerializer):
'packaging',
'part',
'part_detail',
'purchase_order',
'purchase_order_reference',
'pk',
'quantity',
'required_tests',
'sales_order',
'sales_order_reference',
'serial',
'stale',
'status',

View File

@ -325,7 +325,7 @@
<td><a href="{% url 'po-detail' item.purchase_order.id %}">{{ item.purchase_order }}</a></td>
</tr>
{% endif %}
{% if item.purchase_price %}
{% if item.purchase_price != None %}
<tr>
<td><span class='fas fa-dollar-sign'></span></td>
<td>{% trans "Purchase Price" %}</td>
@ -350,7 +350,12 @@
<tr>
<td><span class='fas fa-industry'></span></td>
<td>{% trans "Manufacturer" %}</td>
{% if item.supplier_part.manufacturer_part.manufacturer %}
<td><a href="{% url 'company-detail' item.supplier_part.manufacturer_part.manufacturer.id %}">{{ item.supplier_part.manufacturer_part.manufacturer.name }}</a></td>
{% else %}
<td><i>{% trans "No manufacturer set" %}</i></td>
{% endif %}
</tr>
<tr>
<td><span class='fas fa-hashtag'></span></td>

View File

@ -20,6 +20,7 @@
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_DUPLICATE_IPN" %}
{% include "InvenTree/settings/setting.html" with key="PART_ALLOW_EDIT_IPN" %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_QUANTITY_IN_FORMS" icon="fa-hashtag" %}
{% include "InvenTree/settings/setting.html" with key="PART_SHOW_PRICE_IN_FORMS" icon="fa-dollar-sign" %}
{% include "InvenTree/settings/setting.html" with key="PART_RECENT_COUNT" icon="fa-clock" %}
<tr><td colspan='5 '></td></tr>
{% include "InvenTree/settings/setting.html" with key="PART_TEMPLATE" icon="fa-clone" %}

View File

@ -34,6 +34,11 @@
<td>{% trans "API Version" %}</td>
<td>{% inventree_api_version %}{% include "clip.html" %}</td>
</tr>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Python Version" %}</td>
<td>{% python_version %}</td>
</tr>
<tr>
<td><span class='fas fa-hashtag'></span></td>
<td>{% trans "Django Version" %}</td>

View File

@ -418,6 +418,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
sub_part_detail: true,
sub_part_trackable: trackable,
},
disablePagination: true,
formatNoMatches: function() {
return '{% trans "No BOM items found" %}';
},
@ -667,6 +668,7 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
{
field: 'sub_part_detail.stock',
title: '{% trans "Available" %}',
sortable: true,
},
{
field: 'allocated',
@ -686,14 +688,13 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
return makeProgressBar(allocated, required);
},
sorter: function(valA, valB, rowA, rowB) {
// Custom sorting function for progress bars
var aA = sumAllocations(rowA);
var aB = sumAllocations(rowB);
var qA = rowA.quantity;
var qB = rowB.quantity;
qA *= output.quantity;
qB *= output.quantity;
var qA = requiredQuantity(rowA);
var qB = requiredQuantity(rowB);
// Handle the case where both numerators are zero
if ((aA == 0) && (aB == 0)) {
@ -713,6 +714,8 @@ function loadBuildOutputAllocationTable(buildInfo, output, options={}) {
return (qA < qB) ? 1 : -1;
}
if (progressA == progressB) return 0;
return (progressA < progressB) ? 1 : -1;
}
},

View File

@ -126,7 +126,7 @@ function loadManufacturerPartTable(table, url, options) {
queryParams: filters,
name: 'manufacturerparts',
groupBy: false,
formatNoMatches: function() { return "{% trans "No manufacturer parts found" %}"; },
formatNoMatches: function() { return '{% trans "No manufacturer parts found" %}'; },
columns: [
{
checkbox: true,
@ -199,6 +199,107 @@ function loadManufacturerPartTable(table, url, options) {
}
function loadManufacturerPartParameterTable(table, url, options) {
/*
* Load table of ManufacturerPartParameter objects
*/
var params = options.params || {};
// Load filters
var filters = loadTableFilters("manufacturer-part-parameters");
// Overwrite explicit parameters
for (var key in params) {
filters[key] = params[key];
}
// setupFilterList("manufacturer-part-parameters", $(table));
$(table).inventreeTable({
url: url,
method: 'get',
original: params,
queryParams: filters,
name: 'manufacturerpartparameters',
groupBy: false,
formatNoMatches: function() { return '{% trans "No parameters found" %}'; },
columns: [
{
checkbox: true,
switchable: false,
visible: false,
},
{
field: 'name',
title: '{% trans "Name" %}',
switchable: false,
sortable: true,
},
{
field: 'value',
title: '{% trans "Value" %}',
switchable: false,
sortable: true,
},
{
field: 'units',
title: '{% trans "Units" %}',
switchable: true,
sortable: true,
},
{
field: 'actions',
title: '',
switchable: false,
sortable: false,
formatter: function(value, row) {
var pk = row.pk;
var html = `<div class='btn-group float-right' role='group'>`;
html += makeIconButton('fa-edit icon-blue', 'button-parameter-edit', pk, '{% trans "Edit parameter" %}');
html += makeIconButton('fa-trash-alt icon-red', 'button-parameter-delete', pk, '{% trans "Delete parameter" %}');
html += `</div>`;
return html;
}
}
],
onPostBody: function() {
// Setup callback functions
$(table).find('.button-parameter-edit').click(function() {
var pk = $(this).attr('pk');
launchModalForm(
`/manufacturer-part/parameter/${pk}/edit/`,
{
success: function() {
$(table).bootstrapTable('refresh');
}
}
);
});
$(table).find('.button-parameter-delete').click(function() {
var pk = $(this).attr('pk');
launchModalForm(
`/manufacturer-part/parameter/${pk}/delete/`,
{
success: function() {
$(table).bootstrapTable('refresh');
}
}
);
});
}
});
}
function loadSupplierPartTable(table, url, options) {
/*
* Load supplier part table
@ -224,7 +325,7 @@ function loadSupplierPartTable(table, url, options) {
queryParams: filters,
name: 'supplierparts',
groupBy: false,
formatNoMatches: function() { return "{% trans "No supplier parts found" %}"; },
formatNoMatches: function() { return '{% trans "No supplier parts found" %}'; },
columns: [
{
checkbox: true,
@ -260,7 +361,7 @@ function loadSupplierPartTable(table, url, options) {
{
sortable: true,
field: 'supplier',
title: "{% trans "Supplier" %}",
title: '{% trans "Supplier" %}',
formatter: function(value, row, index, field) {
if (value) {
var name = row.supplier_detail.name;
@ -276,7 +377,7 @@ function loadSupplierPartTable(table, url, options) {
{
sortable: true,
field: 'SKU',
title: "{% trans "Supplier Part" %}",
title: '{% trans "Supplier Part" %}',
formatter: function(value, row, index, field) {
return renderLink(value, `/supplier-part/${row.pk}/`);
}

View File

@ -447,6 +447,7 @@ function loadPartTable(table, url, options={}) {
columns.push({
sortable: true,
sortName: 'category',
field: 'category_detail',
title: '{% trans "Category" %}',
formatter: function(value, row, index, field) {

View File

@ -179,27 +179,32 @@ function loadStockTestResultsTable(table, options) {
var match = false;
var override = false;
// Extract the simplified test key
var key = item.key;
// Attempt to associate this result with an existing test
tableData.forEach(function(row, index) {
for (var idx = 0; idx < tableData.length; idx++) {
var row = tableData[idx];
if (key == row.key) {
item.test_name = row.test_name;
item.required = row.required;
match = true;
if (row.result == null) {
item.parent = parent_node;
tableData[index] = item;
tableData[idx] = item;
override = true;
} else {
item.parent = row.pk;
}
match = true;
break;
}
}
});
// No match could be found
if (!match) {
@ -603,7 +608,6 @@ function loadStockTable(table, options) {
// REJECTED
if (row.status == {{ StockStatus.REJECTED }}) {
console.log("REJECTED - {{ StockStatus.REJECTED }}");
html += makeIconBadge('fa-times-circle icon-red', '{% trans "Stock item has been rejected" %}');
}
// LOST
@ -660,6 +664,27 @@ function loadStockTable(table, options) {
title: '{% trans "Last Updated" %}',
sortable: true,
},
{
field: 'purchase_order',
title: '{% trans "Purchase Order" %}',
formatter: function(value, row) {
if (!value) {
return '-';
}
var link = `/order/purchase-order/${row.purchase_order}/`;
var text = `${row.purchase_order}`;
if (row.purchase_order_reference) {
var prefix = '{% settings_value "PURCHASEORDER_REFERENCE_PREFIX" %}';
text = prefix + row.purchase_order_reference;
}
return renderLink(text, link);
}
},
{
field: 'purchase_price',
title: '{% trans "Purchase Price" %}',

View File

@ -134,12 +134,14 @@ $.fn.inventreeTable = function(options) {
var varName = tableName + '-pagesize';
// Pagingation options (can be server-side or client-side as specified by the caller)
if (!options.disablePagination) {
options.pagination = true;
options.paginationVAlign = options.paginationVAlign || 'both';
options.pageSize = inventreeLoad(varName, 25);
options.pageList = [25, 50, 100, 250, 'all'];
options.totalField = 'count';
options.dataField = 'results';
}
// Extract query params
var filters = options.queryParams || options.filters || {};

View File

@ -43,6 +43,9 @@
</div>
{% endblock %}
{% block pre_content_panels %}
{% endblock %}
{% block content_panels %}
<div class='panel panel-default panel-inventree'>
<div class='panel-heading'>
@ -63,6 +66,9 @@
</div>
{% endblock %}
{% block post_content_panels %}
{% endblock %}
{% endblock %}
{% block js_ready %}

View File

@ -13,7 +13,7 @@ class UsersConfig(AppConfig):
def ready(self):
if canAppAccessDatabase():
if canAppAccessDatabase(allow_test=True):
try:
self.assign_permissions()

View File

@ -85,6 +85,7 @@ class RuleSet(models.Model):
'part_partstar',
'company_supplierpart',
'company_manufacturerpart',
'company_manufacturerpartparameter',
],
'stock_location': [
'stock_stocklocation',
@ -116,6 +117,8 @@ class RuleSet(models.Model):
'order_purchaseorderattachment',
'order_purchaseorderlineitem',
'company_supplierpart',
'company_manufacturerpart',
'company_manufacturerpartparameter',
],
'sales_order': [
'company_company',
@ -273,7 +276,7 @@ def update_group_roles(group, debug=False):
"""
if not canAppAccessDatabase():
if not canAppAccessDatabase(allow_test=True):
return
# List of permissions already associated with this group

23
RELEASE.md Normal file
View File

@ -0,0 +1,23 @@
## Release Checklist
Checklist of steps to perform at each code release
### Update Version String
Update `INVENTREE_SW_VERSION` in [version.py](https://github.com/inventree/InvenTree/blob/master/InvenTree/InvenTree/version.py)
### Increment API Version
If the API has changed, ensure that the API version number is incremented.
### Translation Files
Merge the crowdin translation updates into master branch
### Documentation Release
Create new release for the [inventree documentation](https://github.com/inventree/inventree-docs)
### Python Library Release
Create new release for the [inventree python library](https://github.com/inventree/inventree-python)

View File

@ -251,12 +251,15 @@ def content_excludes():
"contenttypes",
"sessions.session",
"auth.permission",
"authtoken.token",
"error_report.error",
"admin.logentry",
"django_q.schedule",
"django_q.task",
"django_q.ormq",
"users.owner",
"exchange.rate",
"exchange.exchangebackend",
]
output = ""
@ -362,6 +365,21 @@ def import_records(c, filename='data.json'):
print("Data import completed")
@task
def delete_data(c, force=False):
"""
Delete all database records!
Warning: This will REALLY delete all records in the database!!
"""
if force:
manage(c, 'flush --noinput')
else:
manage(c, 'flush')
@task(post=[rebuild])
def import_fixtures(c):
"""