diff --git a/InvenTree/InvenTree/models.py b/InvenTree/InvenTree/models.py index 0f8350f84f..0448a69781 100644 --- a/InvenTree/InvenTree/models.py +++ b/InvenTree/InvenTree/models.py @@ -21,7 +21,8 @@ from django.dispatch import receiver from mptt.models import MPTTModel, TreeForeignKey from mptt.exceptions import InvalidMove -from .validators import validate_tree_name +from InvenTree.fields import InvenTreeURLField +from InvenTree.validators import validate_tree_name logger = logging.getLogger('inventree') @@ -89,12 +90,15 @@ class ReferenceIndexingMixin(models.Model): class InvenTreeAttachment(models.Model): """ Provides an abstracted class for managing file attachments. + An attachment can be either an uploaded file, or an external URL + Attributes: attachment: File comment: String descriptor for the attachment user: User associated with file upload upload_date: Date the file was uploaded """ + def getSubdir(self): """ Return the subdirectory under which attachments should be stored. @@ -103,11 +107,32 @@ class InvenTreeAttachment(models.Model): return "attachments" + def save(self, *args, **kwargs): + # Either 'attachment' or 'link' must be specified! + if not self.attachment and not self.link: + raise ValidationError({ + 'attachment': _('Missing file'), + 'link': _('Missing external link'), + }) + + super().save(*args, **kwargs) + def __str__(self): - return os.path.basename(self.attachment.name) + if self.attachment is not None: + return os.path.basename(self.attachment.name) + else: + return str(self.link) attachment = models.FileField(upload_to=rename_attachment, verbose_name=_('Attachment'), - help_text=_('Select file to attach')) + help_text=_('Select file to attach'), + blank=True, null=True + ) + + link = InvenTreeURLField( + blank=True, null=True, + verbose_name=_('Link'), + help_text=_('Link to external URL') + ) comment = models.CharField(blank=True, max_length=100, verbose_name=_('Comment'), help_text=_('File comment')) @@ -123,7 +148,10 @@ class InvenTreeAttachment(models.Model): @property def basename(self): - return os.path.basename(self.attachment.name) + if self.attachment: + return os.path.basename(self.attachment.name) + else: + return None @basename.setter def basename(self, fn): diff --git a/InvenTree/InvenTree/serializers.py b/InvenTree/InvenTree/serializers.py index 90cc857cdd..3785cfb292 100644 --- a/InvenTree/InvenTree/serializers.py +++ b/InvenTree/InvenTree/serializers.py @@ -239,22 +239,6 @@ class InvenTreeModelSerializer(serializers.ModelSerializer): return data -class InvenTreeAttachmentSerializer(InvenTreeModelSerializer): - """ - Special case of an InvenTreeModelSerializer, which handles an "attachment" model. - - The only real addition here is that we support "renaming" of the attachment file. - """ - - # The 'filename' field must be present in the serializer - filename = serializers.CharField( - label=_('Filename'), - required=False, - source='basename', - allow_blank=False, - ) - - class InvenTreeAttachmentSerializerField(serializers.FileField): """ Override the DRF native FileField serializer, @@ -284,6 +268,27 @@ class InvenTreeAttachmentSerializerField(serializers.FileField): return os.path.join(str(settings.MEDIA_URL), str(value)) +class InvenTreeAttachmentSerializer(InvenTreeModelSerializer): + """ + Special case of an InvenTreeModelSerializer, which handles an "attachment" model. + + The only real addition here is that we support "renaming" of the attachment file. + """ + + attachment = InvenTreeAttachmentSerializerField( + required=False, + allow_null=False, + ) + + # The 'filename' field must be present in the serializer + filename = serializers.CharField( + label=_('Filename'), + required=False, + source='basename', + allow_blank=False, + ) + + class InvenTreeImageSerializerField(serializers.ImageField): """ Custom image serializer. diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index 038d07600f..df84ba315e 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -257,7 +257,7 @@ INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', - 'django.contrib.sessions', + 'user_sessions', # db user sessions 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.sites', @@ -299,7 +299,7 @@ INSTALLED_APPS = [ MIDDLEWARE = CONFIG.get('middleware', [ 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', + 'user_sessions.middleware.SessionMiddleware', # db user sessions 'django.middleware.locale.LocaleMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -626,6 +626,12 @@ if _cache_host: # as well Q_CLUSTER["django_redis"] = "worker" +# database user sessions +SESSION_ENGINE = 'user_sessions.backends.db' +LOGOUT_REDIRECT_URL = 'index' +SILENCED_SYSTEM_CHECKS = [ + 'admin.E410', +] # Password validation # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators diff --git a/InvenTree/InvenTree/urls.py b/InvenTree/InvenTree/urls.py index 584403fc84..15ed8d5478 100644 --- a/InvenTree/InvenTree/urls.py +++ b/InvenTree/InvenTree/urls.py @@ -38,6 +38,7 @@ from rest_framework.documentation import include_docs_urls from .views import auth_request from .views import IndexView, SearchView, DatabaseStatsView from .views import SettingsView, EditUserView, SetPasswordView, CustomEmailView, CustomConnectionsView, CustomPasswordResetFromKeyView +from .views import CustomSessionDeleteView, CustomSessionDeleteOtherView from .views import CurrencyRefreshView from .views import AppearanceSelectView, SettingCategorySelectView from .views import DynamicJsView @@ -156,6 +157,10 @@ urlpatterns = [ url(r'^markdownx/', include('markdownx.urls')), + # DB user sessions + url(r'^accounts/sessions/other/delete/$', view=CustomSessionDeleteOtherView.as_view(), name='session_delete_other', ), + url(r'^accounts/sessions/(?P\w+)/delete/$', view=CustomSessionDeleteView.as_view(), name='session_delete', ), + # Single Sign On / allauth # overrides of urlpatterns url(r'^accounts/email/', CustomEmailView.as_view(), name='account_email'), diff --git a/InvenTree/InvenTree/views.py b/InvenTree/InvenTree/views.py index 989fb1bc9d..a5c4da48b6 100644 --- a/InvenTree/InvenTree/views.py +++ b/InvenTree/InvenTree/views.py @@ -14,6 +14,7 @@ from django.utils.translation import gettext_lazy as _ from django.template.loader import render_to_string from django.http import HttpResponse, JsonResponse, HttpResponseRedirect from django.urls import reverse_lazy +from django.utils.timezone import now from django.shortcuts import redirect from django.conf import settings @@ -29,6 +30,7 @@ from allauth.socialaccount.forms import DisconnectForm from allauth.account.models import EmailAddress from allauth.account.views import EmailView, PasswordResetFromKeyView from allauth.socialaccount.views import ConnectionsView +from user_sessions.views import SessionDeleteView, SessionDeleteOtherView from common.settings import currency_code_default, currency_codes @@ -733,6 +735,10 @@ class SettingsView(TemplateView): ctx["request"] = self.request ctx['social_form'] = DisconnectForm(request=self.request) + # user db sessions + ctx['session_key'] = self.request.session.session_key + ctx['session_list'] = self.request.user.session_set.filter(expire_date__gt=now()).order_by('-last_activity') + return ctx @@ -766,6 +772,20 @@ class CustomPasswordResetFromKeyView(PasswordResetFromKeyView): success_url = reverse_lazy("account_login") +class UserSessionOverride(): + """overrides sucessurl to lead to settings""" + def get_success_url(self): + return str(reverse_lazy('settings')) + + +class CustomSessionDeleteView(UserSessionOverride, SessionDeleteView): + pass + + +class CustomSessionDeleteOtherView(UserSessionOverride, SessionDeleteOtherView): + pass + + class CurrencyRefreshView(RedirectView): """ POST endpoint to refresh / update exchange rates diff --git a/InvenTree/build/migrations/0033_auto_20211128_0151.py b/InvenTree/build/migrations/0033_auto_20211128_0151.py new file mode 100644 index 0000000000..db8df848ce --- /dev/null +++ b/InvenTree/build/migrations/0033_auto_20211128_0151.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.5 on 2021-11-28 01:51 + +import InvenTree.fields +import InvenTree.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('build', '0032_auto_20211014_0632'), + ] + + operations = [ + migrations.AddField( + model_name='buildorderattachment', + name='link', + field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link'), + ), + migrations.AlterField( + model_name='buildorderattachment', + name='attachment', + field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'), + ), + ] diff --git a/InvenTree/build/serializers.py b/InvenTree/build/serializers.py index 2bb3d7f9df..4ef4157bb6 100644 --- a/InvenTree/build/serializers.py +++ b/InvenTree/build/serializers.py @@ -16,7 +16,7 @@ from rest_framework import serializers from rest_framework.serializers import ValidationError from InvenTree.serializers import InvenTreeModelSerializer, InvenTreeAttachmentSerializer -from InvenTree.serializers import InvenTreeAttachmentSerializerField, UserSerializerBrief +from InvenTree.serializers import UserSerializerBrief import InvenTree.helpers from InvenTree.serializers import InvenTreeDecimalField @@ -516,8 +516,6 @@ class BuildAttachmentSerializer(InvenTreeAttachmentSerializer): Serializer for a BuildAttachment """ - attachment = InvenTreeAttachmentSerializerField(required=True) - class Meta: model = BuildOrderAttachment @@ -525,6 +523,7 @@ class BuildAttachmentSerializer(InvenTreeAttachmentSerializer): 'pk', 'build', 'attachment', + 'link', 'filename', 'comment', 'upload_date', diff --git a/InvenTree/build/templates/build/detail.html b/InvenTree/build/templates/build/detail.html index 31e9f38080..8479c2819f 100644 --- a/InvenTree/build/templates/build/detail.html +++ b/InvenTree/build/templates/build/detail.html @@ -431,53 +431,17 @@ enableDragAndDrop( } ); -// Callback for creating a new attachment -$('#new-attachment').click(function() { - - constructForm('{% url "api-build-attachment-list" %}', { - fields: { - attachment: {}, - comment: {}, - build: { - value: {{ build.pk }}, - hidden: true, - } - }, - method: 'POST', - onSuccess: reloadAttachmentTable, - title: '{% trans "Add Attachment" %}', - }); -}); - -loadAttachmentTable( - '{% url "api-build-attachment-list" %}', - { - filters: { - build: {{ build.pk }}, - }, - onEdit: function(pk) { - var url = `/api/build/attachment/${pk}/`; - - constructForm(url, { - fields: { - filename: {}, - comment: {}, - }, - onSuccess: reloadAttachmentTable, - title: '{% trans "Edit Attachment" %}', - }); - }, - onDelete: function(pk) { - - constructForm(`/api/build/attachment/${pk}/`, { - method: 'DELETE', - confirmMessage: '{% trans "Confirm Delete Operation" %}', - title: '{% trans "Delete Attachment" %}', - onSuccess: reloadAttachmentTable, - }); +loadAttachmentTable('{% url "api-build-attachment-list" %}', { + filters: { + build: {{ build.pk }}, + }, + fields: { + build: { + value: {{ build.pk }}, + hidden: true, } } -); +}); $('#edit-notes').click(function() { constructForm('{% url "api-build-detail" build.pk %}', { diff --git a/InvenTree/order/migrations/0053_auto_20211128_0151.py b/InvenTree/order/migrations/0053_auto_20211128_0151.py new file mode 100644 index 0000000000..bbe029b4af --- /dev/null +++ b/InvenTree/order/migrations/0053_auto_20211128_0151.py @@ -0,0 +1,35 @@ +# Generated by Django 3.2.5 on 2021-11-28 01:51 + +import InvenTree.fields +import InvenTree.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0052_auto_20211014_0631'), + ] + + operations = [ + migrations.AddField( + model_name='purchaseorderattachment', + name='link', + field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link'), + ), + migrations.AddField( + model_name='salesorderattachment', + name='link', + field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link'), + ), + migrations.AlterField( + model_name='purchaseorderattachment', + name='attachment', + field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'), + ), + migrations.AlterField( + model_name='salesorderattachment', + name='attachment', + field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'), + ), + ] diff --git a/InvenTree/order/migrations/0053_salesordershipment.py b/InvenTree/order/migrations/0053_salesordershipment.py index b137e2dea8..c2cc40f4db 100644 --- a/InvenTree/order/migrations/0053_salesordershipment.py +++ b/InvenTree/order/migrations/0053_salesordershipment.py @@ -13,7 +13,7 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('order', '0052_auto_20211014_0631'), + ('order', '0053_auto_20211128_0151'), ] operations = [ diff --git a/InvenTree/order/migrations/0059_salesordershipment_tracking_number.py b/InvenTree/order/migrations/0059_salesordershipment_tracking_number.py new file mode 100644 index 0000000000..473761fb4a --- /dev/null +++ b/InvenTree/order/migrations/0059_salesordershipment_tracking_number.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.5 on 2021-11-29 11:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('order', '0058_auto_20211126_1210'), + ] + + operations = [ + migrations.AddField( + model_name='salesordershipment', + name='tracking_number', + field=models.CharField(blank=True, help_text='Shipment tracking information', max_length=100, verbose_name='Tracking Number'), + ), + ] diff --git a/InvenTree/order/models.py b/InvenTree/order/models.py index 73c507b38d..866ecd967a 100644 --- a/InvenTree/order/models.py +++ b/InvenTree/order/models.py @@ -971,6 +971,35 @@ class SalesOrderShipment(models.Model): help_text=_('Shipment notes'), ) + tracking_number = models.CharField( + max_length=100, + blank=True, + unique=False, + verbose_name=_('Tracking Number'), + help_text=_('Shipment tracking information'), + ) + + @transaction.atomic + def complete_shipment(self): + """ + Complete this particular shipment: + + 1. Update any stock items associated with this shipment + 2. Update the "shipped" quantity of all associated line items + 3. Set the "shipment_date" to now + """ + + # Iterate through each stock item assigned to this shipment + for allocation in self.allocations.all(): + pass + + + + # Update the "shipment" date + self.shipment_date = datetime.now() + self.save() + + class SalesOrderAllocation(models.Model): """ diff --git a/InvenTree/order/serializers.py b/InvenTree/order/serializers.py index 8cb1e8a699..d2179d16b7 100644 --- a/InvenTree/order/serializers.py +++ b/InvenTree/order/serializers.py @@ -18,8 +18,8 @@ from rest_framework.serializers import ValidationError from sql_util.utils import SubqueryCount from common.settings import currency_code_mappings - from company.serializers import CompanyBriefSerializer, SupplierPartSerializer + from InvenTree.serializers import InvenTreeAttachmentSerializer from InvenTree.helpers import normalize from InvenTree.serializers import InvenTreeModelSerializer @@ -35,6 +35,8 @@ from part.serializers import PartBriefSerializer import stock.models import stock.serializers +from users.serializers import OwnerSerializer + class POSerializer(InvenTreeModelSerializer): """ @@ -84,6 +86,8 @@ class POSerializer(InvenTreeModelSerializer): reference = serializers.CharField(required=True) + responsible_detail = OwnerSerializer(source='responsible', read_only=True, many=False) + class Meta: model = order.models.PurchaseOrder @@ -98,6 +102,7 @@ class POSerializer(InvenTreeModelSerializer): 'overdue', 'reference', 'responsible', + 'responsible_detail', 'supplier', 'supplier_detail', 'supplier_reference', @@ -372,8 +377,6 @@ class POAttachmentSerializer(InvenTreeAttachmentSerializer): Serializers for the PurchaseOrderAttachment model """ - attachment = InvenTreeAttachmentSerializerField(required=True) - class Meta: model = order.models.PurchaseOrderAttachment @@ -381,6 +384,7 @@ class POAttachmentSerializer(InvenTreeAttachmentSerializer): 'pk', 'order', 'attachment', + 'link', 'filename', 'comment', 'upload_date', @@ -608,6 +612,7 @@ class SalesOrderShipmentSerializer(InvenTreeModelSerializer): 'shipment_date', 'checked_by', 'reference', + 'tracking_number', 'notes', ] @@ -771,8 +776,6 @@ class SOAttachmentSerializer(InvenTreeAttachmentSerializer): Serializers for the SalesOrderAttachment model """ - attachment = InvenTreeAttachmentSerializerField(required=True) - class Meta: model = order.models.SalesOrderAttachment @@ -781,6 +784,7 @@ class SOAttachmentSerializer(InvenTreeAttachmentSerializer): 'order', 'attachment', 'filename', + 'link', 'comment', 'upload_date', ] diff --git a/InvenTree/order/templates/order/purchase_order_detail.html b/InvenTree/order/templates/order/purchase_order_detail.html index 257707347a..3a6ea090d5 100644 --- a/InvenTree/order/templates/order/purchase_order_detail.html +++ b/InvenTree/order/templates/order/purchase_order_detail.html @@ -124,51 +124,16 @@ } ); - loadAttachmentTable( - '{% url "api-po-attachment-list" %}', - { - filters: { - order: {{ order.pk }}, - }, - onEdit: function(pk) { - var url = `/api/order/po/attachment/${pk}/`; - - constructForm(url, { - fields: { - filename: {}, - comment: {}, - }, - onSuccess: reloadAttachmentTable, - title: '{% trans "Edit Attachment" %}', - }); - }, - onDelete: function(pk) { - - constructForm(`/api/order/po/attachment/${pk}/`, { - method: 'DELETE', - confirmMessage: '{% trans "Confirm Delete Operation" %}', - title: '{% trans "Delete Attachment" %}', - onSuccess: reloadAttachmentTable, - }); + loadAttachmentTable('{% url "api-po-attachment-list" %}', { + filters: { + order: {{ order.pk }}, + }, + fields: { + order: { + value: {{ order.pk }}, + hidden: true, } } - ); - - $("#new-attachment").click(function() { - - constructForm('{% url "api-po-attachment-list" %}', { - method: 'POST', - fields: { - attachment: {}, - comment: {}, - order: { - value: {{ order.pk }}, - hidden: true, - }, - }, - reload: true, - title: '{% trans "Add Attachment" %}', - }); }); loadStockTable($("#stock-table"), { diff --git a/InvenTree/order/templates/order/sales_order_detail.html b/InvenTree/order/templates/order/sales_order_detail.html index 67c4f951e4..bb90f4386f 100644 --- a/InvenTree/order/templates/order/sales_order_detail.html +++ b/InvenTree/order/templates/order/sales_order_detail.html @@ -194,55 +194,21 @@ }, label: 'attachment', success: function(data, status, xhr) { - location.reload(); + reloadAttachmentTable(); } } ); - loadAttachmentTable( - '{% url "api-so-attachment-list" %}', - { - filters: { - order: {{ order.pk }}, + loadAttachmentTable('{% url "api-so-attachment-list" %}', { + filters: { + order: {{ order.pk }}, + }, + fields: { + order: { + value: {{ order.pk }}, + hidden: true, }, - onEdit: function(pk) { - var url = `/api/order/so/attachment/${pk}/`; - - constructForm(url, { - fields: { - filename: {}, - comment: {}, - }, - onSuccess: reloadAttachmentTable, - title: '{% trans "Edit Attachment" %}', - }); - }, - onDelete: function(pk) { - constructForm(`/api/order/so/attachment/${pk}/`, { - method: 'DELETE', - confirmMessage: '{% trans "Confirm Delete Operation" %}', - title: '{% trans "Delete Attachment" %}', - onSuccess: reloadAttachmentTable, - }); - } } - ); - - $("#new-attachment").click(function() { - - constructForm('{% url "api-so-attachment-list" %}', { - method: 'POST', - fields: { - attachment: {}, - comment: {}, - order: { - value: {{ order.pk }}, - hidden: true - } - }, - onSuccess: reloadAttachmentTable, - title: '{% trans "Add Attachment" %}' - }); }); loadBuildTable($("#builds-table"), { diff --git a/InvenTree/part/api.py b/InvenTree/part/api.py index 00b5dfa7de..403934d3d9 100644 --- a/InvenTree/part/api.py +++ b/InvenTree/part/api.py @@ -42,7 +42,7 @@ from build.models import Build from . import serializers as part_serializers -from InvenTree.helpers import str2bool, isNull +from InvenTree.helpers import str2bool, isNull, increment from InvenTree.api import AttachmentMixin from InvenTree.status_codes import BuildStatus @@ -410,6 +410,33 @@ class PartThumbsUpdate(generics.RetrieveUpdateAPIView): ] +class PartSerialNumberDetail(generics.RetrieveAPIView): + """ + API endpoint for returning extra serial number information about a particular part + """ + + queryset = Part.objects.all() + + def retrieve(self, request, *args, **kwargs): + + part = self.get_object() + + # Calculate the "latest" serial number + latest = part.getLatestSerialNumber() + + data = { + 'latest': latest, + } + + if latest is not None: + next = increment(latest) + + if next != increment: + data['next'] = next + + return Response(data) + + class PartDetail(generics.RetrieveUpdateDestroyAPIView): """ API endpoint for detail view of a single Part object """ @@ -1532,7 +1559,14 @@ part_api_urls = [ url(r'^(?P\d+)/?', PartThumbsUpdate.as_view(), name='api-part-thumbs-update'), ])), - url(r'^(?P\d+)/', PartDetail.as_view(), name='api-part-detail'), + url(r'^(?P\d+)/', include([ + + # Endpoint for extra serial number information + url(r'^serial-numbers/', PartSerialNumberDetail.as_view(), name='api-part-serial-number-detail'), + + # Part detail endpoint + url(r'^.*$', PartDetail.as_view(), name='api-part-detail'), + ])), url(r'^.*$', PartList.as_view(), name='api-part-list'), ] diff --git a/InvenTree/part/migrations/0075_auto_20211128_0151.py b/InvenTree/part/migrations/0075_auto_20211128_0151.py new file mode 100644 index 0000000000..d484a7adce --- /dev/null +++ b/InvenTree/part/migrations/0075_auto_20211128_0151.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.5 on 2021-11-28 01:51 + +import InvenTree.fields +import InvenTree.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('part', '0074_partcategorystar'), + ] + + operations = [ + migrations.AddField( + model_name='partattachment', + name='link', + field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link'), + ), + migrations.AlterField( + model_name='partattachment', + name='attachment', + field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'), + ), + ] diff --git a/InvenTree/part/serializers.py b/InvenTree/part/serializers.py index 388faf1ca2..1be81c16ba 100644 --- a/InvenTree/part/serializers.py +++ b/InvenTree/part/serializers.py @@ -75,8 +75,6 @@ class PartAttachmentSerializer(InvenTreeAttachmentSerializer): Serializer for the PartAttachment class """ - attachment = InvenTreeAttachmentSerializerField(required=True) - class Meta: model = PartAttachment @@ -85,6 +83,7 @@ class PartAttachmentSerializer(InvenTreeAttachmentSerializer): 'part', 'attachment', 'filename', + 'link', 'comment', 'upload_date', ] diff --git a/InvenTree/part/templates/part/detail.html b/InvenTree/part/templates/part/detail.html index a737bfa6fc..4cf6f5e824 100644 --- a/InvenTree/part/templates/part/detail.html +++ b/InvenTree/part/templates/part/detail.html @@ -999,36 +999,17 @@ }); onPanelLoad("part-attachments", function() { - loadAttachmentTable( - '{% url "api-part-attachment-list" %}', - { - filters: { - part: {{ part.pk }}, - }, - onEdit: function(pk) { - var url = `/api/part/attachment/${pk}/`; - - constructForm(url, { - fields: { - filename: {}, - comment: {}, - }, - title: '{% trans "Edit Attachment" %}', - onSuccess: reloadAttachmentTable, - }); - }, - onDelete: function(pk) { - var url = `/api/part/attachment/${pk}/`; - - constructForm(url, { - method: 'DELETE', - confirmMessage: '{% trans "Confirm Delete Operation" %}', - title: '{% trans "Delete Attachment" %}', - onSuccess: reloadAttachmentTable, - }); + loadAttachmentTable('{% url "api-part-attachment-list" %}', { + filters: { + part: {{ part.pk }}, + }, + fields: { + part: { + value: {{ part.pk }}, + hidden: true } } - ); + }); enableDragAndDrop( '#attachment-dropzone', @@ -1043,26 +1024,6 @@ } } ); - - $("#new-attachment").click(function() { - - constructForm( - '{% url "api-part-attachment-list" %}', - { - method: 'POST', - fields: { - attachment: {}, - comment: {}, - part: { - value: {{ part.pk }}, - hidden: true, - } - }, - onSuccess: reloadAttachmentTable, - title: '{% trans "Add Attachment" %}', - } - ) - }); }); diff --git a/InvenTree/stock/migrations/0070_auto_20211128_0151.py b/InvenTree/stock/migrations/0070_auto_20211128_0151.py new file mode 100644 index 0000000000..a2f6ef322d --- /dev/null +++ b/InvenTree/stock/migrations/0070_auto_20211128_0151.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.5 on 2021-11-28 01:51 + +import InvenTree.fields +import InvenTree.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('stock', '0069_auto_20211109_2347'), + ] + + operations = [ + migrations.AddField( + model_name='stockitemattachment', + name='link', + field=InvenTree.fields.InvenTreeURLField(blank=True, help_text='Link to external URL', null=True, verbose_name='Link'), + ), + migrations.AlterField( + model_name='stockitemattachment', + name='attachment', + field=models.FileField(blank=True, help_text='Select file to attach', null=True, upload_to=InvenTree.models.rename_attachment, verbose_name='Attachment'), + ), + ] diff --git a/InvenTree/stock/serializers.py b/InvenTree/stock/serializers.py index 840eb4793e..c74d674275 100644 --- a/InvenTree/stock/serializers.py +++ b/InvenTree/stock/serializers.py @@ -420,8 +420,6 @@ class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSer user_detail = InvenTree.serializers.UserSerializerBrief(source='user', read_only=True) - attachment = InvenTree.serializers.InvenTreeAttachmentSerializerField(required=True) - # TODO: Record the uploading user when creating or updating an attachment! class Meta: @@ -432,6 +430,7 @@ class StockItemAttachmentSerializer(InvenTree.serializers.InvenTreeAttachmentSer 'stock_item', 'attachment', 'filename', + 'link', 'comment', 'upload_date', 'user', diff --git a/InvenTree/stock/templates/stock/item.html b/InvenTree/stock/templates/stock/item.html index 9bafc2633c..9cc6d85aeb 100644 --- a/InvenTree/stock/templates/stock/item.html +++ b/InvenTree/stock/templates/stock/item.html @@ -221,55 +221,16 @@ } ); - loadAttachmentTable( - '{% url "api-stock-attachment-list" %}', - { - filters: { - stock_item: {{ item.pk }}, - }, - onEdit: function(pk) { - var url = `/api/stock/attachment/${pk}/`; - - constructForm(url, { - fields: { - filename: {}, - comment: {}, - }, - title: '{% trans "Edit Attachment" %}', - onSuccess: reloadAttachmentTable - }); - }, - onDelete: function(pk) { - var url = `/api/stock/attachment/${pk}/`; - - constructForm(url, { - method: 'DELETE', - confirmMessage: '{% trans "Confirm Delete Operation" %}', - title: '{% trans "Delete Attachment" %}', - onSuccess: reloadAttachmentTable, - }); + loadAttachmentTable('{% url "api-stock-attachment-list" %}', { + filters: { + stock_item: {{ item.pk }}, + }, + fields: { + stock_item: { + value: {{ item.pk }}, + hidden: true, } } - ); - - $("#new-attachment").click(function() { - - constructForm( - '{% url "api-stock-attachment-list" %}', - { - method: 'POST', - fields: { - attachment: {}, - comment: {}, - stock_item: { - value: {{ item.pk }}, - hidden: true, - }, - }, - reload: true, - title: '{% trans "Add Attachment" %}', - } - ); }); loadStockTestResultsTable( diff --git a/InvenTree/stock/templates/stock/item_base.html b/InvenTree/stock/templates/stock/item_base.html index ff117cd071..f35462f87c 100644 --- a/InvenTree/stock/templates/stock/item_base.html +++ b/InvenTree/stock/templates/stock/item_base.html @@ -442,6 +442,7 @@ $("#stock-serialize").click(function() { serializeStockItem({{ item.pk }}, { + part: {{ item.part.pk }}, reload: true, data: { quantity: {{ item.quantity }}, diff --git a/InvenTree/templates/InvenTree/settings/user.html b/InvenTree/templates/InvenTree/settings/user.html index a4c12a9bb0..cfc5b421e3 100644 --- a/InvenTree/templates/InvenTree/settings/user.html +++ b/InvenTree/templates/InvenTree/settings/user.html @@ -4,6 +4,7 @@ {% load inventree_extras %} {% load socialaccount %} {% load crispy_forms_tags %} +{% load user_sessions i18n %} {% block label %}account{% endblock %} @@ -14,12 +15,12 @@ {% block actions %} {% inventree_demo_mode as demo %} {% if not demo %} +
+ {% trans "Set Password" %} +
{% trans "Edit" %}
-
- {% trans "Set Password" %} -
{% endif %} {% endblock %} @@ -174,58 +175,48 @@
-

