mirror of
https://github.com/inventree/InvenTree
synced 2024-08-30 18:33:04 +00:00
Merge remote-tracking branch 'inventree/master'
This commit is contained in:
commit
cdeb41e963
@ -47,7 +47,12 @@ class AuthRequiredMiddleware(object):
|
|||||||
|
|
||||||
authorized = False
|
authorized = False
|
||||||
|
|
||||||
if 'Authorization' in request.headers.keys():
|
# Allow static files to be accessed without auth
|
||||||
|
# Important for e.g. login page
|
||||||
|
if request.path_info.startswith('/static/'):
|
||||||
|
authorized = True
|
||||||
|
|
||||||
|
elif 'Authorization' in request.headers.keys():
|
||||||
auth = request.headers['Authorization'].strip()
|
auth = request.headers['Authorization'].strip()
|
||||||
|
|
||||||
if auth.startswith('Token') and len(auth.split()) == 2:
|
if auth.startswith('Token') and len(auth.split()) == 2:
|
||||||
@ -56,7 +61,7 @@ class AuthRequiredMiddleware(object):
|
|||||||
# Does the provided token match a valid user?
|
# Does the provided token match a valid user?
|
||||||
if Token.objects.filter(key=token).exists():
|
if Token.objects.filter(key=token).exists():
|
||||||
|
|
||||||
allowed = ['/api/', '/media/', '/static/']
|
allowed = ['/api/', '/media/']
|
||||||
|
|
||||||
# Only allow token-auth for /media/ or /static/ dirs!
|
# Only allow token-auth for /media/ or /static/ dirs!
|
||||||
if any([request.path_info.startswith(a) for a in allowed]):
|
if any([request.path_info.startswith(a) for a in allowed]):
|
||||||
|
@ -11,6 +11,45 @@
|
|||||||
--label-yellow: #fdc82a;
|
--label-yellow: #fdc82a;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-screen {
|
||||||
|
background-image: url("/static/img/paper_splash.jpg");
|
||||||
|
background-size: cover;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
height: 100%;
|
||||||
|
font-family: 'Numans', sans-serif;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
left: 50%;
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 30%;
|
||||||
|
align-content: center;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 20px;
|
||||||
|
padding-bottom: 35px;
|
||||||
|
background-color: rgba(50, 50, 50, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
padding-right: 30px;
|
||||||
|
margin-right: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container input {
|
||||||
|
background-color: rgba(250, 250, 250, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
background-color: rgba(250, 250, 250, 0.9);
|
||||||
|
color: #333;
|
||||||
|
border-color: #AAA;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.markdownx .row {
|
.markdownx .row {
|
||||||
margin: 5px;
|
margin: 5px;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
@ -268,6 +307,10 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rowinherited {
|
||||||
|
background-color: #dde;
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown {
|
.dropdown {
|
||||||
padding-left: 1px;
|
padding-left: 1px;
|
||||||
margin-left: 1px;
|
margin-left: 1px;
|
||||||
@ -574,7 +617,7 @@
|
|||||||
margin-bottom: 3px;
|
margin-bottom: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-form-content {
|
.modal-form-content-wrapper {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
position:relative;
|
position:relative;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
@ -708,13 +751,6 @@ input[type="submit"] {
|
|||||||
color: #e00;
|
color: #e00;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.part-allocation {
|
.part-allocation {
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
border: 1px solid #ccc;
|
border: 1px solid #ccc;
|
||||||
|
BIN
InvenTree/InvenTree/static/img/paper_splash.jpg
Normal file
BIN
InvenTree/InvenTree/static/img/paper_splash.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.2 MiB |
@ -39,6 +39,11 @@ class EditBuildForm(HelperForm):
|
|||||||
help_text=_('Target date for build completion. Build will be overdue after this date.')
|
help_text=_('Target date for build completion. Build will be overdue after this date.')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
quantity = RoundingDecimalFormField(
|
||||||
|
max_digits=10, decimal_places=5,
|
||||||
|
help_text=_('Number of items to build')
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Build
|
model = Build
|
||||||
fields = [
|
fields = [
|
||||||
@ -53,6 +58,8 @@ class EditBuildForm(HelperForm):
|
|||||||
'parent',
|
'parent',
|
||||||
'sales_order',
|
'sales_order',
|
||||||
'link',
|
'link',
|
||||||
|
'issued_by',
|
||||||
|
'responsible',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
27
InvenTree/build/migrations/0026_auto_20210216_1539.py
Normal file
27
InvenTree/build/migrations/0026_auto_20210216_1539.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 3.0.7 on 2021-02-16 04:39
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('users', '0005_owner_model'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
('build', '0025_build_target_date'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='build',
|
||||||
|
name='issued_by',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='User who issued this build order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds_issued', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='build',
|
||||||
|
name='responsible',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='User responsible for this build order', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='builds_responsible', to='users.Owner'),
|
||||||
|
),
|
||||||
|
]
|
@ -23,7 +23,7 @@ from markdownx.models import MarkdownxField
|
|||||||
from mptt.models import MPTTModel, TreeForeignKey
|
from mptt.models import MPTTModel, TreeForeignKey
|
||||||
|
|
||||||
from InvenTree.status_codes import BuildStatus
|
from InvenTree.status_codes import BuildStatus
|
||||||
from InvenTree.helpers import increment, getSetting, normalize
|
from InvenTree.helpers import increment, getSetting, normalize, MakeBarcode
|
||||||
from InvenTree.validators import validate_build_order_reference
|
from InvenTree.validators import validate_build_order_reference
|
||||||
from InvenTree.models import InvenTreeAttachment
|
from InvenTree.models import InvenTreeAttachment
|
||||||
|
|
||||||
@ -33,6 +33,7 @@ import InvenTree.fields
|
|||||||
|
|
||||||
from stock import models as StockModels
|
from stock import models as StockModels
|
||||||
from part import models as PartModels
|
from part import models as PartModels
|
||||||
|
from users import models as UserModels
|
||||||
|
|
||||||
|
|
||||||
class Build(MPTTModel):
|
class Build(MPTTModel):
|
||||||
@ -53,6 +54,9 @@ class Build(MPTTModel):
|
|||||||
completion_date: Date the build was completed (or, if incomplete, the expected date of completion)
|
completion_date: Date the build was completed (or, if incomplete, the expected date of completion)
|
||||||
link: External URL for extra information
|
link: External URL for extra information
|
||||||
notes: Text notes
|
notes: Text notes
|
||||||
|
completed_by: User that completed the build
|
||||||
|
issued_by: User that issued the build
|
||||||
|
responsible: User (or group) responsible for completing the build
|
||||||
"""
|
"""
|
||||||
|
|
||||||
OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
|
OVERDUE_FILTER = Q(status__in=BuildStatus.ACTIVE_CODES) & ~Q(target_date=None) & Q(target_date__lte=datetime.now().date())
|
||||||
@ -61,6 +65,20 @@ class Build(MPTTModel):
|
|||||||
verbose_name = _("Build Order")
|
verbose_name = _("Build Order")
|
||||||
verbose_name_plural = _("Build Orders")
|
verbose_name_plural = _("Build Orders")
|
||||||
|
|
||||||
|
def format_barcode(self, **kwargs):
|
||||||
|
"""
|
||||||
|
Return a JSON string to represent this build as a barcode
|
||||||
|
"""
|
||||||
|
|
||||||
|
return MakeBarcode(
|
||||||
|
"buildorder",
|
||||||
|
self.pk,
|
||||||
|
{
|
||||||
|
"reference": self.title,
|
||||||
|
"url": self.get_absolute_url(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def filterByDate(queryset, min_date, max_date):
|
def filterByDate(queryset, min_date, max_date):
|
||||||
"""
|
"""
|
||||||
@ -215,6 +233,22 @@ class Build(MPTTModel):
|
|||||||
related_name='builds_completed'
|
related_name='builds_completed'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
issued_by = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
blank=True, null=True,
|
||||||
|
help_text=_('User who issued this build order'),
|
||||||
|
related_name='builds_issued',
|
||||||
|
)
|
||||||
|
|
||||||
|
responsible = models.ForeignKey(
|
||||||
|
UserModels.Owner,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
blank=True, null=True,
|
||||||
|
help_text=_('User responsible for this build order'),
|
||||||
|
related_name='builds_responsible',
|
||||||
|
)
|
||||||
|
|
||||||
link = InvenTree.fields.InvenTreeURLField(
|
link = InvenTree.fields.InvenTreeURLField(
|
||||||
verbose_name=_('External Link'),
|
verbose_name=_('External Link'),
|
||||||
blank=True, help_text=_('Link to external URL')
|
blank=True, help_text=_('Link to external URL')
|
||||||
|
@ -45,27 +45,35 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
</h3>
|
</h3>
|
||||||
<hr>
|
<hr>
|
||||||
<p>{{ build.title }}</p>
|
<p>{{ build.title }}</p>
|
||||||
<div class='btn-row'>
|
|
||||||
<div class='btn-group action-buttons'>
|
<div class='btn-group action-buttons' role='group'>
|
||||||
|
<!-- Printing options -->
|
||||||
|
<div class='btn-group'>
|
||||||
|
<button id='print-options' title='{% trans "Print actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
||||||
|
<span class='fas fa-print'></span> <span class='caret'></span>
|
||||||
|
</button>
|
||||||
|
<ul class='dropdown-menu' role='menu'>
|
||||||
|
<li><a href='#' id='print-build-report'><span class='fas fa-file-pdf'></span> {% trans "Print Build Order" %}</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<!-- Build actions -->
|
||||||
{% if roles.build.change %}
|
{% if roles.build.change %}
|
||||||
<button type='button' class='btn btn-default' id='build-edit' title='{% trans "Edit Build" %}'>
|
<div class='btn-group'>
|
||||||
<span class='fas fa-edit icon-green'/>
|
<button id='build-options' title='{% trans "Build actions" %}' class='btn btn-default dropdown-toggle' type='button' data-toggle='dropdown'>
|
||||||
|
<span class='fas fa-tools'></span> <span class='caret'></span>
|
||||||
</button>
|
</button>
|
||||||
|
<ul class='dropdown-menu' role='menu'>
|
||||||
|
<li><a href='#' id='build-edit'><span class='fas fa-edit icon-green'></span> {% trans "Edit Build" %}</a></li>
|
||||||
{% if build.is_active %}
|
{% if build.is_active %}
|
||||||
<button type='button' class='btn btn-default' id='build-complete' title='{% trans "Complete Build" %}'>
|
<li><a href='#' id='build-complete'><span class='fas fa-tools'></span> {% trans "Complete Build" %}</a></li>
|
||||||
<span class='fas fa-tools'/>
|
<li><a href='#' id='build-cancel'><span class='fas fa-times-circle icon-red'></span> {% trans "Cancel Build" %}</a></li>
|
||||||
</button>
|
|
||||||
<button type='button' class='btn btn-default btn-glyph' id='build-cancel' title='{% trans "Cancel Build" %}'>
|
|
||||||
<span class='fas fa-times-circle icon-red'/>
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if build.status == BuildStatus.CANCELLED and roles.build.delete %}
|
{% if build.status == BuildStatus.CANCELLED and roles.build.delete %}
|
||||||
<button type='button' class='btn btn-default btn-glyph' id='build-delete' title='{% trans "Delete Build" %}'>
|
<li><a href='#' id='build-delete'><span class='fas fa-trash-alt'></span> {% trans "Delete Build"% }</a>
|
||||||
<span class='fas fa-trash-alt icon-red'/>
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -121,6 +129,20 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
<td><a href="{% url 'so-detail' build.sales_order.id %}">{{ build.sales_order }}</a></td>
|
<td><a href="{% url 'so-detail' build.sales_order.id %}">{{ build.sales_order }}</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if build.issued_by %}
|
||||||
|
<tr>
|
||||||
|
<td><span class='fas fa-user'></span></td>
|
||||||
|
<td>{% trans "Issued By" %}</td>
|
||||||
|
<td>{{ build.issued_by }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if build.responsible %}
|
||||||
|
<tr>
|
||||||
|
<td><span class='fas fa-users'></span></td>
|
||||||
|
<td>{% trans "Responsible" %}</td>
|
||||||
|
<td>{{ build.responsible }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
</table>
|
</table>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@ -151,6 +173,10 @@ src="{% static 'img/blank_image.png' %}"
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#print-build-report').click(function() {
|
||||||
|
printBuildReports([{{ build.pk }}]);
|
||||||
|
});
|
||||||
|
|
||||||
$("#build-delete").on('click', function() {
|
$("#build-delete").on('click', function() {
|
||||||
launchModalForm(
|
launchModalForm(
|
||||||
"{% url 'build-delete' build.id %}",
|
"{% url 'build-delete' build.id %}",
|
||||||
|
@ -90,6 +90,25 @@
|
|||||||
<td><a href="{{ build.link }}">{{ build.link }}</a></td>
|
<td><a href="{{ build.link }}">{{ build.link }}</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if build.issued_by %}
|
||||||
|
<tr>
|
||||||
|
<td><span class='fas fa-user'></span></td>
|
||||||
|
<td>{% trans "Issued By" %}</td>
|
||||||
|
<td>{{ build.issued_by }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if build.responsible %}
|
||||||
|
<tr>
|
||||||
|
<td><span class='fas fa-users'></span></td>
|
||||||
|
<td>{% trans "Responsible" %}</td>
|
||||||
|
<td>{{ build.responsible }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class='col-sm-6'>
|
||||||
|
<table class='table table-striped'>
|
||||||
|
<col width='25'>
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-calendar-alt'></span></td>
|
<td><span class='fas fa-calendar-alt'></span></td>
|
||||||
<td>{% trans "Created" %}</td>
|
<td>{% trans "Created" %}</td>
|
||||||
|
@ -22,11 +22,24 @@ InvenTree | {% trans "Build Orders" %}
|
|||||||
|
|
||||||
<div id='button-toolbar'>
|
<div id='button-toolbar'>
|
||||||
<div class='button-toolbar container-fluid' style='float: right;'>
|
<div class='button-toolbar container-fluid' style='float: right;'>
|
||||||
|
<div class='btn-group'>
|
||||||
{% if roles.build.add %}
|
{% if roles.build.add %}
|
||||||
<button type='button' class="btn btn-success" id='new-build'>
|
<button type='button' class="btn btn-success" id='new-build'>
|
||||||
<span class='fas fa-tools'></span> {% trans "New Build Order" %}
|
<span class='fas fa-tools'></span> {% trans "New Build Order" %}
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class='btn-group'>
|
||||||
|
<!-- Print actions -->
|
||||||
|
<button id='build-print-options' class='btn btn-primary dropdown-toggle' data-toggle='dropdown'>
|
||||||
|
<span class='fas fa-print'></span> <span class='caret'></span>
|
||||||
|
</button>
|
||||||
|
<ul class='dropdown-menu'>
|
||||||
|
<li><a href='#' id='multi-build-print' title='{% trans "Print Build Orders" %}'>
|
||||||
|
<span class='fas fa-file-pdf'></span> {% trans "Print Build Orders" %}
|
||||||
|
</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<!-- Buttons to switch between list and calendar views -->
|
||||||
<button class='btn btn-default' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'>
|
<button class='btn btn-default' type='button' id='view-calendar' title='{% trans "Display calendar view" %}'>
|
||||||
<span class='fas fa-calendar-alt'></span>
|
<span class='fas fa-calendar-alt'></span>
|
||||||
</button>
|
</button>
|
||||||
@ -38,6 +51,7 @@ InvenTree | {% trans "Build Orders" %}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class='table table-striped table-condensed' id='build-table' data-toolbar='#button-toolbar'>
|
<table class='table table-striped table-condensed' id='build-table' data-toolbar='#button-toolbar'>
|
||||||
@ -157,17 +171,29 @@ $("#view-list").click(function() {
|
|||||||
$("#view-calendar").show();
|
$("#view-calendar").show();
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#collapse-item-active").collapse().show();
|
$("#collapse-item-active").collapse().show();
|
||||||
|
|
||||||
$("#new-build").click(function() {
|
$("#new-build").click(function() {
|
||||||
newBuildOrder();
|
newBuildOrder();
|
||||||
});
|
});
|
||||||
|
|
||||||
loadBuildTable($("#build-table"), {
|
loadBuildTable($("#build-table"), {
|
||||||
url: "{% url 'api-build-list' %}",
|
url: "{% url 'api-build-list' %}",
|
||||||
params: {
|
params: {
|
||||||
part_detail: "true",
|
part_detail: "true",
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#multi-build-print').click(function() {
|
||||||
|
var rows = $("#build-table").bootstrapTable('getSelections');
|
||||||
|
|
||||||
|
var build_ids = [];
|
||||||
|
|
||||||
|
rows.forEach(function(row) {
|
||||||
|
build_ids.push(row.pk);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
printBuildReports(build_ids);
|
||||||
|
});
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -675,6 +675,13 @@ class BuildCreate(AjaxCreateView):
|
|||||||
|
|
||||||
initials = super(BuildCreate, self).get_initial().copy()
|
initials = super(BuildCreate, self).get_initial().copy()
|
||||||
|
|
||||||
|
initials['parent'] = self.request.GET.get('parent', None)
|
||||||
|
|
||||||
|
# User has provided a SalesOrder ID
|
||||||
|
initials['sales_order'] = self.request.GET.get('sales_order', None)
|
||||||
|
|
||||||
|
initials['quantity'] = self.request.GET.get('quantity', 1)
|
||||||
|
|
||||||
part = self.request.GET.get('part', None)
|
part = self.request.GET.get('part', None)
|
||||||
|
|
||||||
if part:
|
if part:
|
||||||
@ -684,17 +691,20 @@ class BuildCreate(AjaxCreateView):
|
|||||||
# User has provided a Part ID
|
# User has provided a Part ID
|
||||||
initials['part'] = part
|
initials['part'] = part
|
||||||
initials['destination'] = part.get_default_location()
|
initials['destination'] = part.get_default_location()
|
||||||
|
|
||||||
|
to_order = part.quantity_to_order
|
||||||
|
|
||||||
|
if to_order < 1:
|
||||||
|
to_order = 1
|
||||||
|
|
||||||
|
initials['quantity'] = to_order
|
||||||
except (ValueError, Part.DoesNotExist):
|
except (ValueError, Part.DoesNotExist):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
initials['reference'] = Build.getNextBuildNumber()
|
initials['reference'] = Build.getNextBuildNumber()
|
||||||
|
|
||||||
initials['parent'] = self.request.GET.get('parent', None)
|
# Pre-fill the issued_by user
|
||||||
|
initials['issued_by'] = self.request.user
|
||||||
# User has provided a SalesOrder ID
|
|
||||||
initials['sales_order'] = self.request.GET.get('sales_order', None)
|
|
||||||
|
|
||||||
initials['quantity'] = self.request.GET.get('quantity', 1)
|
|
||||||
|
|
||||||
return initials
|
return initials
|
||||||
|
|
||||||
|
@ -18,7 +18,7 @@ from djmoney.contrib.exchange.models import convert_money
|
|||||||
from djmoney.contrib.exchange.exceptions import MissingRate
|
from djmoney.contrib.exchange.exceptions import MissingRate
|
||||||
|
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator, URLValidator
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
import InvenTree.helpers
|
import InvenTree.helpers
|
||||||
@ -64,6 +64,13 @@ class InvenTreeSetting(models.Model):
|
|||||||
'default': 'My company name',
|
'default': 'My company name',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'INVENTREE_BASE_URL': {
|
||||||
|
'name': _('Base URL'),
|
||||||
|
'description': _('Base URL for server instance'),
|
||||||
|
'validator': URLValidator(),
|
||||||
|
'default': '',
|
||||||
|
},
|
||||||
|
|
||||||
'INVENTREE_DEFAULT_CURRENCY': {
|
'INVENTREE_DEFAULT_CURRENCY': {
|
||||||
'name': _('Default Currency'),
|
'name': _('Default Currency'),
|
||||||
'description': _('Default currency'),
|
'description': _('Default currency'),
|
||||||
@ -528,6 +535,11 @@ class InvenTreeSetting(models.Model):
|
|||||||
|
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if callable(validator):
|
||||||
|
# We can accept function validators with a single argument
|
||||||
|
print("Running validator function")
|
||||||
|
validator(self.value)
|
||||||
|
|
||||||
# Boolean validator
|
# Boolean validator
|
||||||
if validator == bool:
|
if validator == bool:
|
||||||
# Value must "look like" a boolean value
|
# Value must "look like" a boolean value
|
||||||
|
@ -5,6 +5,7 @@ import sys
|
|||||||
|
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from django.conf.urls import url, include
|
from django.conf.urls import url, include
|
||||||
|
from django.core.exceptions import ValidationError, FieldError
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
|
||||||
@ -119,15 +120,22 @@ class StockItemLabelList(LabelListView, StockItemLabelMixin):
|
|||||||
matches = True
|
matches = True
|
||||||
|
|
||||||
# Filter string defined for the StockItemLabel object
|
# Filter string defined for the StockItemLabel object
|
||||||
|
try:
|
||||||
filters = InvenTree.helpers.validateFilterString(label.filters)
|
filters = InvenTree.helpers.validateFilterString(label.filters)
|
||||||
|
except ValidationError:
|
||||||
|
continue
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
|
|
||||||
item_query = StockItem.objects.filter(pk=item.pk)
|
item_query = StockItem.objects.filter(pk=item.pk)
|
||||||
|
|
||||||
|
try:
|
||||||
if not item_query.filter(**filters).exists():
|
if not item_query.filter(**filters).exists():
|
||||||
matches = False
|
matches = False
|
||||||
break
|
break
|
||||||
|
except FieldError:
|
||||||
|
matches = False
|
||||||
|
break
|
||||||
|
|
||||||
# Matched all items
|
# Matched all items
|
||||||
if matches:
|
if matches:
|
||||||
@ -273,15 +281,23 @@ class StockLocationLabelList(LabelListView, StockLocationLabelMixin):
|
|||||||
matches = True
|
matches = True
|
||||||
|
|
||||||
# Filter string defined for the StockLocationLabel object
|
# Filter string defined for the StockLocationLabel object
|
||||||
|
try:
|
||||||
filters = InvenTree.helpers.validateFilterString(label.filters)
|
filters = InvenTree.helpers.validateFilterString(label.filters)
|
||||||
|
except:
|
||||||
|
# Skip if there was an error validating the filters...
|
||||||
|
continue
|
||||||
|
|
||||||
for loc in locations:
|
for loc in locations:
|
||||||
|
|
||||||
loc_query = StockLocation.objects.filter(pk=loc.pk)
|
loc_query = StockLocation.objects.filter(pk=loc.pk)
|
||||||
|
|
||||||
|
try:
|
||||||
if not loc_query.filter(**filters).exists():
|
if not loc_query.filter(**filters).exists():
|
||||||
matches = False
|
matches = False
|
||||||
break
|
break
|
||||||
|
except FieldError:
|
||||||
|
matches = False
|
||||||
|
break
|
||||||
|
|
||||||
# Matched all items
|
# Matched all items
|
||||||
if matches:
|
if matches:
|
||||||
|
@ -12,6 +12,7 @@ from blabel import LabelWriter
|
|||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.core.validators import FileExtensionValidator
|
from django.core.validators import FileExtensionValidator
|
||||||
|
from django.core.exceptions import ValidationError, FieldError
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@ -145,9 +146,12 @@ class StockItemLabel(LabelTemplate):
|
|||||||
Test if this label template matches a given StockItem object
|
Test if this label template matches a given StockItem object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
filters = validateFilterString(self.filters)
|
filters = validateFilterString(self.filters)
|
||||||
|
|
||||||
items = stock.models.StockItem.objects.filter(**filters)
|
items = stock.models.StockItem.objects.filter(**filters)
|
||||||
|
except (ValidationError, FieldError):
|
||||||
|
# If an error exists with the "filters" field, return False
|
||||||
|
return False
|
||||||
|
|
||||||
items = items.filter(pk=item.pk)
|
items = items.filter(pk=item.pk)
|
||||||
|
|
||||||
@ -198,9 +202,11 @@ class StockLocationLabel(LabelTemplate):
|
|||||||
Test if this label template matches a given StockLocation object
|
Test if this label template matches a given StockLocation object
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
filters = validateFilterString(self.filters)
|
filters = validateFilterString(self.filters)
|
||||||
|
|
||||||
locs = stock.models.StockLocation.objects.filter(**filters)
|
locs = stock.models.StockLocation.objects.filter(**filters)
|
||||||
|
except (ValidationError, FieldError):
|
||||||
|
return False
|
||||||
|
|
||||||
locs = locs.filter(pk=location.pk)
|
locs = locs.filter(pk=location.pk)
|
||||||
|
|
||||||
|
Binary file not shown.
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
@ -810,11 +810,35 @@ class BomList(generics.ListCreateAPIView):
|
|||||||
|
|
||||||
queryset = queryset.filter(optional=optional)
|
queryset = queryset.filter(optional=optional)
|
||||||
|
|
||||||
|
# Filter by "inherited" status
|
||||||
|
inherited = params.get('inherited', None)
|
||||||
|
|
||||||
|
if inherited is not None:
|
||||||
|
inherited = str2bool(inherited)
|
||||||
|
|
||||||
|
queryset = queryset.filter(inherited=inherited)
|
||||||
|
|
||||||
# Filter by part?
|
# Filter by part?
|
||||||
part = params.get('part', None)
|
part = params.get('part', None)
|
||||||
|
|
||||||
if part is not None:
|
if part is not None:
|
||||||
queryset = queryset.filter(part=part)
|
"""
|
||||||
|
If we are filtering by "part", there are two cases to consider:
|
||||||
|
|
||||||
|
a) Bom items which are defined for *this* part
|
||||||
|
b) Inherited parts which are defined for a *parent* part
|
||||||
|
|
||||||
|
So we need to construct two queries!
|
||||||
|
"""
|
||||||
|
|
||||||
|
# First, check that the part is actually valid!
|
||||||
|
try:
|
||||||
|
part = Part.objects.get(pk=part)
|
||||||
|
|
||||||
|
queryset = queryset.filter(part.get_bom_item_filter())
|
||||||
|
|
||||||
|
except (ValueError, Part.DoesNotExist):
|
||||||
|
pass
|
||||||
|
|
||||||
# Filter by sub-part?
|
# Filter by sub-part?
|
||||||
sub_part = params.get('sub_part', None)
|
sub_part = params.get('sub_part', None)
|
||||||
|
@ -331,6 +331,7 @@ class EditBomItemForm(HelperForm):
|
|||||||
'reference',
|
'reference',
|
||||||
'overage',
|
'overage',
|
||||||
'note',
|
'note',
|
||||||
|
'inherited',
|
||||||
'optional',
|
'optional',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
18
InvenTree/part/migrations/0063_bomitem_inherited.py
Normal file
18
InvenTree/part/migrations/0063_bomitem_inherited.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.0.7 on 2021-02-17 10:51
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('part', '0062_merge_20210105_0056'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='bomitem',
|
||||||
|
name='inherited',
|
||||||
|
field=models.BooleanField(default=False, help_text='This BOM item is inherited by BOMs for variant parts', verbose_name='Inherited'),
|
||||||
|
),
|
||||||
|
]
|
@ -14,7 +14,7 @@ from django.urls import reverse
|
|||||||
|
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.db.utils import IntegrityError
|
from django.db.utils import IntegrityError
|
||||||
from django.db.models import Sum, UniqueConstraint
|
from django.db.models import Q, Sum, UniqueConstraint
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.core.validators import MinValueValidator
|
from django.core.validators import MinValueValidator
|
||||||
|
|
||||||
@ -41,7 +41,7 @@ from InvenTree.models import InvenTreeTree, InvenTreeAttachment
|
|||||||
from InvenTree.fields import InvenTreeURLField
|
from InvenTree.fields import InvenTreeURLField
|
||||||
from InvenTree.helpers import decimal2string, normalize
|
from InvenTree.helpers import decimal2string, normalize
|
||||||
|
|
||||||
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus
|
from InvenTree.status_codes import BuildStatus, PurchaseOrderStatus, SalesOrderStatus
|
||||||
|
|
||||||
from build import models as BuildModels
|
from build import models as BuildModels
|
||||||
from order import models as OrderModels
|
from order import models as OrderModels
|
||||||
@ -418,8 +418,10 @@ class Part(MPTTModel):
|
|||||||
p2=str(parent)
|
p2=str(parent)
|
||||||
))})
|
))})
|
||||||
|
|
||||||
|
bom_items = self.get_bom_items()
|
||||||
|
|
||||||
# Ensure that the parent part does not appear under any child BOM item!
|
# Ensure that the parent part does not appear under any child BOM item!
|
||||||
for item in self.bom_items.all():
|
for item in bom_items.all():
|
||||||
|
|
||||||
# Check for simple match
|
# Check for simple match
|
||||||
if item.sub_part == parent:
|
if item.sub_part == parent:
|
||||||
@ -884,20 +886,135 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
return max(total, 0)
|
return max(total, 0)
|
||||||
|
|
||||||
|
def requiring_build_orders(self):
|
||||||
|
"""
|
||||||
|
Return list of outstanding build orders which require this part
|
||||||
|
"""
|
||||||
|
|
||||||
|
# List of BOM that this part is required for
|
||||||
|
boms = BomItem.objects.filter(sub_part=self)
|
||||||
|
|
||||||
|
part_ids = [bom.part.pk for bom in boms]
|
||||||
|
|
||||||
|
# Now, get a list of outstanding build orders which require this part
|
||||||
|
builds = BuildModels.Build.objects.filter(
|
||||||
|
part__in=part_ids,
|
||||||
|
status__in=BuildStatus.ACTIVE_CODES
|
||||||
|
)
|
||||||
|
|
||||||
|
return builds
|
||||||
|
|
||||||
|
def required_build_order_quantity(self):
|
||||||
|
"""
|
||||||
|
Return the quantity of this part required for active build orders
|
||||||
|
"""
|
||||||
|
|
||||||
|
# List of BOM that this part is required for
|
||||||
|
boms = BomItem.objects.filter(sub_part=self)
|
||||||
|
|
||||||
|
part_ids = [bom.part.pk for bom in boms]
|
||||||
|
|
||||||
|
# Now, get a list of outstanding build orders which require this part
|
||||||
|
builds = BuildModels.Build.objects.filter(
|
||||||
|
part__in=part_ids,
|
||||||
|
status__in=BuildStatus.ACTIVE_CODES
|
||||||
|
)
|
||||||
|
|
||||||
|
quantity = 0
|
||||||
|
|
||||||
|
for build in builds:
|
||||||
|
|
||||||
|
bom_item = None
|
||||||
|
|
||||||
|
# Match BOM item to build
|
||||||
|
for bom in boms:
|
||||||
|
if bom.part == build.part:
|
||||||
|
bom_item = bom
|
||||||
|
break
|
||||||
|
|
||||||
|
if bom_item is None:
|
||||||
|
logger.warning("Found null BomItem when calculating required quantity")
|
||||||
|
continue
|
||||||
|
|
||||||
|
build_quantity = build.quantity * bom_item.quantity
|
||||||
|
|
||||||
|
quantity += build_quantity
|
||||||
|
|
||||||
|
return quantity
|
||||||
|
|
||||||
|
def requiring_sales_orders(self):
|
||||||
|
"""
|
||||||
|
Return a list of sales orders which require this part
|
||||||
|
"""
|
||||||
|
|
||||||
|
orders = set()
|
||||||
|
|
||||||
|
# Get a list of line items for open orders which match this part
|
||||||
|
open_lines = OrderModels.SalesOrderLineItem.objects.filter(
|
||||||
|
order__status__in=SalesOrderStatus.OPEN,
|
||||||
|
part=self
|
||||||
|
)
|
||||||
|
|
||||||
|
for line in open_lines:
|
||||||
|
orders.add(line.order)
|
||||||
|
|
||||||
|
return orders
|
||||||
|
|
||||||
|
def required_sales_order_quantity(self):
|
||||||
|
"""
|
||||||
|
Return the quantity of this part required for active sales orders
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Get a list of line items for open orders which match this part
|
||||||
|
open_lines = OrderModels.SalesOrderLineItem.objects.filter(
|
||||||
|
order__status__in=SalesOrderStatus.OPEN,
|
||||||
|
part=self
|
||||||
|
)
|
||||||
|
|
||||||
|
quantity = 0
|
||||||
|
|
||||||
|
for line in open_lines:
|
||||||
|
quantity += line.quantity
|
||||||
|
|
||||||
|
return quantity
|
||||||
|
|
||||||
|
def required_order_quantity(self):
|
||||||
|
"""
|
||||||
|
Return total required to fulfil orders
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.required_build_order_quantity() + self.required_sales_order_quantity()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def quantity_to_order(self):
|
def quantity_to_order(self):
|
||||||
""" Return the quantity needing to be ordered for this part. """
|
"""
|
||||||
|
Return the quantity needing to be ordered for this part.
|
||||||
|
|
||||||
# How many do we need to have "on hand" at any point?
|
Here, an "order" could be one of:
|
||||||
required = self.net_stock - self.minimum_stock
|
- Build Order
|
||||||
|
- Sales Order
|
||||||
|
|
||||||
if required < 0:
|
To work out how many we need to order:
|
||||||
return abs(required)
|
|
||||||
|
|
||||||
# Do not need to order any
|
Stock on hand = self.total_stock
|
||||||
return 0
|
Required for orders = self.required_order_quantity()
|
||||||
|
Currently on order = self.on_order
|
||||||
|
Currently building = self.quantity_being_built
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Total requirement
|
||||||
|
required = self.required_order_quantity()
|
||||||
|
|
||||||
|
# Subtract stock levels
|
||||||
|
required -= max(self.total_stock, self.minimum_stock)
|
||||||
|
|
||||||
|
# Subtract quantity on order
|
||||||
|
required -= self.on_order
|
||||||
|
|
||||||
|
# Subtract quantity being built
|
||||||
|
required -= self.quantity_being_built
|
||||||
|
|
||||||
required = self.net_stock
|
|
||||||
return max(required, 0)
|
return max(required, 0)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -943,8 +1060,10 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
total = None
|
total = None
|
||||||
|
|
||||||
|
bom_items = self.get_bom_items().prefetch_related('sub_part__stock_items')
|
||||||
|
|
||||||
# Calculate the minimum number of parts that can be built using each sub-part
|
# Calculate the minimum number of parts that can be built using each sub-part
|
||||||
for item in self.bom_items.all().prefetch_related('sub_part__stock_items'):
|
for item in bom_items.all():
|
||||||
stock = item.sub_part.available_stock
|
stock = item.sub_part.available_stock
|
||||||
|
|
||||||
# If (by some chance) we get here but the BOM item quantity is invalid,
|
# If (by some chance) we get here but the BOM item quantity is invalid,
|
||||||
@ -979,16 +1098,22 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def quantity_being_built(self):
|
def quantity_being_built(self):
|
||||||
""" Return the current number of parts currently being built
|
"""
|
||||||
|
Return the current number of parts currently being built.
|
||||||
|
|
||||||
|
Note: This is the total quantity of Build orders, *not* the number of build outputs.
|
||||||
|
In this fashion, it is the "projected" quantity of builds
|
||||||
"""
|
"""
|
||||||
|
|
||||||
stock_items = self.stock_items.filter(is_building=True)
|
builds = self.active_builds
|
||||||
|
|
||||||
query = stock_items.aggregate(
|
quantity = 0
|
||||||
quantity=Coalesce(Sum('quantity'), Decimal(0))
|
|
||||||
)
|
|
||||||
|
|
||||||
return query['quantity']
|
for build in builds:
|
||||||
|
# The remaining items in the build
|
||||||
|
quantity += build.remaining
|
||||||
|
|
||||||
|
return quantity
|
||||||
|
|
||||||
def build_order_allocations(self):
|
def build_order_allocations(self):
|
||||||
"""
|
"""
|
||||||
@ -1068,9 +1193,56 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
return query['t']
|
return query['t']
|
||||||
|
|
||||||
|
def get_bom_item_filter(self, include_inherited=True):
|
||||||
|
"""
|
||||||
|
Returns a query filter for all BOM items associated with this Part.
|
||||||
|
|
||||||
|
There are some considerations:
|
||||||
|
|
||||||
|
a) BOM items can be defined against *this* part
|
||||||
|
b) BOM items can be inherited from a *parent* part
|
||||||
|
|
||||||
|
We will construct a filter to grab *all* the BOM items!
|
||||||
|
|
||||||
|
Note: This does *not* return a queryset, it returns a Q object,
|
||||||
|
which can be used by some other query operation!
|
||||||
|
Because we want to keep our code DRY!
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
bom_filter = Q(part=self)
|
||||||
|
|
||||||
|
if include_inherited:
|
||||||
|
# We wish to include parent parts
|
||||||
|
|
||||||
|
parents = self.get_ancestors(include_self=False)
|
||||||
|
|
||||||
|
# There are parents available
|
||||||
|
if parents.count() > 0:
|
||||||
|
parent_ids = [p.pk for p in parents]
|
||||||
|
|
||||||
|
parent_filter = Q(
|
||||||
|
part__id__in=parent_ids,
|
||||||
|
inherited=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# OR the filters together
|
||||||
|
bom_filter |= parent_filter
|
||||||
|
|
||||||
|
return bom_filter
|
||||||
|
|
||||||
|
def get_bom_items(self, include_inherited=True):
|
||||||
|
"""
|
||||||
|
Return a queryset containing all BOM items for this part
|
||||||
|
|
||||||
|
By default, will include inherited BOM items
|
||||||
|
"""
|
||||||
|
|
||||||
|
return BomItem.objects.filter(self.get_bom_item_filter(include_inherited=include_inherited))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_bom(self):
|
def has_bom(self):
|
||||||
return self.bom_count > 0
|
return self.get_bom_items().count() > 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def has_trackable_parts(self):
|
def has_trackable_parts(self):
|
||||||
@ -1079,7 +1251,7 @@ class Part(MPTTModel):
|
|||||||
This is important when building the part.
|
This is important when building the part.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for bom_item in self.bom_items.all():
|
for bom_item in self.get_bom_items().all():
|
||||||
if bom_item.sub_part.trackable:
|
if bom_item.sub_part.trackable:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -1088,7 +1260,7 @@ class Part(MPTTModel):
|
|||||||
@property
|
@property
|
||||||
def bom_count(self):
|
def bom_count(self):
|
||||||
""" Return the number of items contained in the BOM for this part """
|
""" Return the number of items contained in the BOM for this part """
|
||||||
return self.bom_items.count()
|
return self.get_bom_items().count()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def used_in_count(self):
|
def used_in_count(self):
|
||||||
@ -1106,7 +1278,10 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
hash = hashlib.md5(str(self.id).encode())
|
hash = hashlib.md5(str(self.id).encode())
|
||||||
|
|
||||||
for item in self.bom_items.all().prefetch_related('sub_part'):
|
# List *all* BOM items (including inherited ones!)
|
||||||
|
bom_items = self.get_bom_items().all().prefetch_related('sub_part')
|
||||||
|
|
||||||
|
for item in bom_items:
|
||||||
hash.update(str(item.get_item_hash()).encode())
|
hash.update(str(item.get_item_hash()).encode())
|
||||||
|
|
||||||
return str(hash.digest())
|
return str(hash.digest())
|
||||||
@ -1125,8 +1300,10 @@ class Part(MPTTModel):
|
|||||||
- Saves the current date and the checking user
|
- Saves the current date and the checking user
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Validate each line item too
|
# Validate each line item, ignoring inherited ones
|
||||||
for item in self.bom_items.all():
|
bom_items = self.get_bom_items(include_inherited=False)
|
||||||
|
|
||||||
|
for item in bom_items.all():
|
||||||
item.validate_hash()
|
item.validate_hash()
|
||||||
|
|
||||||
self.bom_checksum = self.get_bom_hash()
|
self.bom_checksum = self.get_bom_hash()
|
||||||
@ -1137,7 +1314,10 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def clear_bom(self):
|
def clear_bom(self):
|
||||||
""" Clear the BOM items for the part (delete all BOM lines).
|
"""
|
||||||
|
Clear the BOM items for the part (delete all BOM lines).
|
||||||
|
|
||||||
|
Note: Does *NOT* delete inherited BOM items!
|
||||||
"""
|
"""
|
||||||
|
|
||||||
self.bom_items.all().delete()
|
self.bom_items.all().delete()
|
||||||
@ -1154,9 +1334,9 @@ class Part(MPTTModel):
|
|||||||
if parts is None:
|
if parts is None:
|
||||||
parts = set()
|
parts = set()
|
||||||
|
|
||||||
items = BomItem.objects.filter(part=self.pk)
|
bom_items = self.get_bom_items().all()
|
||||||
|
|
||||||
for bom_item in items:
|
for bom_item in bom_items:
|
||||||
|
|
||||||
sub_part = bom_item.sub_part
|
sub_part = bom_item.sub_part
|
||||||
|
|
||||||
@ -1204,7 +1384,7 @@ class Part(MPTTModel):
|
|||||||
def has_complete_bom_pricing(self):
|
def has_complete_bom_pricing(self):
|
||||||
""" Return true if there is pricing information for each item in the BOM. """
|
""" Return true if there is pricing information for each item in the BOM. """
|
||||||
|
|
||||||
for item in self.bom_items.all().select_related('sub_part'):
|
for item in self.get_bom_items().all().select_related('sub_part'):
|
||||||
if not item.sub_part.has_pricing_info:
|
if not item.sub_part.has_pricing_info:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@ -1271,7 +1451,7 @@ class Part(MPTTModel):
|
|||||||
min_price = None
|
min_price = None
|
||||||
max_price = None
|
max_price = None
|
||||||
|
|
||||||
for item in self.bom_items.all().select_related('sub_part'):
|
for item in self.get_bom_items().all().select_related('sub_part'):
|
||||||
|
|
||||||
if item.sub_part.pk == self.pk:
|
if item.sub_part.pk == self.pk:
|
||||||
print("Warning: Item contains itself in BOM")
|
print("Warning: Item contains itself in BOM")
|
||||||
@ -1339,8 +1519,11 @@ class Part(MPTTModel):
|
|||||||
|
|
||||||
if clear:
|
if clear:
|
||||||
# Remove existing BOM items
|
# Remove existing BOM items
|
||||||
|
# Note: Inherited BOM items are *not* deleted!
|
||||||
self.bom_items.all().delete()
|
self.bom_items.all().delete()
|
||||||
|
|
||||||
|
# Copy existing BOM items from another part
|
||||||
|
# Note: Inherited BOM Items will *not* be duplicated!!
|
||||||
for bom_item in other.bom_items.all():
|
for bom_item in other.bom_items.all():
|
||||||
# If this part already has a BomItem pointing to the same sub-part,
|
# If this part already has a BomItem pointing to the same sub-part,
|
||||||
# delete that BomItem from this part first!
|
# delete that BomItem from this part first!
|
||||||
@ -1856,6 +2039,7 @@ class BomItem(models.Model):
|
|||||||
overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%')
|
overage: Estimated losses for a Build. Can be expressed as absolute value (e.g. '7') or a percentage (e.g. '2%')
|
||||||
note: Note field for this BOM item
|
note: Note field for this BOM item
|
||||||
checksum: Validation checksum for the particular BOM line item
|
checksum: Validation checksum for the particular BOM line item
|
||||||
|
inherited: This BomItem can be inherited by the BOMs of variant parts
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
@ -1895,6 +2079,12 @@ class BomItem(models.Model):
|
|||||||
|
|
||||||
checksum = models.CharField(max_length=128, blank=True, help_text=_('BOM line checksum'))
|
checksum = models.CharField(max_length=128, blank=True, help_text=_('BOM line checksum'))
|
||||||
|
|
||||||
|
inherited = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_('Inherited'),
|
||||||
|
help_text=_('This BOM item is inherited by BOMs for variant parts'),
|
||||||
|
)
|
||||||
|
|
||||||
def get_item_hash(self):
|
def get_item_hash(self):
|
||||||
""" Calculate the checksum hash of this BOM line item:
|
""" Calculate the checksum hash of this BOM line item:
|
||||||
|
|
||||||
|
@ -381,17 +381,18 @@ class BomItemSerializer(InvenTreeModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = BomItem
|
model = BomItem
|
||||||
fields = [
|
fields = [
|
||||||
|
'inherited',
|
||||||
|
'note',
|
||||||
|
'optional',
|
||||||
|
'overage',
|
||||||
'pk',
|
'pk',
|
||||||
'part',
|
'part',
|
||||||
'part_detail',
|
'part_detail',
|
||||||
'sub_part',
|
|
||||||
'sub_part_detail',
|
|
||||||
'quantity',
|
'quantity',
|
||||||
'reference',
|
'reference',
|
||||||
|
'sub_part',
|
||||||
|
'sub_part_detail',
|
||||||
# 'price_range',
|
# 'price_range',
|
||||||
'optional',
|
|
||||||
'overage',
|
|
||||||
'note',
|
|
||||||
'validated',
|
'validated',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -35,45 +35,46 @@
|
|||||||
<span class='fas fa-trash-alt icon-red'></span>
|
<span class='fas fa-trash-alt icon-red'></span>
|
||||||
</button>
|
</button>
|
||||||
<button class='btn btn-primary' type='button' title='{% trans "Import BOM data" %}' id='bom-upload'>
|
<button class='btn btn-primary' type='button' title='{% trans "Import BOM data" %}' id='bom-upload'>
|
||||||
<span class='fas fa-file-upload'></span> {% trans "Import from File" %}
|
<span class='fas fa-file-upload'></span>
|
||||||
</button>
|
</button>
|
||||||
{% if part.variant_of %}
|
{% if part.variant_of %}
|
||||||
<button class='btn btn-default' type='button' title='{% trans "Copy BOM from parent part" %}' id='bom-duplicate'>
|
<button class='btn btn-default' type='button' title='{% trans "Copy BOM from parent part" %}' id='bom-duplicate'>
|
||||||
<span class='fas fa-clone'></span> {% trans "Copy from Parent" %}
|
<span class='fas fa-clone'></span>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button class='btn btn-default' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new'>
|
<button class='btn btn-default' type='button' title='{% trans "New BOM Item" %}' id='bom-item-new'>
|
||||||
<span class='fas fa-plus-circle'></span> {% trans "Add Item" %}
|
<span class='fas fa-plus-circle'></span>
|
||||||
</button>
|
</button>
|
||||||
<button class='btn btn-success' type='button' title='{% trans "Finish Editing" %}' id='editing-finished'>
|
<button class='btn btn-success' type='button' title='{% trans "Finish Editing" %}' id='editing-finished'>
|
||||||
<span class='fas fa-check-circle'></span> {% trans "Finished" %}
|
<span class='fas fa-check-circle'></span>
|
||||||
</button>
|
</button>
|
||||||
{% elif part.active %}
|
{% elif part.active %}
|
||||||
{% if roles.part.change %}
|
{% if roles.part.change %}
|
||||||
<button class='btn btn-primary' type='button' title='{% trans "Edit BOM" %}' id='edit-bom'>
|
<button class='btn btn-primary' type='button' title='{% trans "Edit BOM" %}' id='edit-bom'>
|
||||||
<span class='fas fa-edit'></span> {% trans "Edit" %}
|
<span class='fas fa-edit'></span>
|
||||||
</button>
|
</button>
|
||||||
{% if part.is_bom_valid == False %}
|
{% if part.is_bom_valid == False %}
|
||||||
<button class='btn btn-success' id='validate-bom' title='{% trans "Validate Bill of Materials" %}' type='button'>
|
<button class='btn btn-success' id='validate-bom' title='{% trans "Validate Bill of Materials" %}' type='button'>
|
||||||
<span class='fas fa-clipboard-check'></span> {% trans "Validate" %}
|
<span class='fas fa-clipboard-check'></span>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
<button title='{% trans "Export Bill of Materials" %}' class='btn btn-default' id='download-bom' type='button'>
|
<button title='{% trans "Export Bill of Materials" %}' class='btn btn-default' id='download-bom' type='button'>
|
||||||
<span class='fas fa-file-download'></span> {% trans "Export" %}
|
<span class='fas fa-file-download'></span>
|
||||||
|
</button>
|
||||||
|
<button title='{% trans "Print BOM Report" %}' class='btn btn-default' id='print-bom-report' type='button'>
|
||||||
|
<span class='fas fa-file-pdf'></span>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
|
||||||
<div class='filter-list' id='filter-list-bom'>
|
<div class='filter-list' id='filter-list-bom'>
|
||||||
<!-- Empty div (will be filled out with avilable BOM filters) -->
|
<!-- Empty div (will be filled out with avilable BOM filters) -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class='table table-striped table-condensed' data-toolbar="#button-toolbar" id='bom-table'>
|
<table class='table table-bom table-condensed' data-toolbar="#button-toolbar" id='bom-table'>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<table class='table table-striped table-condensed' id='test-table'></table>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block js_load %}
|
{% block js_load %}
|
||||||
@ -215,4 +216,8 @@
|
|||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
$("#print-bom-report").click(function() {
|
||||||
|
printBomReports([{{ part.pk }}]);
|
||||||
|
});
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -97,11 +97,13 @@
|
|||||||
</a></td>
|
</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if part.units %}
|
||||||
<tr>
|
<tr>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td><b>{% trans "Units" %}</b></td>
|
<td><b>{% trans "Units" %}</b></td>
|
||||||
<td>{{ part.units }}</td>
|
<td>{{ part.units }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% endif %}
|
||||||
{% if part.minimum_stock > 0 %}
|
{% if part.minimum_stock > 0 %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-greater-than-equal'></span></td>
|
<td><span class='fas fa-greater-than-equal'></span></td>
|
||||||
|
@ -119,36 +119,35 @@
|
|||||||
<td>
|
<td>
|
||||||
<h4>{% trans "Available Stock" %}</h4>
|
<h4>{% trans "Available Stock" %}</h4>
|
||||||
</td>
|
</td>
|
||||||
<td><h4>{% decimal part.available_stock %} {{ part.units }}</h4></td>
|
<td><h4>{% decimal available %}{% if part.units %} {{ part.units }}{% endif %}</h4></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-map-marker-alt'></span></td>
|
<td><span class='fas fa-map-marker-alt'></span></td>
|
||||||
<td>{% trans "In Stock" %}</td>
|
<td>{% trans "In Stock" %}</td>
|
||||||
<td>{% include "part/stock_count.html" %}</td>
|
<td>{% include "part/stock_count.html" %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if not part.is_template %}
|
{% if on_order > 0 %}
|
||||||
{% if part.build_order_allocation_count > 0 %}
|
|
||||||
<tr>
|
|
||||||
<td><span class='fas fa-dolly'></span></td>
|
|
||||||
<td>{% trans "Allocated to Build Orders" %}</td>
|
|
||||||
<td>{% decimal part.build_order_allocation_count %}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{% if part.sales_order_allocation_count > 0 %}
|
|
||||||
<tr>
|
|
||||||
<td><span class='fas fa-dolly'></span></td>
|
|
||||||
<td>{% trans "Allocated to Sales Orders" %}</td>
|
|
||||||
<td>{% decimal part.sales_order_allocation_count %}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
{% if part.on_order > 0 %}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class='fas fa-shopping-cart'></span></td>
|
<td><span class='fas fa-shopping-cart'></span></td>
|
||||||
<td>{% trans "On Order" %}</td>
|
<td>{% trans "On Order" %}</td>
|
||||||
<td>{% decimal part.on_order %}</td>
|
<td>{% decimal on_order %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if required > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td><span class='fas fa-clipboard-list'></span></td>
|
||||||
|
<td>{% trans "Required for Orders" %}</td>
|
||||||
|
<td>{% decimal required %}
|
||||||
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if allocated > 0 %}
|
||||||
|
<tr>
|
||||||
|
<td><span class='fas fa-dolly'></span></td>
|
||||||
|
<td>{% trans "Allocated to Orders" %}</td>
|
||||||
|
<td>{% decimal allocated %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if not part.is_template %}
|
{% if not part.is_template %}
|
||||||
{% if part.assembly %}
|
{% if part.assembly %}
|
||||||
<tr>
|
<tr>
|
||||||
@ -162,11 +161,11 @@
|
|||||||
<td>{% trans "Can Build" %}</td>
|
<td>{% trans "Can Build" %}</td>
|
||||||
<td>{% decimal part.can_build %}</td>
|
<td>{% decimal part.can_build %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if part.quantity_being_built > 0 %}
|
{% if quantity_being_built > 0 %}
|
||||||
<tr>
|
<tr>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td>{% trans "Underway" %}</td>
|
<td>{% trans "Building" %}</td>
|
||||||
<td>{% decimal part.quantity_being_built %}</td>
|
<td>{% decimal quantity_being_built %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
{% load inventree_extras %}
|
{% load inventree_extras %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% decimal part.total_stock %}
|
{% decimal total_stock %}
|
||||||
|
|
||||||
{% if part.total_stock == 0 %}
|
{% if total_stock == 0 %}
|
||||||
<span class='label label-danger label-right'>{% trans "No Stock" %}</span>
|
<span class='label label-danger label-right'>{% trans "No Stock" %}</span>
|
||||||
{% elif part.total_stock < part.minimum_stock %}
|
{% elif total_stock < part.minimum_stock %}
|
||||||
<span class='label label-warning label-right'>{% trans "Low Stock" %}</span>
|
<span class='label label-warning label-right'>{% trans "Low Stock" %}</span>
|
||||||
{% endif %}
|
{% endif %}
|
@ -15,12 +15,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not part.virtual %}
|
{% if not part.virtual %}
|
||||||
<li{% ifequal tab 'stock' %} class="active"{% endifequal %}>
|
<li{% ifequal tab 'stock' %} class="active"{% endifequal %}>
|
||||||
<a href="{% url 'part-stock' part.id %}">{% trans "Stock" %} <span class="badge">{% decimal part.total_stock %}</span></a>
|
<a href="{% url 'part-stock' part.id %}">{% trans "Stock" %} <span class="badge">{% decimal total_stock %}</span></a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if part.component or part.salable or part.used_in_count > 0 %}
|
{% if part.component or part.salable or part.used_in_count > 0 %}
|
||||||
<li{% ifequal tab 'allocation' %} class="active"{% endifequal %}>
|
<li{% ifequal tab 'allocation' %} class="active"{% endifequal %}>
|
||||||
<a href="{% url 'part-allocation' part.id %}">{% trans "Allocated" %} <span class="badge">{% decimal part.allocation_count %}</span></a>
|
<a href="{% url 'part-allocation' part.id %}">{% trans "Allocated" %} <span class="badge">{% decimal allocated %}</span></a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if part.assembly %}
|
{% if part.assembly %}
|
||||||
|
@ -792,6 +792,22 @@ class PartDetail(InvenTreeRoleMixin, DetailView):
|
|||||||
context['starred'] = part.isStarredBy(self.request.user)
|
context['starred'] = part.isStarredBy(self.request.user)
|
||||||
context['disabled'] = not part.active
|
context['disabled'] = not part.active
|
||||||
|
|
||||||
|
# Pre-calculate complex queries so they only need to be performed once
|
||||||
|
context['total_stock'] = part.total_stock
|
||||||
|
|
||||||
|
context['quantity_being_built'] = part.quantity_being_built
|
||||||
|
|
||||||
|
context['required_build_order_quantity'] = part.required_build_order_quantity()
|
||||||
|
context['allocated_build_order_quantity'] = part.build_order_allocation_count()
|
||||||
|
|
||||||
|
context['required_sales_order_quantity'] = part.required_sales_order_quantity()
|
||||||
|
context['allocated_sales_order_quantity'] = part.sales_order_allocation_count()
|
||||||
|
|
||||||
|
context['available'] = part.available_stock
|
||||||
|
context['on_order'] = part.on_order
|
||||||
|
context['required'] = context['required_build_order_quantity'] + context['required_sales_order_quantity']
|
||||||
|
context['allocated'] = context['allocated_build_order_quantity'] + context['allocated_sales_order_quantity']
|
||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
@ -3,7 +3,10 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import ReportSnippet, TestReport, ReportAsset
|
from .models import ReportSnippet, ReportAsset
|
||||||
|
from .models import TestReport
|
||||||
|
from .models import BuildReport
|
||||||
|
from .models import BillOfMaterialsReport
|
||||||
|
|
||||||
|
|
||||||
class ReportTemplateAdmin(admin.ModelAdmin):
|
class ReportTemplateAdmin(admin.ModelAdmin):
|
||||||
@ -22,5 +25,8 @@ class ReportAssetAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
admin.site.register(ReportSnippet, ReportSnippetAdmin)
|
admin.site.register(ReportSnippet, ReportSnippetAdmin)
|
||||||
admin.site.register(TestReport, ReportTemplateAdmin)
|
|
||||||
admin.site.register(ReportAsset, ReportAssetAdmin)
|
admin.site.register(ReportAsset, ReportAssetAdmin)
|
||||||
|
|
||||||
|
admin.site.register(TestReport, ReportTemplateAdmin)
|
||||||
|
admin.site.register(BuildReport, ReportTemplateAdmin)
|
||||||
|
admin.site.register(BillOfMaterialsReport, ReportTemplateAdmin)
|
||||||
|
@ -3,6 +3,7 @@ from __future__ import unicode_literals
|
|||||||
|
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from django.conf.urls import url, include
|
from django.conf.urls import url, include
|
||||||
|
from django.core.exceptions import ValidationError, FieldError
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
@ -15,8 +16,16 @@ import InvenTree.helpers
|
|||||||
|
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
|
|
||||||
|
import build.models
|
||||||
|
import part.models
|
||||||
|
|
||||||
from .models import TestReport
|
from .models import TestReport
|
||||||
|
from .models import BuildReport
|
||||||
|
from .models import BillOfMaterialsReport
|
||||||
|
|
||||||
from .serializers import TestReportSerializer
|
from .serializers import TestReportSerializer
|
||||||
|
from .serializers import BuildReportSerializer
|
||||||
|
from .serializers import BOMReportSerializer
|
||||||
|
|
||||||
|
|
||||||
class ReportListView(generics.ListAPIView):
|
class ReportListView(generics.ListAPIView):
|
||||||
@ -53,13 +62,7 @@ class StockItemReportMixin:
|
|||||||
|
|
||||||
params = self.request.query_params
|
params = self.request.query_params
|
||||||
|
|
||||||
if 'items[]' in params:
|
items = params.getlist('item', [])
|
||||||
items = params.getlist('items[]', [])
|
|
||||||
elif 'item' in params:
|
|
||||||
items = [params.get('item', None)]
|
|
||||||
|
|
||||||
if type(items) not in [list, tuple]:
|
|
||||||
item = [items]
|
|
||||||
|
|
||||||
valid_ids = []
|
valid_ids = []
|
||||||
|
|
||||||
@ -75,6 +78,131 @@ class StockItemReportMixin:
|
|||||||
return valid_items
|
return valid_items
|
||||||
|
|
||||||
|
|
||||||
|
class BuildReportMixin:
|
||||||
|
"""
|
||||||
|
Mixin for extracting Build items from query params
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_builds(self):
|
||||||
|
"""
|
||||||
|
Return a list of requested Build objects
|
||||||
|
"""
|
||||||
|
|
||||||
|
builds = []
|
||||||
|
|
||||||
|
params = self.request.query_params
|
||||||
|
|
||||||
|
builds = params.getlist('build', [])
|
||||||
|
|
||||||
|
valid_ids = []
|
||||||
|
|
||||||
|
for b in builds:
|
||||||
|
try:
|
||||||
|
valid_ids.append(int(b))
|
||||||
|
except (ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return build.models.Build.objects.filter(pk__in=valid_ids)
|
||||||
|
|
||||||
|
|
||||||
|
class PartReportMixin:
|
||||||
|
"""
|
||||||
|
Mixin for extracting part items from query params
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_parts(self):
|
||||||
|
"""
|
||||||
|
Return a list of requested part objects
|
||||||
|
"""
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
params = self.request.query_params
|
||||||
|
|
||||||
|
parts = params.getlist('part', [])
|
||||||
|
|
||||||
|
valid_ids = []
|
||||||
|
|
||||||
|
for p in parts:
|
||||||
|
try:
|
||||||
|
valid_ids.append(int(p))
|
||||||
|
except (ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract a valid set of Part objects
|
||||||
|
valid_parts = part.models.Part.objects.filter(pk__in=valid_ids)
|
||||||
|
|
||||||
|
return valid_parts
|
||||||
|
|
||||||
|
|
||||||
|
class ReportPrintMixin:
|
||||||
|
"""
|
||||||
|
Mixin for printing reports
|
||||||
|
"""
|
||||||
|
|
||||||
|
def print(self, request, items_to_print):
|
||||||
|
"""
|
||||||
|
Print this report template against a number of pre-validated items.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if len(items_to_print) == 0:
|
||||||
|
# No valid items provided, return an error message
|
||||||
|
data = {
|
||||||
|
'error': _('No valid objects provided to template'),
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response(data, status=400)
|
||||||
|
|
||||||
|
outputs = []
|
||||||
|
|
||||||
|
# In debug mode, generate single HTML output, rather than PDF
|
||||||
|
debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE')
|
||||||
|
|
||||||
|
# Merge one or more PDF files into a single download
|
||||||
|
for item in items_to_print:
|
||||||
|
report = self.get_object()
|
||||||
|
report.object_to_print = item
|
||||||
|
|
||||||
|
if debug_mode:
|
||||||
|
outputs.append(report.render_to_string(request))
|
||||||
|
else:
|
||||||
|
outputs.append(report.render(request))
|
||||||
|
|
||||||
|
if debug_mode:
|
||||||
|
"""
|
||||||
|
Contatenate all rendered templates into a single HTML string,
|
||||||
|
and return the string as a HTML response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
html = "\n".join(outputs)
|
||||||
|
|
||||||
|
return HttpResponse(html)
|
||||||
|
else:
|
||||||
|
"""
|
||||||
|
Concatenate all rendered pages into a single PDF object,
|
||||||
|
and return the resulting document!
|
||||||
|
"""
|
||||||
|
|
||||||
|
pages = []
|
||||||
|
|
||||||
|
if len(outputs) > 1:
|
||||||
|
# If more than one output is generated, merge them into a single file
|
||||||
|
for output in outputs:
|
||||||
|
doc = output.get_document()
|
||||||
|
for page in doc.pages:
|
||||||
|
pages.append(page)
|
||||||
|
|
||||||
|
pdf = outputs[0].get_document().copy(pages).write_pdf()
|
||||||
|
else:
|
||||||
|
pdf = outputs[0].get_document().write_pdf()
|
||||||
|
|
||||||
|
return InvenTree.helpers.DownloadFile(
|
||||||
|
pdf,
|
||||||
|
'inventree_report.pdf',
|
||||||
|
content_type='application/pdf'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class StockItemTestReportList(ReportListView, StockItemReportMixin):
|
class StockItemTestReportList(ReportListView, StockItemReportMixin):
|
||||||
"""
|
"""
|
||||||
API endpoint for viewing list of TestReport objects.
|
API endpoint for viewing list of TestReport objects.
|
||||||
@ -82,8 +210,7 @@ class StockItemTestReportList(ReportListView, StockItemReportMixin):
|
|||||||
Filterable by:
|
Filterable by:
|
||||||
|
|
||||||
- enabled: Filter by enabled / disabled status
|
- enabled: Filter by enabled / disabled status
|
||||||
- item: Filter by single stock item
|
- item: Filter by stock item(s)
|
||||||
- items: Filter by list of stock items
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -114,14 +241,21 @@ class StockItemTestReportList(ReportListView, StockItemReportMixin):
|
|||||||
matches = True
|
matches = True
|
||||||
|
|
||||||
# Filter string defined for the report object
|
# Filter string defined for the report object
|
||||||
|
try:
|
||||||
filters = InvenTree.helpers.validateFilterString(report.filters)
|
filters = InvenTree.helpers.validateFilterString(report.filters)
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
item_query = StockItem.objects.filter(pk=item.pk)
|
item_query = StockItem.objects.filter(pk=item.pk)
|
||||||
|
|
||||||
|
try:
|
||||||
if not item_query.filter(**filters).exists():
|
if not item_query.filter(**filters).exists():
|
||||||
matches = False
|
matches = False
|
||||||
break
|
break
|
||||||
|
except FieldError:
|
||||||
|
matches = False
|
||||||
|
break
|
||||||
|
|
||||||
if matches:
|
if matches:
|
||||||
valid_report_ids.add(report.pk)
|
valid_report_ids.add(report.pk)
|
||||||
@ -142,7 +276,7 @@ class StockItemTestReportDetail(generics.RetrieveUpdateDestroyAPIView):
|
|||||||
serializer_class = TestReportSerializer
|
serializer_class = TestReportSerializer
|
||||||
|
|
||||||
|
|
||||||
class StockItemTestReportPrint(generics.RetrieveAPIView, StockItemReportMixin):
|
class StockItemTestReportPrint(generics.RetrieveAPIView, StockItemReportMixin, ReportPrintMixin):
|
||||||
"""
|
"""
|
||||||
API endpoint for printing a TestReport object
|
API endpoint for printing a TestReport object
|
||||||
"""
|
"""
|
||||||
@ -157,67 +291,212 @@ class StockItemTestReportPrint(generics.RetrieveAPIView, StockItemReportMixin):
|
|||||||
|
|
||||||
items = self.get_items()
|
items = self.get_items()
|
||||||
|
|
||||||
if len(items) == 0:
|
return self.print(request, items)
|
||||||
# No valid items provided, return an error message
|
|
||||||
data = {
|
|
||||||
'error': _('Must provide valid StockItem(s)')
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response(data, status=400)
|
|
||||||
|
|
||||||
outputs = []
|
class BOMReportList(ReportListView, PartReportMixin):
|
||||||
|
"""
|
||||||
|
API endpoint for viewing a list of BillOfMaterialReport objects.
|
||||||
|
|
||||||
# In debug mode, generate single HTML output, rather than PDF
|
Filterably by:
|
||||||
debug_mode = common.models.InvenTreeSetting.get_setting('REPORT_DEBUG_MODE')
|
|
||||||
|
|
||||||
# Merge one or more PDF files into a single download
|
- enabled: Filter by enabled / disabled status
|
||||||
for item in items:
|
- part: Filter by part(s)
|
||||||
report = self.get_object()
|
"""
|
||||||
report.stock_item = item
|
|
||||||
|
|
||||||
if debug_mode:
|
queryset = BillOfMaterialsReport.objects.all()
|
||||||
outputs.append(report.render_to_string(request))
|
serializer_class = BOMReportSerializer
|
||||||
|
|
||||||
|
def filter_queryset(self, queryset):
|
||||||
|
|
||||||
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
|
# List of Part objects to match against
|
||||||
|
parts = self.get_parts()
|
||||||
|
|
||||||
|
if len(parts) > 0:
|
||||||
|
"""
|
||||||
|
We wish to filter by part(s).
|
||||||
|
|
||||||
|
We need to compare the 'filters' string of each report,
|
||||||
|
and see if it matches against each of the specified parts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
valid_report_ids = set()
|
||||||
|
|
||||||
|
for report in queryset.all():
|
||||||
|
|
||||||
|
matches = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
filters = InvenTree.helpers.validateFilterString(report.filters)
|
||||||
|
except ValidationError:
|
||||||
|
# Filters are ill-defined
|
||||||
|
continue
|
||||||
|
|
||||||
|
for p in parts:
|
||||||
|
part_query = part.models.Part.objects.filter(pk=p.pk)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not part_query.filter(**filters).exists():
|
||||||
|
matches = False
|
||||||
|
break
|
||||||
|
except FieldError:
|
||||||
|
matches = False
|
||||||
|
break
|
||||||
|
|
||||||
|
if matches:
|
||||||
|
valid_report_ids.add(report.pk)
|
||||||
else:
|
else:
|
||||||
outputs.append(report.render(request))
|
continue
|
||||||
|
|
||||||
if debug_mode:
|
# Reduce queryset to only valid matches
|
||||||
|
queryset = queryset.filter(pk__in=[pk for pk in valid_report_ids])
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
class BOMReportDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""
|
"""
|
||||||
Contatenate all rendered templates into a single HTML string,
|
API endpoint for a single BillOfMaterialReport object
|
||||||
and return the string as a HTML response.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
html = "\n".join(outputs)
|
queryset = BillOfMaterialsReport.objects.all()
|
||||||
|
serializer_class = BOMReportSerializer
|
||||||
|
|
||||||
return HttpResponse(html)
|
|
||||||
|
|
||||||
|
class BOMReportPrint(generics.RetrieveAPIView, PartReportMixin, ReportPrintMixin):
|
||||||
|
"""
|
||||||
|
API endpoint for printing a BillOfMaterialReport object
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = BillOfMaterialsReport.objects.all()
|
||||||
|
serializer_class = BOMReportSerializer
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Check if valid part item(s) have been provided
|
||||||
|
"""
|
||||||
|
|
||||||
|
parts = self.get_parts()
|
||||||
|
|
||||||
|
return self.print(request, parts)
|
||||||
|
|
||||||
|
|
||||||
|
class BuildReportList(ReportListView, BuildReportMixin):
|
||||||
|
"""
|
||||||
|
API endpoint for viewing a list of BuildReport objects.
|
||||||
|
|
||||||
|
Can be filtered by:
|
||||||
|
|
||||||
|
- enabled: Filter by enabled / disabled status
|
||||||
|
- build: Filter by Build object
|
||||||
|
"""
|
||||||
|
|
||||||
|
queryset = BuildReport.objects.all()
|
||||||
|
serializer_class = BuildReportSerializer
|
||||||
|
|
||||||
|
def filter_queryset(self, queryset):
|
||||||
|
|
||||||
|
queryset = super().filter_queryset(queryset)
|
||||||
|
|
||||||
|
# List of Build objects to match against
|
||||||
|
builds = self.get_builds()
|
||||||
|
|
||||||
|
if len(builds) > 0:
|
||||||
|
"""
|
||||||
|
We wish to filter by Build(s)
|
||||||
|
|
||||||
|
We need to compare the 'filters' string of each report,
|
||||||
|
and see if it matches against each of the specified parts
|
||||||
|
|
||||||
|
# TODO: This code needs some refactoring!
|
||||||
|
"""
|
||||||
|
|
||||||
|
valid_build_ids = set()
|
||||||
|
|
||||||
|
for report in queryset.all():
|
||||||
|
|
||||||
|
matches = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
filters = InvenTree.helpers.validateFilterString(report.filters)
|
||||||
|
except ValidationError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for b in builds:
|
||||||
|
build_query = build.models.Build.objects.filter(pk=b.pk)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not build_query.filter(**filters).exists():
|
||||||
|
matches = False
|
||||||
|
break
|
||||||
|
except FieldError:
|
||||||
|
matches = False
|
||||||
|
break
|
||||||
|
|
||||||
|
if matches:
|
||||||
|
valid_build_ids.add(report.pk)
|
||||||
else:
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Reduce queryset to only valid matches
|
||||||
|
queryset = queryset.filter(pk__in=[pk for pk in valid_build_ids])
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
class BuildReportDetail(generics.RetrieveUpdateDestroyAPIView):
|
||||||
"""
|
"""
|
||||||
Concatenate all rendered pages into a single PDF object,
|
API endpoint for a single BuildReport object
|
||||||
and return the resulting document!
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
pages = []
|
queryset = BuildReport.objects.all()
|
||||||
|
serializer_class = BuildReportSerializer
|
||||||
|
|
||||||
if len(outputs) > 1:
|
|
||||||
# If more than one output is generated, merge them into a single file
|
|
||||||
for output in outputs:
|
|
||||||
doc = output.get_document()
|
|
||||||
for page in doc.pages:
|
|
||||||
pages.append(page)
|
|
||||||
|
|
||||||
pdf = outputs[0].get_document().copy(pages).write_pdf()
|
class BuildReportPrint(generics.RetrieveAPIView, BuildReportMixin, ReportPrintMixin):
|
||||||
else:
|
"""
|
||||||
pdf = outputs[0].get_document().write_pdf()
|
API endpoint for printing a BuildReport
|
||||||
|
"""
|
||||||
|
|
||||||
return InvenTree.helpers.DownloadFile(
|
queryset = BuildReport.objects.all()
|
||||||
pdf,
|
serializer_class = BuildReportSerializer
|
||||||
'test_report.pdf',
|
|
||||||
content_type='application/pdf'
|
def get(self, request, *ars, **kwargs):
|
||||||
)
|
|
||||||
|
builds = self.get_builds()
|
||||||
|
|
||||||
|
return self.print(request, builds)
|
||||||
|
|
||||||
|
|
||||||
report_api_urls = [
|
report_api_urls = [
|
||||||
|
|
||||||
|
# Build reports
|
||||||
|
url(r'build/', include([
|
||||||
|
# Detail views
|
||||||
|
url(r'^(?P<pk>\d+)/', include([
|
||||||
|
url(r'print/?', BuildReportPrint.as_view(), name='api-build-report-print'),
|
||||||
|
url(r'^.*$', BuildReportDetail.as_view(), name='api-build-report-detail'),
|
||||||
|
])),
|
||||||
|
|
||||||
|
# List view
|
||||||
|
url(r'^.*$', BuildReportList.as_view(), name='api-build-report-list'),
|
||||||
|
])),
|
||||||
|
|
||||||
|
# Bill of Material reports
|
||||||
|
url(r'bom/', include([
|
||||||
|
|
||||||
|
# Detail views
|
||||||
|
url(r'^(?P<pk>\d+)/', include([
|
||||||
|
url(r'print/?', BOMReportPrint.as_view(), name='api-bom-report-print'),
|
||||||
|
url(r'^.*$', BOMReportDetail.as_view(), name='api-bom-report-detail'),
|
||||||
|
])),
|
||||||
|
|
||||||
|
# List view
|
||||||
|
url(r'^.*$', BOMReportList.as_view(), name='api-bom-report-list'),
|
||||||
|
])),
|
||||||
|
|
||||||
# Stock item test reports
|
# Stock item test reports
|
||||||
url(r'test/', include([
|
url(r'test/', include([
|
||||||
# Detail views
|
# Detail views
|
||||||
|
@ -18,6 +18,66 @@ class ReportConfig(AppConfig):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
self.create_default_test_reports()
|
self.create_default_test_reports()
|
||||||
|
self.create_default_build_reports()
|
||||||
|
|
||||||
|
def create_default_reports(self, model, reports):
|
||||||
|
"""
|
||||||
|
Copy defualt report files across to the media directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Source directory for report templates
|
||||||
|
src_dir = os.path.join(
|
||||||
|
os.path.dirname(os.path.realpath(__file__)),
|
||||||
|
'templates',
|
||||||
|
'report',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Destination directory
|
||||||
|
dst_dir = os.path.join(
|
||||||
|
settings.MEDIA_ROOT,
|
||||||
|
'report',
|
||||||
|
'inventree',
|
||||||
|
model.getSubdir(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not os.path.exists(dst_dir):
|
||||||
|
logger.info(f"Creating missing directory: '{dst_dir}'")
|
||||||
|
os.makedirs(dst_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Copy each report template across (if required)
|
||||||
|
for report in reports:
|
||||||
|
|
||||||
|
# Destination filename
|
||||||
|
filename = os.path.join(
|
||||||
|
'report',
|
||||||
|
'inventree',
|
||||||
|
model.getSubdir(),
|
||||||
|
report['file'],
|
||||||
|
)
|
||||||
|
|
||||||
|
src_file = os.path.join(src_dir, report['file'])
|
||||||
|
dst_file = os.path.join(settings.MEDIA_ROOT, filename)
|
||||||
|
|
||||||
|
if not os.path.exists(dst_file):
|
||||||
|
logger.info(f"Copying test report template '{dst_file}'")
|
||||||
|
shutil.copyfile(src_file, dst_file)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if a report matching the template already exists
|
||||||
|
if model.objects.filter(template=filename).exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(f"Creating new TestReport for '{report['name']}'")
|
||||||
|
|
||||||
|
model.objects.create(
|
||||||
|
name=report['name'],
|
||||||
|
description=report['description'],
|
||||||
|
template=filename,
|
||||||
|
enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
def create_default_test_reports(self):
|
def create_default_test_reports(self):
|
||||||
"""
|
"""
|
||||||
@ -31,23 +91,6 @@ class ReportConfig(AppConfig):
|
|||||||
# Database is not ready yet
|
# Database is not ready yet
|
||||||
return
|
return
|
||||||
|
|
||||||
src_dir = os.path.join(
|
|
||||||
os.path.dirname(os.path.realpath(__file__)),
|
|
||||||
'templates',
|
|
||||||
'report',
|
|
||||||
)
|
|
||||||
|
|
||||||
dst_dir = os.path.join(
|
|
||||||
settings.MEDIA_ROOT,
|
|
||||||
'report',
|
|
||||||
'inventree', # Stored in secret directory!
|
|
||||||
'test',
|
|
||||||
)
|
|
||||||
|
|
||||||
if not os.path.exists(dst_dir):
|
|
||||||
logger.info(f"Creating missing directory: '{dst_dir}'")
|
|
||||||
os.makedirs(dst_dir, exist_ok=True)
|
|
||||||
|
|
||||||
# List of test reports to copy across
|
# List of test reports to copy across
|
||||||
reports = [
|
reports = [
|
||||||
{
|
{
|
||||||
@ -57,36 +100,27 @@ class ReportConfig(AppConfig):
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
for report in reports:
|
self.create_default_reports(TestReport, reports)
|
||||||
|
|
||||||
# Create destination file name
|
def create_default_build_reports(self):
|
||||||
filename = os.path.join(
|
"""
|
||||||
'report',
|
Create database entries for the default BuildReport templates
|
||||||
'inventree',
|
(if they do not already exist)
|
||||||
'test',
|
"""
|
||||||
report['file']
|
|
||||||
)
|
|
||||||
|
|
||||||
src_file = os.path.join(src_dir, report['file'])
|
|
||||||
dst_file = os.path.join(settings.MEDIA_ROOT, filename)
|
|
||||||
|
|
||||||
if not os.path.exists(dst_file):
|
|
||||||
logger.info(f"Copying test report template '{dst_file}'")
|
|
||||||
shutil.copyfile(src_file, dst_file)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Check if a report matching the template already exists
|
from .models import BuildReport
|
||||||
if TestReport.objects.filter(template=filename).exists():
|
|
||||||
continue
|
|
||||||
|
|
||||||
logger.info(f"Creating new TestReport for '{report['name']}'")
|
|
||||||
|
|
||||||
TestReport.objects.create(
|
|
||||||
name=report['name'],
|
|
||||||
description=report['description'],
|
|
||||||
template=filename,
|
|
||||||
filters='',
|
|
||||||
enabled=True
|
|
||||||
)
|
|
||||||
except:
|
except:
|
||||||
pass
|
# Database is not ready yet
|
||||||
|
return
|
||||||
|
|
||||||
|
# List of Build reports to copy across
|
||||||
|
reports = [
|
||||||
|
{
|
||||||
|
'file': 'inventree_build_order.html',
|
||||||
|
'name': 'InvenTree Build Order',
|
||||||
|
'description': 'Build Order job sheet',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
self.create_default_reports(BuildReport, reports)
|
||||||
|
35
InvenTree/report/migrations/0011_auto_20210212_2024.py
Normal file
35
InvenTree/report/migrations/0011_auto_20210212_2024.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 3.0.7 on 2021-02-12 09:24
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
import report.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('report', '0010_auto_20210205_1201'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BillOfMaterialsReport',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(help_text='Template name', max_length=100, verbose_name='Name')),
|
||||||
|
('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')),
|
||||||
|
('description', models.CharField(help_text='Report template description', max_length=250, verbose_name='Description')),
|
||||||
|
('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Report revision number (auto-increments)', verbose_name='Revision')),
|
||||||
|
('enabled', models.BooleanField(default=True, help_text='Report template is enabled', verbose_name='Enabled')),
|
||||||
|
('filters', models.CharField(blank=True, help_text='Part query filters (comma-separated list of key=value pairs', max_length=250, validators=[report.models.validate_part_report_filters], verbose_name='Part Filters')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='testreport',
|
||||||
|
name='filters',
|
||||||
|
field=models.CharField(blank=True, help_text='StockItem query filters (comma-separated list of key=value pairs)', max_length=250, validators=[report.models.validate_stock_item_report_filters], verbose_name='Filters'),
|
||||||
|
),
|
||||||
|
]
|
30
InvenTree/report/migrations/0012_buildreport.py
Normal file
30
InvenTree/report/migrations/0012_buildreport.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 3.0.7 on 2021-02-15 21:08
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
|
from django.db import migrations, models
|
||||||
|
import report.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('report', '0011_auto_20210212_2024'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='BuildReport',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(help_text='Template name', max_length=100, verbose_name='Name')),
|
||||||
|
('template', models.FileField(help_text='Report template file', upload_to=report.models.rename_template, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['html', 'htm'])], verbose_name='Template')),
|
||||||
|
('description', models.CharField(help_text='Report template description', max_length=250, verbose_name='Description')),
|
||||||
|
('revision', models.PositiveIntegerField(default=1, editable=False, help_text='Report revision number (auto-increments)', verbose_name='Revision')),
|
||||||
|
('enabled', models.BooleanField(default=True, help_text='Report template is enabled', verbose_name='Enabled')),
|
||||||
|
('filters', models.CharField(blank=True, help_text='Build query filters (comma-separated list of key=value pairs', max_length=250, validators=[report.models.validate_build_report_filters], verbose_name='Build Filters')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'abstract': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
@ -13,14 +13,17 @@ import datetime
|
|||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ValidationError, FieldError
|
||||||
|
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
from django.core.files.storage import FileSystemStorage
|
from django.core.files.storage import FileSystemStorage
|
||||||
from django.core.validators import FileExtensionValidator
|
from django.core.validators import FileExtensionValidator
|
||||||
|
|
||||||
import stock.models
|
import build.models
|
||||||
import common.models
|
import common.models
|
||||||
|
import part.models
|
||||||
|
import stock.models
|
||||||
|
|
||||||
from InvenTree.helpers import validateFilterString
|
from InvenTree.helpers import validateFilterString
|
||||||
|
|
||||||
@ -59,7 +62,6 @@ class ReportFileUpload(FileSystemStorage):
|
|||||||
|
|
||||||
def get_available_name(self, name, max_length=None):
|
def get_available_name(self, name, max_length=None):
|
||||||
|
|
||||||
print("Name:", name)
|
|
||||||
return super().get_available_name(name, max_length)
|
return super().get_available_name(name, max_length)
|
||||||
|
|
||||||
|
|
||||||
@ -69,10 +71,29 @@ def rename_template(instance, filename):
|
|||||||
|
|
||||||
|
|
||||||
def validate_stock_item_report_filters(filters):
|
def validate_stock_item_report_filters(filters):
|
||||||
|
"""
|
||||||
|
Validate filter string against StockItem model
|
||||||
|
"""
|
||||||
|
|
||||||
return validateFilterString(filters, model=stock.models.StockItem)
|
return validateFilterString(filters, model=stock.models.StockItem)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_part_report_filters(filters):
|
||||||
|
"""
|
||||||
|
Validate filter string against Part model
|
||||||
|
"""
|
||||||
|
|
||||||
|
return validateFilterString(filters, model=part.models.Part)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_build_report_filters(filters):
|
||||||
|
"""
|
||||||
|
Validate filter string against Build model
|
||||||
|
"""
|
||||||
|
|
||||||
|
return validateFilterString(filters, model=build.models.Build)
|
||||||
|
|
||||||
|
|
||||||
class WeasyprintReportMixin(WeasyTemplateResponseMixin):
|
class WeasyprintReportMixin(WeasyTemplateResponseMixin):
|
||||||
"""
|
"""
|
||||||
Class for rendering a HTML template to a PDF.
|
Class for rendering a HTML template to a PDF.
|
||||||
@ -106,7 +127,8 @@ class ReportBase(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "{n} - {d}".format(n=self.name, d=self.description)
|
return "{n} - {d}".format(n=self.name, d=self.description)
|
||||||
|
|
||||||
def getSubdir(self):
|
@classmethod
|
||||||
|
def getSubdir(cls):
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
def rename_file(self, filename):
|
def rename_file(self, filename):
|
||||||
@ -170,6 +192,9 @@ class ReportTemplateBase(ReportBase):
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Pass a single top-level object to the report template
|
||||||
|
object_to_print = None
|
||||||
|
|
||||||
def get_context_data(self, request):
|
def get_context_data(self, request):
|
||||||
"""
|
"""
|
||||||
Supply context data to the template for rendering
|
Supply context data to the template for rendering
|
||||||
@ -184,6 +209,7 @@ class ReportTemplateBase(ReportBase):
|
|||||||
|
|
||||||
context = self.get_context_data(request)
|
context = self.get_context_data(request)
|
||||||
|
|
||||||
|
context['base_url'] = common.models.InvenTreeSetting.get_setting('INVENTREE_BASE_URL')
|
||||||
context['date'] = datetime.datetime.now().date()
|
context['date'] = datetime.datetime.now().date()
|
||||||
context['datetime'] = datetime.datetime.now()
|
context['datetime'] = datetime.datetime.now()
|
||||||
context['default_page_size'] = common.models.InvenTreeSetting.get_setting('REPORT_DEFAULT_PAGE_SIZE')
|
context['default_page_size'] = common.models.InvenTreeSetting.get_setting('REPORT_DEFAULT_PAGE_SIZE')
|
||||||
@ -241,17 +267,15 @@ class TestReport(ReportTemplateBase):
|
|||||||
Render a TestReport against a StockItem object.
|
Render a TestReport against a StockItem object.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def getSubdir(self):
|
@classmethod
|
||||||
|
def getSubdir(cls):
|
||||||
return 'test'
|
return 'test'
|
||||||
|
|
||||||
# Requires a stock_item object to be given to it before rendering
|
|
||||||
stock_item = None
|
|
||||||
|
|
||||||
filters = models.CharField(
|
filters = models.CharField(
|
||||||
blank=True,
|
blank=True,
|
||||||
max_length=250,
|
max_length=250,
|
||||||
verbose_name=_('Filters'),
|
verbose_name=_('Filters'),
|
||||||
help_text=_("Part query filters (comma-separated list of key=value pairs)"),
|
help_text=_("StockItem query filters (comma-separated list of key=value pairs)"),
|
||||||
validators=[
|
validators=[
|
||||||
validate_stock_item_report_filters
|
validate_stock_item_report_filters
|
||||||
]
|
]
|
||||||
@ -262,9 +286,11 @@ class TestReport(ReportTemplateBase):
|
|||||||
Test if this report template matches a given StockItem objects
|
Test if this report template matches a given StockItem objects
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
filters = validateFilterString(self.filters)
|
filters = validateFilterString(self.filters)
|
||||||
|
|
||||||
items = stock.models.StockItem.objects.filter(**filters)
|
items = stock.models.StockItem.objects.filter(**filters)
|
||||||
|
except (ValidationError, FieldError):
|
||||||
|
return False
|
||||||
|
|
||||||
# Ensure the provided StockItem object matches the filters
|
# Ensure the provided StockItem object matches the filters
|
||||||
items = items.filter(pk=item.pk)
|
items = items.filter(pk=item.pk)
|
||||||
@ -272,11 +298,82 @@ class TestReport(ReportTemplateBase):
|
|||||||
return items.exists()
|
return items.exists()
|
||||||
|
|
||||||
def get_context_data(self, request):
|
def get_context_data(self, request):
|
||||||
|
|
||||||
|
stock_item = self.object_to_print
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'stock_item': self.stock_item,
|
'stock_item': stock_item,
|
||||||
'part': self.stock_item.part,
|
'part': stock_item.part,
|
||||||
'results': self.stock_item.testResultMap(),
|
'results': stock_item.testResultMap(),
|
||||||
'result_list': self.stock_item.testResultList()
|
'result_list': stock_item.testResultList()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BuildReport(ReportTemplateBase):
|
||||||
|
"""
|
||||||
|
Build order / work order report
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def getSubdir(cls):
|
||||||
|
return 'build'
|
||||||
|
|
||||||
|
filters = models.CharField(
|
||||||
|
blank=True,
|
||||||
|
max_length=250,
|
||||||
|
verbose_name=_('Build Filters'),
|
||||||
|
help_text=_('Build query filters (comma-separated list of key=value pairs'),
|
||||||
|
validators=[
|
||||||
|
validate_build_report_filters,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, request):
|
||||||
|
"""
|
||||||
|
Custom context data for the build report
|
||||||
|
"""
|
||||||
|
|
||||||
|
my_build = self.object_to_print
|
||||||
|
|
||||||
|
if not type(my_build) == build.models.Build:
|
||||||
|
raise TypeError('Provided model is not a Build object')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'build': my_build,
|
||||||
|
'part': my_build.part,
|
||||||
|
'bom_items': my_build.part.get_bom_items(),
|
||||||
|
'reference': my_build.reference,
|
||||||
|
'quantity': my_build.quantity,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class BillOfMaterialsReport(ReportTemplateBase):
|
||||||
|
"""
|
||||||
|
Render a Bill of Materials against a Part object
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def getSubdir(cls):
|
||||||
|
return 'bom'
|
||||||
|
|
||||||
|
filters = models.CharField(
|
||||||
|
blank=True,
|
||||||
|
max_length=250,
|
||||||
|
verbose_name=_('Part Filters'),
|
||||||
|
help_text=_('Part query filters (comma-separated list of key=value pairs'),
|
||||||
|
validators=[
|
||||||
|
validate_part_report_filters
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, request):
|
||||||
|
|
||||||
|
part = self.object_to_print
|
||||||
|
|
||||||
|
return {
|
||||||
|
'part': part,
|
||||||
|
'category': part.category,
|
||||||
|
'bom_items': part.get_bom_items(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -5,6 +5,8 @@ from InvenTree.serializers import InvenTreeModelSerializer
|
|||||||
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
from InvenTree.serializers import InvenTreeAttachmentSerializerField
|
||||||
|
|
||||||
from .models import TestReport
|
from .models import TestReport
|
||||||
|
from .models import BuildReport
|
||||||
|
from .models import BillOfMaterialsReport
|
||||||
|
|
||||||
|
|
||||||
class TestReportSerializer(InvenTreeModelSerializer):
|
class TestReportSerializer(InvenTreeModelSerializer):
|
||||||
@ -21,3 +23,35 @@ class TestReportSerializer(InvenTreeModelSerializer):
|
|||||||
'filters',
|
'filters',
|
||||||
'enabled',
|
'enabled',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BuildReportSerializer(InvenTreeModelSerializer):
|
||||||
|
|
||||||
|
template = InvenTreeAttachmentSerializerField(required=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = BuildReport
|
||||||
|
fields = [
|
||||||
|
'pk',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'template',
|
||||||
|
'filters',
|
||||||
|
'enabled',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class BOMReportSerializer(InvenTreeModelSerializer):
|
||||||
|
|
||||||
|
template = InvenTreeAttachmentSerializerField(required=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = BillOfMaterialsReport
|
||||||
|
fields = [
|
||||||
|
'pk',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'template',
|
||||||
|
'filters',
|
||||||
|
'enabled',
|
||||||
|
]
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
{% extends "report/inventree_build_order_base.html" %}
|
||||||
|
|
||||||
|
<!-- Refer to the inventree_build_order_base template -->
|
@ -0,0 +1,182 @@
|
|||||||
|
{% extends "report/inventree_report_base.html" %}
|
||||||
|
|
||||||
|
{% load i18n %}
|
||||||
|
{% load report %}
|
||||||
|
{% load inventree_extras %}
|
||||||
|
{% load markdownify %}
|
||||||
|
{% load qr_code %}
|
||||||
|
|
||||||
|
{% block page_margin %}
|
||||||
|
margin: 2cm;
|
||||||
|
margin-top: 4cm;
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block style %}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
text-align: right;
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 20mm;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-image {
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 2px;
|
||||||
|
vertical-align: middle;
|
||||||
|
height: 40mm;
|
||||||
|
width: 100%;
|
||||||
|
display: inline-block;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-image {
|
||||||
|
float: right;
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 5px;
|
||||||
|
min-height: 42mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details table {
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-wrap: break-word;
|
||||||
|
width: 65%;
|
||||||
|
table-layout: fixed;
|
||||||
|
font-size: 75%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details table td:not(:last-child){
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details table td:last-child{
|
||||||
|
width: 50%;
|
||||||
|
padding-left: 1cm;
|
||||||
|
padding-right: 1cm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-table td {
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-top: 5px;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
border-bottom: 1px solid #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block bottom_left %}
|
||||||
|
content: "v{{report_revision}} - {{ date.isoformat }}";
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block bottom_center %}
|
||||||
|
content: "www.currawong.aero";
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block header_content %}
|
||||||
|
<img class='logo' src="{% asset 'logo_black_with_black_bird.png' %}" alt="hello" width="150">
|
||||||
|
|
||||||
|
<div class='header-right'>
|
||||||
|
<h3>
|
||||||
|
Build Order {{ build }}
|
||||||
|
</h3>
|
||||||
|
<small>{{ quantity }} x {{ part.full_name }}</small>
|
||||||
|
<br>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page_content %}
|
||||||
|
|
||||||
|
<div class='details'>
|
||||||
|
<div class='details-image'>
|
||||||
|
<img class='part-image' src="{% part_image part %}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='details-container'>
|
||||||
|
|
||||||
|
<table class='details-table'>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Build Order" %}</th>
|
||||||
|
<td>{% internal_link build.get_absolute_url build %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Part" %}</th>
|
||||||
|
<td>{% internal_link part.get_absolute_url part.full_name %}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Quantity" %}</th>
|
||||||
|
<td>{{ build.quantity }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Description" %}</th>
|
||||||
|
<td>{{ build.title }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Issued" %}</th>
|
||||||
|
<td>{{ build.creation_date }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Target Date" %}</th>
|
||||||
|
<td>
|
||||||
|
{% if build.target_date %}
|
||||||
|
{{ build.target_date }}
|
||||||
|
{% else %}
|
||||||
|
<i>Not specified</i>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Sales Order" %}</th>
|
||||||
|
<td>
|
||||||
|
{% if build.sales_order %}
|
||||||
|
{% internal_link build.sales_order.get_absolute_url build.sales_order %}
|
||||||
|
{% else %}
|
||||||
|
<i>Not specified</i>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% if build.parent %}
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Required For" %}</th>
|
||||||
|
<td>{% internal_link build.parent.get_absolute_url build.parent %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if build.issued_by %}
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Issued By" %}</th>
|
||||||
|
<td>{{ build.issued_by }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if build.responsible %}
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Responsible" %}</th>
|
||||||
|
<td>{{ build.responsible }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% if build.link %}
|
||||||
|
<tr>
|
||||||
|
<th>{% trans "Link" %}</th>
|
||||||
|
<td><a href="{{ build.link }}">{{ build.link }}</a></td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>{% trans "Notes" %}</h3>
|
||||||
|
|
||||||
|
{% if build.notes %}
|
||||||
|
{{ build.notes|markdownify }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endblock %}
|
@ -4,8 +4,12 @@
|
|||||||
<style>
|
<style>
|
||||||
@page {
|
@page {
|
||||||
{% block page_style %}
|
{% block page_style %}
|
||||||
size: {% block page_size %}{{ default_page_size }}{% endblock %};
|
{% block page_size %}
|
||||||
margin: {% block page_margin %}2cm{% endblock %};
|
size: {{ default_page_size }};
|
||||||
|
{% endblock %}
|
||||||
|
{% block page_margin %}
|
||||||
|
margin: 2cm;
|
||||||
|
{% endblock %}
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
font-size: 75%;
|
font-size: 75%;
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -6,10 +6,13 @@ import os
|
|||||||
|
|
||||||
from django import template
|
from django import template
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from part.models import Part
|
from part.models import Part
|
||||||
from stock.models import StockItem
|
from stock.models import StockItem
|
||||||
|
|
||||||
|
from common.models import InvenTreeSetting
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
@ -19,6 +22,13 @@ def asset(filename):
|
|||||||
Return fully-qualified path for an upload report asset file.
|
Return fully-qualified path for an upload report asset file.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# If in debug mode, return URL to the image, not a local file
|
||||||
|
debug_mode = InvenTreeSetting.get_setting('REPORT_DEBUG_MODE')
|
||||||
|
|
||||||
|
if debug_mode:
|
||||||
|
path = os.path.join(settings.MEDIA_URL, 'report', 'assets', filename)
|
||||||
|
else:
|
||||||
|
|
||||||
path = os.path.join(settings.MEDIA_ROOT, 'report', 'assets', filename)
|
path = os.path.join(settings.MEDIA_ROOT, 'report', 'assets', filename)
|
||||||
path = os.path.abspath(path)
|
path = os.path.abspath(path)
|
||||||
|
|
||||||
@ -31,6 +41,9 @@ def part_image(part):
|
|||||||
Return a fully-qualified path for a part image
|
Return a fully-qualified path for a part image
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# If in debug mode, return URL to the image, not a local file
|
||||||
|
debug_mode = InvenTreeSetting.get_setting('REPORT_DEBUG_MODE')
|
||||||
|
|
||||||
if type(part) is Part:
|
if type(part) is Part:
|
||||||
img = part.image.name
|
img = part.image.name
|
||||||
|
|
||||||
@ -40,6 +53,13 @@ def part_image(part):
|
|||||||
else:
|
else:
|
||||||
img = ''
|
img = ''
|
||||||
|
|
||||||
|
if debug_mode:
|
||||||
|
if img:
|
||||||
|
return os.path.join(settings.MEDIA_URL, img)
|
||||||
|
else:
|
||||||
|
return os.path.join(settings.STATIC_URL, 'img', 'blank_image.png')
|
||||||
|
|
||||||
|
else:
|
||||||
path = os.path.join(settings.MEDIA_ROOT, img)
|
path = os.path.join(settings.MEDIA_ROOT, img)
|
||||||
path = os.path.abspath(path)
|
path = os.path.abspath(path)
|
||||||
|
|
||||||
@ -50,3 +70,33 @@ def part_image(part):
|
|||||||
path = os.path.abspath(path)
|
path = os.path.abspath(path)
|
||||||
|
|
||||||
return f"file://{path}"
|
return f"file://{path}"
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag()
|
||||||
|
def internal_link(link, text):
|
||||||
|
"""
|
||||||
|
Make a <a></a> href which points to an InvenTree URL.
|
||||||
|
|
||||||
|
Important Note: This only works if the INVENTREE_BASE_URL parameter is set!
|
||||||
|
|
||||||
|
If the INVENTREE_BASE_URL parameter is not configured,
|
||||||
|
the text will be returned (unlinked)
|
||||||
|
"""
|
||||||
|
|
||||||
|
text = str(text)
|
||||||
|
|
||||||
|
base_url = InvenTreeSetting.get_setting('INVENTREE_BASE_URL')
|
||||||
|
|
||||||
|
# If the base URL is not set, just return the text
|
||||||
|
if not base_url:
|
||||||
|
return text
|
||||||
|
|
||||||
|
if not base_url.endswith('/'):
|
||||||
|
base_url += '/'
|
||||||
|
|
||||||
|
if base_url.endswith('/') and link.startswith('/'):
|
||||||
|
link = link[1:]
|
||||||
|
|
||||||
|
url = f"{base_url}{link}"
|
||||||
|
|
||||||
|
return mark_safe(f'<a href="{url}">{text}</a>')
|
||||||
|
@ -9,7 +9,7 @@ from __future__ import unicode_literals
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError, FieldError
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
@ -1365,10 +1365,13 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
for test_report in report.models.TestReport.objects.filter(enabled=True):
|
for test_report in report.models.TestReport.objects.filter(enabled=True):
|
||||||
|
|
||||||
|
# Attempt to validate report filter (skip if invalid)
|
||||||
|
try:
|
||||||
filters = helpers.validateFilterString(test_report.filters)
|
filters = helpers.validateFilterString(test_report.filters)
|
||||||
|
|
||||||
if item_query.filter(**filters).exists():
|
if item_query.filter(**filters).exists():
|
||||||
reports.append(test_report)
|
reports.append(test_report)
|
||||||
|
except (ValidationError, FieldError):
|
||||||
|
continue
|
||||||
|
|
||||||
return reports
|
return reports
|
||||||
|
|
||||||
@ -1391,10 +1394,13 @@ class StockItem(MPTTModel):
|
|||||||
|
|
||||||
for lbl in label.models.StockItemLabel.objects.filter(enabled=True):
|
for lbl in label.models.StockItemLabel.objects.filter(enabled=True):
|
||||||
|
|
||||||
|
try:
|
||||||
filters = helpers.validateFilterString(lbl.filters)
|
filters = helpers.validateFilterString(lbl.filters)
|
||||||
|
|
||||||
if item_query.filter(**filters).exists():
|
if item_query.filter(**filters).exists():
|
||||||
labels.append(lbl)
|
labels.append(lbl)
|
||||||
|
except (ValidationError, FieldError):
|
||||||
|
continue
|
||||||
|
|
||||||
return labels
|
return labels
|
||||||
|
|
||||||
|
@ -110,7 +110,7 @@ class StockTest(TestCase):
|
|||||||
# The "is_building" quantity should not be counted here
|
# The "is_building" quantity should not be counted here
|
||||||
self.assertEqual(part.total_stock, n + 5)
|
self.assertEqual(part.total_stock, n + 5)
|
||||||
|
|
||||||
self.assertEqual(part.quantity_being_built, 100)
|
self.assertEqual(part.quantity_being_built, 1)
|
||||||
|
|
||||||
def test_loc_count(self):
|
def test_loc_count(self):
|
||||||
self.assertEqual(StockLocation.objects.count(), 7)
|
self.assertEqual(StockLocation.objects.count(), 7)
|
||||||
|
@ -16,6 +16,7 @@
|
|||||||
{% include "InvenTree/settings/header.html" %}
|
{% include "InvenTree/settings/header.html" %}
|
||||||
<tbody>
|
<tbody>
|
||||||
{% include "InvenTree/settings/setting.html" with key="INVENTREE_INSTANCE" icon="fa-info-circle" %}
|
{% include "InvenTree/settings/setting.html" with key="INVENTREE_INSTANCE" icon="fa-info-circle" %}
|
||||||
|
{% include "InvenTree/settings/setting.html" with key="INVENTREE_BASE_URL" icon="fa-globe" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="INVENTREE_COMPANY_NAME" icon="fa-building" %}
|
{% include "InvenTree/settings/setting.html" with key="INVENTREE_COMPANY_NAME" icon="fa-building" %}
|
||||||
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-dollar-sign" %}
|
{% include "InvenTree/settings/setting.html" with key="INVENTREE_DEFAULT_CURRENCY" icon="fa-dollar-sign" %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -137,6 +137,16 @@ function loadBomTable(table, options) {
|
|||||||
checkbox: true,
|
checkbox: true,
|
||||||
visible: true,
|
visible: true,
|
||||||
switchable: false,
|
switchable: false,
|
||||||
|
formatter: function(value, row, index, field) {
|
||||||
|
// Disable checkbox if the row is defined for a *different* part!
|
||||||
|
if (row.part != options.parent_id) {
|
||||||
|
return {
|
||||||
|
disabled: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -254,6 +264,32 @@ function loadBomTable(table, options) {
|
|||||||
});
|
});
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
cols.push({
|
||||||
|
field: 'optional',
|
||||||
|
title: '{% trans "Optional" %}',
|
||||||
|
searchable: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
cols.push({
|
||||||
|
field: 'inherited',
|
||||||
|
title: '{% trans "Inherited" %}',
|
||||||
|
searchable: false,
|
||||||
|
formatter: function(value, row, index, field) {
|
||||||
|
// This BOM item *is* inheritable, but is defined for this BOM
|
||||||
|
if (!row.inherited) {
|
||||||
|
return "-";
|
||||||
|
} else if (row.part == options.parent_id) {
|
||||||
|
return '{% trans "Inherited" %}';
|
||||||
|
} else {
|
||||||
|
// If this BOM item is inherited from a parent part
|
||||||
|
return renderLink(
|
||||||
|
'{% trans "View BOM" %}',
|
||||||
|
`/part/${row.part}/bom/`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
cols.push(
|
cols.push(
|
||||||
{
|
{
|
||||||
'field': 'can_build',
|
'field': 'can_build',
|
||||||
@ -330,7 +366,12 @@ function loadBomTable(table, options) {
|
|||||||
|
|
||||||
return html;
|
return html;
|
||||||
} else {
|
} else {
|
||||||
return '';
|
// Return a link to the external BOM
|
||||||
|
|
||||||
|
return renderLink(
|
||||||
|
'{% trans "View BOM" %}',
|
||||||
|
`/part/${row.part}/bom/`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -379,15 +420,24 @@ function loadBomTable(table, options) {
|
|||||||
sortable: true,
|
sortable: true,
|
||||||
search: true,
|
search: true,
|
||||||
rowStyle: function(row, index) {
|
rowStyle: function(row, index) {
|
||||||
if (row.validated) {
|
|
||||||
return {
|
var classes = [];
|
||||||
classes: 'rowvalid'
|
|
||||||
};
|
// Shade rows differently if they are for different parent parts
|
||||||
} else {
|
if (row.part != options.parent_id) {
|
||||||
return {
|
classes.push('rowinherited');
|
||||||
classes: 'rowinvalid'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (row.validated) {
|
||||||
|
classes.push('rowvalid');
|
||||||
|
} else {
|
||||||
|
classes.push('rowinvalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
classes: classes.join(' '),
|
||||||
|
};
|
||||||
|
|
||||||
},
|
},
|
||||||
formatNoMatches: function() {
|
formatNoMatches: function() {
|
||||||
return '{% trans "No BOM items found" %}';
|
return '{% trans "No BOM items found" %}';
|
||||||
|
@ -637,6 +637,12 @@ function loadBuildTable(table, options) {
|
|||||||
visible: false,
|
visible: false,
|
||||||
switchable: false,
|
switchable: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
checkbox: true,
|
||||||
|
title: '{% trans "Select" %}',
|
||||||
|
searchable: false,
|
||||||
|
switchable: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
field: 'reference',
|
field: 'reference',
|
||||||
title: '{% trans "Build" %}',
|
title: '{% trans "Build" %}',
|
||||||
@ -717,6 +723,13 @@ function loadBuildTable(table, options) {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
linkButtonsToSelection(
|
||||||
|
table,
|
||||||
|
[
|
||||||
|
'#build-print-options',
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
|
|
||||||
function selectTestReport(reports, items, options={}) {
|
function selectReport(reports, items, options={}) {
|
||||||
/**
|
/**
|
||||||
* Present the user with the available test reports,
|
* Present the user with the available reports,
|
||||||
* and allow them to select which test report to print.
|
* and allow them to select which report to print.
|
||||||
*
|
*
|
||||||
* The intent is that the available report templates have been requested
|
* The intent is that the available report templates have been requested
|
||||||
* (via AJAX) from the server.
|
* (via AJAX) from the server.
|
||||||
@ -44,7 +44,7 @@ function selectTestReport(reports, items, options={}) {
|
|||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class='alert alert-block alert-info'>
|
<div class='alert alert-block alert-info'>
|
||||||
${items.length} {% trans "stock items selected" %}
|
${items.length} {% trans "items selected" %}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,7 +102,7 @@ function printTestReports(items, options={}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request available labels from the server
|
// Request available reports from the server
|
||||||
inventreeGet(
|
inventreeGet(
|
||||||
'{% url "api-stockitem-testreport-list" %}',
|
'{% url "api-stockitem-testreport-list" %}',
|
||||||
{
|
{
|
||||||
@ -121,7 +121,7 @@ function printTestReports(items, options={}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Select report template to print
|
// Select report template to print
|
||||||
selectTestReport(
|
selectReport(
|
||||||
response,
|
response,
|
||||||
items,
|
items,
|
||||||
{
|
{
|
||||||
@ -129,7 +129,7 @@ function printTestReports(items, options={}) {
|
|||||||
var href = `/api/report/test/${pk}/print/?`;
|
var href = `/api/report/test/${pk}/print/?`;
|
||||||
|
|
||||||
items.forEach(function(item) {
|
items.forEach(function(item) {
|
||||||
href += `items[]=${item}&`;
|
href += `item=${item}&`;
|
||||||
});
|
});
|
||||||
|
|
||||||
window.location.href = href;
|
window.location.href = href;
|
||||||
@ -140,3 +140,110 @@ function printTestReports(items, options={}) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function printBuildReports(builds, options={}) {
|
||||||
|
/**
|
||||||
|
* Print Build report for the provided build(s)
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (builds.length == 0) {
|
||||||
|
showAlertDialog(
|
||||||
|
'{% trans "Select Builds" %}',
|
||||||
|
'{% trans "Build(s) must be selected before printing reports" %}',
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inventreeGet(
|
||||||
|
'{% url "api-build-report-list" %}',
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
builds: builds,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
success: function(response) {
|
||||||
|
if (response.length == 0) {
|
||||||
|
showAlertDialog(
|
||||||
|
'{% trans "No Reports Found" %}',
|
||||||
|
'{% trans "No report templates found which match selected build(s)" %}'
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select which report to print
|
||||||
|
selectReport(
|
||||||
|
response,
|
||||||
|
builds,
|
||||||
|
{
|
||||||
|
success: function(pk) {
|
||||||
|
var href = `/api/report/build/${pk}/print/?`;
|
||||||
|
|
||||||
|
builds.forEach(function(build) {
|
||||||
|
href += `build=${build}&`;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.location.href = href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function printBomReports(parts, options={}) {
|
||||||
|
/**
|
||||||
|
* Print BOM reports for the provided part(s)
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (parts.length == 0) {
|
||||||
|
showAlertDialog(
|
||||||
|
'{% trans "Select Parts" %}',
|
||||||
|
'{% trans "Part(s) must be selected before printing reports" %}'
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request available reports from the server
|
||||||
|
inventreeGet(
|
||||||
|
'{% url "api-bom-report-list" %}',
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
parts: parts,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
success: function(response) {
|
||||||
|
if (response.length == 0) {
|
||||||
|
showAlertDialog(
|
||||||
|
'{% trans "No Reports Found" %}',
|
||||||
|
'{% trans "No report templates found which match selected part(s)" %}',
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select which report to print
|
||||||
|
selectReport(
|
||||||
|
response,
|
||||||
|
parts,
|
||||||
|
{
|
||||||
|
success: function(pk) {
|
||||||
|
var href = `/api/report/bom/${pk}/print/?`;
|
||||||
|
|
||||||
|
parts.forEach(function(part) {
|
||||||
|
href += `part=${part}&`;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.location.href = href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
@ -101,7 +101,7 @@ function loadStockTestResultsTable(table, options) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'test_name',
|
field: 'test_name',
|
||||||
title: "{% trans "Test Name" %}",
|
title: '{% trans "Test Name" %}',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
formatter: function(value, row) {
|
formatter: function(value, row) {
|
||||||
var html = value;
|
var html = value;
|
||||||
@ -925,7 +925,7 @@ function loadStockTrackingTable(table, options) {
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
return "{% trans "No user information" %}";
|
return '{% trans "No user information" %}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -1206,7 +1206,7 @@ function loadInstalledInTable(table, options) {
|
|||||||
columns: [
|
columns: [
|
||||||
{
|
{
|
||||||
checkbox: true,
|
checkbox: true,
|
||||||
title: '{% trans 'Select' %}',
|
title: '{% trans "Select" %}',
|
||||||
searchable: false,
|
searchable: false,
|
||||||
switchable: false,
|
switchable: false,
|
||||||
},
|
},
|
||||||
|
@ -44,6 +44,10 @@ function getAvailableTableFilters(tableKey) {
|
|||||||
type: 'bool',
|
type: 'bool',
|
||||||
title: '{% trans "Validated" %}',
|
title: '{% trans "Validated" %}',
|
||||||
},
|
},
|
||||||
|
inherited: {
|
||||||
|
type: 'bool',
|
||||||
|
title: '{% trans "Inherited" %}',
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ function deleteButton(url, text='Delete') {
|
|||||||
|
|
||||||
|
|
||||||
function renderLink(text, url, options={}) {
|
function renderLink(text, url, options={}) {
|
||||||
if (url == null || url === '') {
|
if (url === null || url === undefined || url === '') {
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -19,14 +19,6 @@ function renderLink(text, url, options={}) {
|
|||||||
|
|
||||||
var remove_http = options.remove_http || false;
|
var remove_http = options.remove_http || false;
|
||||||
|
|
||||||
if (remove_http) {
|
|
||||||
if (text.startsWith('http://')) {
|
|
||||||
text = text.slice(7);
|
|
||||||
} else if (text.startsWith('https://')) {
|
|
||||||
text = text.slice(8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shorten the displayed length if required
|
// Shorten the displayed length if required
|
||||||
if ((max_length > 0) && (text.length > max_length)) {
|
if ((max_length > 0) && (text.length > max_length)) {
|
||||||
var slice_length = (max_length - 3) / 2;
|
var slice_length = (max_length - 3) / 2;
|
||||||
@ -53,6 +45,10 @@ function linkButtonsToSelection(table, buttons) {
|
|||||||
* The buttons will only be enabled if there is at least one row selected
|
* The buttons will only be enabled if there is at least one row selected
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
if (typeof table === 'string') {
|
||||||
|
table = $(table);
|
||||||
|
}
|
||||||
|
|
||||||
// Initially set the enable state of the buttons
|
// Initially set the enable state of the buttons
|
||||||
enableButtons(buttons, table.bootstrapTable('getSelections').length > 0);
|
enableButtons(buttons, table.bootstrapTable('getSelections').length > 0);
|
||||||
|
|
||||||
|
@ -9,10 +9,13 @@
|
|||||||
</button>
|
</button>
|
||||||
<h3 id='modal-title'><i>Form Title Here</i></h3>
|
<h3 id='modal-title'><i>Form Title Here</i></h3>
|
||||||
</div>
|
</div>
|
||||||
|
<div class='modal-form-content-wrapper'>
|
||||||
<div class='alert alert-block alert-danger' id='form-validation-warning' style='display: none;'>
|
<div class='alert alert-block alert-danger' id='form-validation-warning' style='display: none;'>
|
||||||
{% trans "Form errors exist" %}
|
{% trans "Form errors exist" %}
|
||||||
</div>
|
</div>
|
||||||
<div class='modal-form-content'>
|
<div class='modal-form-content'>
|
||||||
|
<!-- Form content will be injected here-->
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class='modal-footer'>
|
<div class='modal-footer'>
|
||||||
<button type='button' class='btn btn-default' id='modal-form-close' data-dismiss='modal'>{% trans "Close" %}</button>
|
<button type='button' class='btn btn-default' id='modal-form-close' data-dismiss='modal'>{% trans "Close" %}</button>
|
||||||
@ -57,8 +60,8 @@
|
|||||||
<div class='modal-form-content'>
|
<div class='modal-form-content'>
|
||||||
</div>
|
</div>
|
||||||
<div class='modal-footer'>
|
<div class='modal-footer'>
|
||||||
<button type='button' class='btn btn-default' id='modal-form-cancel' data-dismiss='modal'>Cancel</button>
|
<button type='button' class='btn btn-default' id='modal-form-cancel' data-dismiss='modal'>{% trans "Cancel" %}</button>
|
||||||
<button type='button' class='btn btn-primary' id='modal-form-accept'>Accept</button>
|
<button type='button' class='btn btn-primary' id='modal-form-accept'>{% trans "Accept" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -76,7 +79,7 @@
|
|||||||
<div class='modal-form-content'>
|
<div class='modal-form-content'>
|
||||||
</div>
|
</div>
|
||||||
<div class='modal-footer'>
|
<div class='modal-footer'>
|
||||||
<button type='button' class='btn btn-default' data-dismiss='modal'>Close</button>
|
<button type='button' class='btn btn-default' data-dismiss='modal'>{% trans "Close" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -14,33 +14,80 @@
|
|||||||
<link rel="stylesheet" href="{% static 'css/select2.css' %}">
|
<link rel="stylesheet" href="{% static 'css/select2.css' %}">
|
||||||
<link rel="stylesheet" href="{% static 'css/bootstrap-table.css' %}">
|
<link rel="stylesheet" href="{% static 'css/bootstrap-table.css' %}">
|
||||||
<link rel="stylesheet" href="{% static 'css/inventree.css' %}">
|
<link rel="stylesheet" href="{% static 'css/inventree.css' %}">
|
||||||
|
<link rel="stylesheet" href="{% static 'fontawesome/css/brands.css' %}">
|
||||||
|
<link rel="stylesheet" href="{% static 'fontawesome/css/solid.css' %}">
|
||||||
|
|
||||||
|
<script type='text/javascript' src="{% static 'fontawesome/js/solid.js' %}"></script>
|
||||||
|
<script type='text/javascript' src="{% static 'fontawesome/js/brands.js' %}"></script>
|
||||||
|
<script type='text/javascript' src="{% static 'fontawesome/js/fontawesome.js' %}"></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.login-error {
|
||||||
|
color: #F88;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<title>
|
<title>
|
||||||
InvenTree
|
InvenTree
|
||||||
</title>
|
</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body class='login-screen'>
|
||||||
|
<!--
|
||||||
|
Background Image Attribution: https://unsplash.com/photos/Ixvv3YZkd7w
|
||||||
|
-->
|
||||||
|
|
||||||
<div class='main body-wrapper'>
|
<div class='main body-wrapper login-screen'>
|
||||||
|
|
||||||
|
<div class='login-container'>
|
||||||
<div class='login'>
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class='container-fluid'>
|
<div class='container-fluid'>
|
||||||
<div class='clearfix content-heading'>
|
<div class='clearfix content-heading login-header'>
|
||||||
<img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/> <h3>InvenTree</h3>
|
<img class="pull-left" src="{% static 'img/inventree.png' %}" width="60" height="60"/>
|
||||||
|
<span><h3>InvenTree</h3></span>
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<div class='container-fluid'>
|
<div class='container-fluid'>
|
||||||
<form method="post" action=''>
|
<form method="post" action=''>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
{% load crispy_forms_tags %}
|
{% load crispy_forms_tags %}
|
||||||
|
|
||||||
{{ form|crispy }}
|
<div id="div_id_username" class="form-group">
|
||||||
|
<label for="id_username" class="control-label requiredField">{% trans "Username" %}<span class="asteriskField">*</span></label>
|
||||||
|
<div class="controls ">
|
||||||
|
<div class='input-group'>
|
||||||
|
<div class='input-group-addon'>
|
||||||
|
<span class='fas fa-user'></span>
|
||||||
|
</div>
|
||||||
|
<input type="text" name="username" autofocus autocapitalize="none" autocomplete="username" maxlength="150" class="textinput textInput form-control" required id="id_username" placeholder='{% trans "Enter username" %}'>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="div_id_password" class="form-group">
|
||||||
|
<label for="id_password" class="control-label requiredField">{% trans "Password" %}<span class="asteriskField">*</span></label>
|
||||||
|
<div class='controls'>
|
||||||
|
<div class="input-group">
|
||||||
|
<div class='input-group-addon'>
|
||||||
|
<span class='fas fa-key'></span>
|
||||||
|
</div>
|
||||||
|
<input type="password" name="password" autocomplete="current-password" class="textinput textInput form-control" required id="id_password" placeholder='{% trans "Enter password" %}'>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if form.errors %}
|
||||||
|
<div class='login-error'>
|
||||||
|
<b>{% trans "Username / password combination is incorrect" %}</b>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<button class='pull-right btn btn-primary login-button' type="submit">{% trans "Login" %}</button>
|
||||||
|
|
||||||
<button class='pull-right btn btn-primary' type="submit">{% trans "Login" %}</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -120,6 +120,8 @@ class RuleSet(models.Model):
|
|||||||
'report_reportasset',
|
'report_reportasset',
|
||||||
'report_reportsnippet',
|
'report_reportsnippet',
|
||||||
'report_testreport',
|
'report_testreport',
|
||||||
|
'report_buildreport',
|
||||||
|
'report_billofmaterialsreport',
|
||||||
'part_partstar',
|
'part_partstar',
|
||||||
'users_owner',
|
'users_owner',
|
||||||
|
|
||||||
|
12
tasks.py
12
tasks.py
@ -349,12 +349,22 @@ def backup(c):
|
|||||||
Create a backup of database models and uploaded media files.
|
Create a backup of database models and uploaded media files.
|
||||||
|
|
||||||
Backup files will be written to the 'backup_dir' file specified in 'config.yaml'
|
Backup files will be written to the 'backup_dir' file specified in 'config.yaml'
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
manage(c, 'dbbackup')
|
manage(c, 'dbbackup')
|
||||||
manage(c, 'mediabackup')
|
manage(c, 'mediabackup')
|
||||||
|
|
||||||
|
@task
|
||||||
|
def restore(c):
|
||||||
|
"""
|
||||||
|
Restores database models and media files.
|
||||||
|
|
||||||
|
Backup files are read from the 'backup_dir' file specified in 'config.yaml'
|
||||||
|
"""
|
||||||
|
|
||||||
|
manage(c, 'dbrestore')
|
||||||
|
manage(c, 'mediarestore')
|
||||||
|
|
||||||
@task(help={'address': 'Server address:port (default=127.0.0.1:8000)'})
|
@task(help={'address': 'Server address:port (default=127.0.0.1:8000)'})
|
||||||
def server(c, address="127.0.0.1:8000"):
|
def server(c, address="127.0.0.1:8000"):
|
||||||
"""
|
"""
|
||||||
|
Loading…
Reference in New Issue
Block a user