{% trans "Language Settings" %}

-
- -
-
-
- {% csrf_token %} - - -
- -
- -
-
-

{% trans "Some languages are not complete" %} - {% if ALL_LANG %} - . {% trans "Show only sufficent" %} - {% else %} - {% trans "and hidden." %} {% trans "Show them too" %} +

+

{% trans "Active Sessions" %}

+ {% include "spacer.html" %} +
+ {% if session_list.count > 1 %} + + {% csrf_token %} + + {% endif %} -

- -
-
-

{% trans "Help the translation efforts!" %}

-

{% blocktrans with link="https://crowdin.com/project/inventree" %}Native language translation of the InvenTree web application is community contributed via crowdin. Contributions are welcomed and encouraged.{% endblocktrans %}

+
+
+ {% trans "unknown on unknown" as unknown_on_unknown %} + {% trans "unknown" as unknown %} + + + + + + + + + {% for object in session_list %} + + + + + + {% endfor %} +
{% trans "IP Address" %}{% trans "Device" %}{% trans "Last Activity" %}
{{ object.ip }}{{ object.user_agent|device|default_if_none:unknown_on_unknown|safe }} + {% if object.session_key == session_key %} + {% blocktrans with time=object.last_activity|timesince %}{{ time }} ago (this session){% endblocktrans %} + {% else %} + {% blocktrans with time=object.last_activity|timesince %}{{ time }} ago{% endblocktrans %} + {% endif %} +
+
{% endblock %} {% block js_ready %} diff --git a/InvenTree/templates/InvenTree/settings/user_display.html b/InvenTree/templates/InvenTree/settings/user_display.html index ae7843df9c..9f5c22f991 100644 --- a/InvenTree/templates/InvenTree/settings/user_display.html +++ b/InvenTree/templates/InvenTree/settings/user_display.html @@ -50,4 +50,57 @@
+
+

{% trans "Language Settings" %}

+
+ +
+
+
+ {% csrf_token %} + + +
+ +
+ +
+
+

{% trans "Some languages are not complete" %} + {% if ALL_LANG %} + . {% trans "Show only sufficent" %} + {% else %} + {% trans "and hidden." %} {% trans "Show them too" %} + {% endif %} +

+
+
+
+

{% trans "Help the translation efforts!" %}

+

{% blocktrans with link="https://crowdin.com/project/inventree" %}Native language translation of the InvenTree web application is community contributed via crowdin. Contributions are welcomed and encouraged.{% endblocktrans %}

+
+
+ {% endblock %} \ No newline at end of file diff --git a/InvenTree/templates/attachment_button.html b/InvenTree/templates/attachment_button.html index e1561010c0..d220f4829d 100644 --- a/InvenTree/templates/attachment_button.html +++ b/InvenTree/templates/attachment_button.html @@ -1,5 +1,8 @@ {% load i18n %} + \ No newline at end of file diff --git a/InvenTree/templates/js/translated/api.js b/InvenTree/templates/js/translated/api.js index 735ce0a676..d9c23f035f 100644 --- a/InvenTree/templates/js/translated/api.js +++ b/InvenTree/templates/js/translated/api.js @@ -54,6 +54,7 @@ function inventreeGet(url, filters={}, options={}) { data: filters, dataType: 'json', contentType: 'application/json', + async: (options.async == false) ? false : true, success: function(response) { if (options.success) { options.success(response); diff --git a/InvenTree/templates/js/translated/attachment.js b/InvenTree/templates/js/translated/attachment.js index 5ff5786588..5c5af5682f 100644 --- a/InvenTree/templates/js/translated/attachment.js +++ b/InvenTree/templates/js/translated/attachment.js @@ -6,10 +6,57 @@ */ /* exported + addAttachmentButtonCallbacks, loadAttachmentTable, reloadAttachmentTable, */ + +/* + * Add callbacks to buttons for creating new attachments. + * + * Note: Attachments can also be external links! + */ +function addAttachmentButtonCallbacks(url, fields={}) { + + // Callback for 'new attachment' button + $('#new-attachment').click(function() { + + var file_fields = { + attachment: {}, + comment: {}, + }; + + Object.assign(file_fields, fields); + + constructForm(url, { + fields: file_fields, + method: 'POST', + onSuccess: reloadAttachmentTable, + title: '{% trans "Add Attachment" %}', + }); + }); + + // Callback for 'new link' button + $('#new-attachment-link').click(function() { + + var link_fields = { + link: {}, + comment: {}, + }; + + Object.assign(link_fields, fields); + + constructForm(url, { + fields: link_fields, + method: 'POST', + onSuccess: reloadAttachmentTable, + title: '{% trans "Add Link" %}', + }); + }); +} + + function reloadAttachmentTable() { $('#attachment-table').bootstrapTable('refresh'); @@ -20,6 +67,8 @@ function loadAttachmentTable(url, options) { var table = options.table || '#attachment-table'; + addAttachmentButtonCallbacks(url, options.fields || {}); + $(table).inventreeTable({ url: url, name: options.name || 'attachments', @@ -34,56 +83,77 @@ function loadAttachmentTable(url, options) { $(table).find('.button-attachment-edit').click(function() { var pk = $(this).attr('pk'); - if (options.onEdit) { - options.onEdit(pk); - } + constructForm(`${url}${pk}/`, { + fields: { + link: {}, + comment: {}, + }, + processResults: function(data, fields, opts) { + // Remove the "link" field if the attachment is a file! + if (data.attachment) { + delete opts.fields.link; + } + }, + onSuccess: reloadAttachmentTable, + title: '{% trans "Edit Attachment" %}', + }); }); // Add callback for 'delete' button $(table).find('.button-attachment-delete').click(function() { var pk = $(this).attr('pk'); - if (options.onDelete) { - options.onDelete(pk); - } + constructForm(`${url}${pk}/`, { + method: 'DELETE', + confirmMessage: '{% trans "Confirm Delete" %}', + title: '{% trans "Delete Attachment" %}', + onSuccess: reloadAttachmentTable, + }); }); }, columns: [ { field: 'attachment', - title: '{% trans "File" %}', - formatter: function(value) { + title: '{% trans "Attachment" %}', + formatter: function(value, row) { - var icon = 'fa-file-alt'; + if (row.attachment) { + var icon = 'fa-file-alt'; - var fn = value.toLowerCase(); + var fn = value.toLowerCase(); - if (fn.endsWith('.csv')) { - icon = 'fa-file-csv'; - } else if (fn.endsWith('.pdf')) { - icon = 'fa-file-pdf'; - } else if (fn.endsWith('.xls') || fn.endsWith('.xlsx')) { - icon = 'fa-file-excel'; - } else if (fn.endsWith('.doc') || fn.endsWith('.docx')) { - icon = 'fa-file-word'; - } else if (fn.endsWith('.zip') || fn.endsWith('.7z')) { - icon = 'fa-file-archive'; + if (fn.endsWith('.csv')) { + icon = 'fa-file-csv'; + } else if (fn.endsWith('.pdf')) { + icon = 'fa-file-pdf'; + } else if (fn.endsWith('.xls') || fn.endsWith('.xlsx')) { + icon = 'fa-file-excel'; + } else if (fn.endsWith('.doc') || fn.endsWith('.docx')) { + icon = 'fa-file-word'; + } else if (fn.endsWith('.zip') || fn.endsWith('.7z')) { + icon = 'fa-file-archive'; + } else { + var images = ['.png', '.jpg', '.bmp', '.gif', '.svg', '.tif']; + + images.forEach(function(suffix) { + if (fn.endsWith(suffix)) { + icon = 'fa-file-image'; + } + }); + } + + var split = value.split('/'); + var filename = split[split.length - 1]; + + var html = ` ${filename}`; + + return renderLink(html, value); + } else if (row.link) { + var html = ` ${row.link}`; + return renderLink(html, row.link); } else { - var images = ['.png', '.jpg', '.bmp', '.gif', '.svg', '.tif']; - - images.forEach(function(suffix) { - if (fn.endsWith(suffix)) { - icon = 'fa-file-image'; - } - }); + return '-'; } - - var split = value.split('/'); - var filename = split[split.length - 1]; - - var html = ` ${filename}`; - - return renderLink(html, value); } }, { diff --git a/InvenTree/templates/js/translated/forms.js b/InvenTree/templates/js/translated/forms.js index 7a92604754..fdd8b60d28 100644 --- a/InvenTree/templates/js/translated/forms.js +++ b/InvenTree/templates/js/translated/forms.js @@ -28,6 +28,7 @@ disableFormInput, enableFormInput, hideFormInput, + setFormInputPlaceholder, setFormGroupVisibility, showFormInput, */ @@ -1276,6 +1277,11 @@ function initializeGroups(fields, options) { } } +// Set the placeholder value for a field +function setFormInputPlaceholder(name, placeholder, options) { + $(options.modal).find(`#id_${name}`).attr('placeholder', placeholder); +} + // Clear a form input function clearFormInput(name, options) { updateFieldValue(name, null, {}, options); diff --git a/InvenTree/templates/js/translated/order.js b/InvenTree/templates/js/translated/order.js index 5766b7216d..478bccc2a5 100644 --- a/InvenTree/templates/js/translated/order.js +++ b/InvenTree/templates/js/translated/order.js @@ -729,6 +729,23 @@ function loadPurchaseOrderTable(table, options) { title: '{% trans "Items" %}', sortable: true, }, + { + field: 'responsible', + title: '{% trans "Responsible" %}', + switchable: true, + sortable: false, + formatter: function(value, row) { + var html = row.responsible_detail.name; + + if (row.responsible_detail.label == 'group') { + html += ``; + } else { + html += ``; + } + + return html; + } + }, ], }); } diff --git a/InvenTree/templates/js/translated/stock.js b/InvenTree/templates/js/translated/stock.js index c624278c93..5e92299f03 100644 --- a/InvenTree/templates/js/translated/stock.js +++ b/InvenTree/templates/js/translated/stock.js @@ -80,6 +80,20 @@ function serializeStockItem(pk, options={}) { notes: {}, }; + if (options.part) { + // Work out the next available serial number + inventreeGet(`/api/part/${options.part}/serial-numbers/`, {}, { + success: function(data) { + if (data.next) { + options.fields.serial_numbers.placeholder = `{% trans "Next available serial number" %}: ${data.next}`; + } else if (data.latest) { + options.fields.serial_numbers.placeholder = `{% trans "Latest serial number" %}: ${data.latest}`; + } + }, + async: false, + }); + } + constructForm(url, options); } @@ -144,10 +158,26 @@ function stockItemFields(options={}) { // If a "trackable" part is selected, enable serial number field if (data.trackable) { enableFormInput('serial_numbers', opts); - // showFormInput('serial_numbers', opts); + + // Request part serial number information from the server + inventreeGet(`/api/part/${data.pk}/serial-numbers/`, {}, { + success: function(data) { + var placeholder = ''; + if (data.next) { + placeholder = `{% trans "Next available serial number" %}: ${data.next}`; + } else if (data.latest) { + placeholder = `{% trans "Latest serial number" %}: ${data.latest}`; + } + + setFormInputPlaceholder('serial_numbers', placeholder, opts); + } + }); + } else { clearFormInput('serial_numbers', opts); disableFormInput('serial_numbers', opts); + + setFormInputPlaceholder('serial_numbers', '{% trans "This part cannot be serialized" %}', opts); } // Enable / disable fields based on purchaseable status diff --git a/InvenTree/users/models.py b/InvenTree/users/models.py index 3cab905b68..2b16a9bb05 100644 --- a/InvenTree/users/models.py +++ b/InvenTree/users/models.py @@ -146,7 +146,6 @@ class RuleSet(models.Model): # Core django models (not user configurable) 'admin_logentry', 'contenttypes_contenttype', - 'sessions_session', # Models which currently do not require permissions 'common_colortheme', @@ -160,6 +159,7 @@ class RuleSet(models.Model): 'error_report_error', 'exchange_rate', 'exchange_exchangebackend', + 'user_sessions_session', # Django-q 'django_q_ormq', diff --git a/requirements.txt b/requirements.txt index 139942fd80..5f5695ed3d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,6 +23,7 @@ django-q==1.3.4 # Background task scheduling django-sql-utils==0.5.0 # Advanced query annotation / aggregation django-stdimage==5.1.1 # Advanced ImageField management django-test-migrations==1.1.0 # Unit testing for database migrations +django-user-sessions==1.7.1 # user sessions in DB django-weasyprint==1.0.1 # django weasyprint integration djangorestframework==3.12.4 # DRF framework flake8==3.8.3 # PEP checking diff --git a/tasks.py b/tasks.py index 21f7616d76..7408bb40b5 100644 --- a/tasks.py +++ b/tasks.py @@ -279,7 +279,6 @@ def content_excludes(): excludes = [ "contenttypes", - "sessions.session", "auth.permission", "authtoken.token", "error_report.error", @@ -291,6 +290,7 @@ def content_excludes(): "exchange.rate", "exchange.exchangebackend", "common.notificationentry", + "user_sessions.session", ] output = ""