mirror of
https://github.com/inventree/InvenTree.git
synced 2025-06-16 12:05:53 +00:00
[UI] Reimplement calendar views (#8933)
* Add deps for @fullcalendar * Basic calendar setup * Add locale support * Add custom <SegmentedIconControl> component * optional 'control' for panel * Scaffolding out build index page * Fetch build orders based on calendar dates * Display builds * Click to navigate to build * Working on custom header * Implement button functionality * Display current month name * Implement datepicker dropdown * Adjust layout * Custom rendering * Include part detail * Implement hook to track calendar state * Clearing search causes immediate update * Offload core functionality into hook - Adds search input * Spread props correctly * Cleanup * Allow build orders to be edited directly from calendar view * Cleanup * Fix for InvenTreeCalendarProps * Improve date range filtering for Build model * Simplify BuildFilter * Refactoring * PurchaseOrder calendar implementation * Allow edit of purchase orders * Refactor and implement for all order types - Add missing "issue_date" field for SalesOrder - Refactor API for other order types - Fix rendering issues * Bump API version * Add playwright tests for calendar (simple) * Fix migrations * Simplify notifications * Refactor filter components - Make them more generic * Enable custom filters for calendars * Custom filters per table * Display status label * Refactoring * Update playwright tests * Update build order docs * Bug fix for order date editing * Update purchase order docs * Return Order docs * Update date formatting
This commit is contained in:
@ -1,13 +1,16 @@
|
||||
"""InvenTree API version information."""
|
||||
|
||||
# InvenTree API version
|
||||
INVENTREE_API_VERSION = 321
|
||||
INVENTREE_API_VERSION = 322
|
||||
|
||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||
|
||||
|
||||
INVENTREE_API_TEXT = """
|
||||
|
||||
v322 - 2025-03-16 : https://github.com/inventree/InvenTree/pull/8933
|
||||
- Add min_date and max_date query filters for orders, for use in calendar views
|
||||
|
||||
v321 - 2025-03-06 : https://github.com/inventree/InvenTree/pull/9236
|
||||
- Adds conditionally-returned fields to the schema to match API behavior
|
||||
- Removes required flag for nullable read-only fields to match API behavior
|
||||
|
@ -242,6 +242,65 @@ class BuildFilter(rest_filters.FilterSet):
|
||||
label=_('Completed after'), field_name='completion_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
min_date = InvenTreeDateFilter(label=_('Min Date'), method='filter_min_date')
|
||||
|
||||
def filter_min_date(self, queryset, name, value):
|
||||
"""Filter the queryset to include orders *after* a specified date.
|
||||
|
||||
This filter is used in combination with filter_max_date,
|
||||
to provide a queryset which matches a particular range of dates.
|
||||
|
||||
In particular, this is used in the UI for the calendar view.
|
||||
|
||||
So, we are interested in orders which are active *after* this date:
|
||||
|
||||
- creation_date is set *after* this date (but there is no start date)
|
||||
- start_date is set *after* this date
|
||||
- target_date is set *after* this date
|
||||
|
||||
"""
|
||||
q1 = Q(creation_date__gte=value, start_date__isnull=True)
|
||||
q2 = Q(start_date__gte=value)
|
||||
q3 = Q(target_date__gte=value)
|
||||
|
||||
return queryset.filter(q1 | q2 | q3).distinct()
|
||||
|
||||
max_date = InvenTreeDateFilter(label=_('Max Date'), method='filter_max_date')
|
||||
|
||||
def filter_max_date(self, queryset, name, value):
|
||||
"""Filter the queryset to include orders *before* a specified date.
|
||||
|
||||
This filter is used in combination with filter_min_date,
|
||||
to provide a queryset which matches a particular range of dates.
|
||||
|
||||
In particular, this is used in the UI for the calendar view.
|
||||
|
||||
So, we are interested in orders which are active *before* this date:
|
||||
|
||||
- creation_date is set *before* this date (but there is no start date)
|
||||
- start_date is set *before* this date
|
||||
- target_date is set *before* this date
|
||||
"""
|
||||
q1 = Q(creation_date__lte=value, start_date__isnull=True)
|
||||
q2 = Q(start_date__lte=value)
|
||||
q3 = Q(target_date__lte=value)
|
||||
|
||||
return queryset.filter(q1 | q2 | q3).distinct()
|
||||
|
||||
exclude_tree = rest_filters.ModelChoiceFilter(
|
||||
queryset=Build.objects.all(),
|
||||
method='filter_exclude_tree',
|
||||
label=_('Exclude Tree'),
|
||||
)
|
||||
|
||||
def filter_exclude_tree(self, queryset, name, value):
|
||||
"""Filter by excluding a tree of Build objects."""
|
||||
queryset = queryset.exclude(
|
||||
pk__in=[bld.pk for bld in value.get_descendants(include_self=True)]
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class BuildMixin:
|
||||
"""Mixin class for Build API endpoints."""
|
||||
@ -319,35 +378,6 @@ class BuildList(DataExportViewMixin, BuildMixin, ListCreateAPI):
|
||||
|
||||
return queryset
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Custom query filtering for the BuildList endpoint."""
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
params = self.request.query_params
|
||||
|
||||
# exclude parent tree
|
||||
exclude_tree = params.get('exclude_tree', None)
|
||||
|
||||
if exclude_tree is not None:
|
||||
try:
|
||||
build = Build.objects.get(pk=exclude_tree)
|
||||
|
||||
queryset = queryset.exclude(
|
||||
pk__in=[bld.pk for bld in build.get_descendants(include_self=True)]
|
||||
)
|
||||
|
||||
except (ValueError, Build.DoesNotExist):
|
||||
pass
|
||||
|
||||
# Filter by 'date range'
|
||||
min_date = params.get('min_date', None)
|
||||
max_date = params.get('max_date', None)
|
||||
|
||||
if min_date is not None and max_date is not None:
|
||||
queryset = Build.filterByDate(queryset, min_date, max_date)
|
||||
|
||||
return queryset
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
"""Add extra context information to the endpoint serializer."""
|
||||
try:
|
||||
|
@ -1,7 +1,6 @@
|
||||
"""Build database model definitions."""
|
||||
|
||||
import decimal
|
||||
from datetime import datetime
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.exceptions import ValidationError
|
||||
@ -197,44 +196,6 @@ class Build(
|
||||
'title': str(self),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def filterByDate(queryset, min_date, max_date):
|
||||
"""Filter by 'minimum and maximum date range'.
|
||||
|
||||
- Specified as min_date, max_date
|
||||
- Both must be specified for filter to be applied
|
||||
"""
|
||||
date_fmt = '%Y-%m-%d' # ISO format date string
|
||||
|
||||
# Ensure that both dates are valid
|
||||
try:
|
||||
min_date = datetime.strptime(str(min_date), date_fmt).date()
|
||||
max_date = datetime.strptime(str(max_date), date_fmt).date()
|
||||
except (ValueError, TypeError):
|
||||
# Date processing error, return queryset unchanged
|
||||
return queryset
|
||||
|
||||
# Order was completed within the specified range
|
||||
completed = (
|
||||
Q(status=BuildStatus.COMPLETE.value)
|
||||
& Q(completion_date__gte=min_date)
|
||||
& Q(completion_date__lte=max_date)
|
||||
)
|
||||
|
||||
# Order target date falls within specified range
|
||||
pending = (
|
||||
Q(status__in=BuildStatusGroups.ACTIVE_CODES)
|
||||
& ~Q(target_date=None)
|
||||
& Q(target_date__gte=min_date)
|
||||
& Q(target_date__lte=max_date)
|
||||
)
|
||||
|
||||
# TODO - Construct a queryset for "overdue" orders
|
||||
|
||||
queryset = queryset.filter(completed | pending)
|
||||
|
||||
return queryset
|
||||
|
||||
def __str__(self):
|
||||
"""String representation of a BuildOrder."""
|
||||
return self.reference
|
||||
|
@ -213,6 +213,44 @@ class OrderFilter(rest_filters.FilterSet):
|
||||
label=_('Target Date After'), field_name='target_date', lookup_expr='gt'
|
||||
)
|
||||
|
||||
min_date = InvenTreeDateFilter(label=_('Min Date'), method='filter_min_date')
|
||||
|
||||
def filter_min_date(self, queryset, name, value):
|
||||
"""Filter the queryset to include orders *after* a specified date.
|
||||
|
||||
This is used in combination with filter_max_date,
|
||||
to provide a queryset which matches a particular range of dates.
|
||||
|
||||
In particular, this is used in the UI for the calendar view.
|
||||
"""
|
||||
q1 = Q(
|
||||
creation_date__gte=value, issue_date__isnull=True, start_date__isnull=True
|
||||
)
|
||||
q2 = Q(issue_date__gte=value, start_date__isnull=True)
|
||||
q3 = Q(start_date__gte=value)
|
||||
q4 = Q(target_date__gte=value)
|
||||
|
||||
return queryset.filter(q1 | q2 | q3 | q4).distinct()
|
||||
|
||||
max_date = InvenTreeDateFilter(label=_('Max Date'), method='filter_max_date')
|
||||
|
||||
def filter_max_date(self, queryset, name, value):
|
||||
"""Filter the queryset to include orders *before* a specified date.
|
||||
|
||||
This is used in combination with filter_min_date,
|
||||
to provide a queryset which matches a particular range of dates.
|
||||
|
||||
In particular, this is used in the UI for the calendar view.
|
||||
"""
|
||||
q1 = Q(
|
||||
creation_date__lte=value, issue_date__isnull=True, start_date__isnull=True
|
||||
)
|
||||
q2 = Q(issue_date__lte=value, start_date__isnull=True)
|
||||
q3 = Q(start_date__lte=value)
|
||||
q4 = Q(target_date__lte=value)
|
||||
|
||||
return queryset.filter(q1 | q2 | q3 | q4).distinct()
|
||||
|
||||
|
||||
class LineItemFilter(rest_filters.FilterSet):
|
||||
"""Base class for custom API filters for order line item list(s)."""
|
||||
@ -323,23 +361,6 @@ class PurchaseOrderList(
|
||||
"""
|
||||
|
||||
filterset_class = PurchaseOrderFilter
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Custom queryset filtering."""
|
||||
# Perform basic filtering
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
params = self.request.query_params
|
||||
|
||||
# Filter by 'date range'
|
||||
min_date = params.get('min_date', None)
|
||||
max_date = params.get('max_date', None)
|
||||
|
||||
if min_date is not None and max_date is not None:
|
||||
queryset = models.PurchaseOrder.filterByDate(queryset, min_date, max_date)
|
||||
|
||||
return queryset
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
ordering_field_aliases = {
|
||||
@ -791,21 +812,6 @@ class SalesOrderList(
|
||||
|
||||
filterset_class = SalesOrderFilter
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
"""Perform custom filtering operations on the SalesOrder queryset."""
|
||||
queryset = super().filter_queryset(queryset)
|
||||
|
||||
params = self.request.query_params
|
||||
|
||||
# Filter by 'date range'
|
||||
min_date = params.get('min_date', None)
|
||||
max_date = params.get('max_date', None)
|
||||
|
||||
if min_date is not None and max_date is not None:
|
||||
queryset = models.SalesOrder.filterByDate(queryset, min_date, max_date)
|
||||
|
||||
return queryset
|
||||
|
||||
filter_backends = SEARCH_ORDER_FILTER_ALIAS
|
||||
|
||||
ordering_field_aliases = {
|
||||
|
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.19 on 2025-02-23 04:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("order", "0108_alter_purchaseorder_link_and_more_squashed_0109_alter_purchaseorderextraline_link_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="salesorder",
|
||||
name="issue_date",
|
||||
field=models.DateField(
|
||||
blank=True,
|
||||
help_text="Date order was issued",
|
||||
null=True,
|
||||
verbose_name="Issue Date",
|
||||
),
|
||||
),
|
||||
]
|
@ -1,6 +1,5 @@
|
||||
"""Order model definitions."""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
@ -389,6 +388,13 @@ class Order(
|
||||
verbose_name=_('Created By'),
|
||||
)
|
||||
|
||||
issue_date = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Issue Date'),
|
||||
help_text=_('Date order was issued'),
|
||||
)
|
||||
|
||||
responsible = models.ForeignKey(
|
||||
UserModels.Owner,
|
||||
on_delete=models.SET_NULL,
|
||||
@ -482,50 +488,6 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
"""Return the associated barcode model type code for this model."""
|
||||
return 'PO'
|
||||
|
||||
@staticmethod
|
||||
def filterByDate(queryset, min_date, max_date):
|
||||
"""Filter by 'minimum and maximum date range'.
|
||||
|
||||
- Specified as min_date, max_date
|
||||
- Both must be specified for filter to be applied
|
||||
- Determine which "interesting" orders exist between these dates
|
||||
|
||||
To be "interesting":
|
||||
- A "received" order where the received date lies within the date range
|
||||
- A "pending" order where the target date lies within the date range
|
||||
- TODO: An "overdue" order where the target date is in the past
|
||||
"""
|
||||
date_fmt = '%Y-%m-%d' # ISO format date string
|
||||
|
||||
# Ensure that both dates are valid
|
||||
try:
|
||||
min_date = datetime.strptime(str(min_date), date_fmt).date()
|
||||
max_date = datetime.strptime(str(max_date), date_fmt).date()
|
||||
except (ValueError, TypeError):
|
||||
# Date processing error, return queryset unchanged
|
||||
return queryset
|
||||
|
||||
# Construct a queryset for "received" orders within the range
|
||||
received = (
|
||||
Q(status=PurchaseOrderStatus.COMPLETE.value)
|
||||
& Q(complete_date__gte=min_date)
|
||||
& Q(complete_date__lte=max_date)
|
||||
)
|
||||
|
||||
# Construct a queryset for "pending" orders within the range
|
||||
pending = (
|
||||
Q(status__in=PurchaseOrderStatusGroups.OPEN)
|
||||
& ~Q(target_date=None)
|
||||
& Q(target_date__gte=min_date)
|
||||
& Q(target_date__lte=max_date)
|
||||
)
|
||||
|
||||
# TODO - Construct a queryset for "overdue" orders within the range
|
||||
|
||||
queryset = queryset.filter(received | pending)
|
||||
|
||||
return queryset
|
||||
|
||||
def __str__(self):
|
||||
"""Render a string representation of this PurchaseOrder."""
|
||||
return f'{self.reference} - {self.supplier.name if self.supplier else _("deleted")}'
|
||||
@ -584,13 +546,6 @@ class PurchaseOrder(TotalPriceMixin, Order):
|
||||
verbose_name=_('received by'),
|
||||
)
|
||||
|
||||
issue_date = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Issue Date'),
|
||||
help_text=_('Date order was issued'),
|
||||
)
|
||||
|
||||
complete_date = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
@ -1054,50 +1009,6 @@ class SalesOrder(TotalPriceMixin, Order):
|
||||
"""Return the associated barcode model type code for this model."""
|
||||
return 'SO'
|
||||
|
||||
@staticmethod
|
||||
def filterByDate(queryset, min_date, max_date):
|
||||
"""Filter by "minimum and maximum date range".
|
||||
|
||||
- Specified as min_date, max_date
|
||||
- Both must be specified for filter to be applied
|
||||
- Determine which "interesting" orders exist between these dates
|
||||
|
||||
To be "interesting":
|
||||
- A "completed" order where the completion date lies within the date range
|
||||
- A "pending" order where the target date lies within the date range
|
||||
- TODO: An "overdue" order where the target date is in the past
|
||||
"""
|
||||
date_fmt = '%Y-%m-%d' # ISO format date string
|
||||
|
||||
# Ensure that both dates are valid
|
||||
try:
|
||||
min_date = datetime.strptime(str(min_date), date_fmt).date()
|
||||
max_date = datetime.strptime(str(max_date), date_fmt).date()
|
||||
except (ValueError, TypeError):
|
||||
# Date processing error, return queryset unchanged
|
||||
return queryset
|
||||
|
||||
# Construct a queryset for "completed" orders within the range
|
||||
completed = (
|
||||
Q(status__in=SalesOrderStatusGroups.COMPLETE)
|
||||
& Q(shipment_date__gte=min_date)
|
||||
& Q(shipment_date__lte=max_date)
|
||||
)
|
||||
|
||||
# Construct a queryset for "pending" orders within the range
|
||||
pending = (
|
||||
Q(status__in=SalesOrderStatusGroups.OPEN)
|
||||
& ~Q(target_date=None)
|
||||
& Q(target_date__gte=min_date)
|
||||
& Q(target_date__lte=max_date)
|
||||
)
|
||||
|
||||
# TODO: Construct a queryset for "overdue" orders within the range
|
||||
|
||||
queryset = queryset.filter(completed | pending)
|
||||
|
||||
return queryset
|
||||
|
||||
def __str__(self):
|
||||
"""Render a string representation of this SalesOrder."""
|
||||
return f'{self.reference} - {self.customer.name if self.customer else _("deleted")}'
|
||||
@ -2363,13 +2274,6 @@ class ReturnOrder(TotalPriceMixin, Order):
|
||||
help_text=_('Customer order reference code'),
|
||||
)
|
||||
|
||||
issue_date = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_('Issue Date'),
|
||||
help_text=_('Date order was issued'),
|
||||
)
|
||||
|
||||
complete_date = models.DateField(
|
||||
blank=True,
|
||||
null=True,
|
||||
|
@ -193,6 +193,7 @@ class AbstractOrderSerializer(DataImportExportSerializerMixin, serializers.Seria
|
||||
'pk',
|
||||
'created_by',
|
||||
'creation_date',
|
||||
'issue_date',
|
||||
'start_date',
|
||||
'target_date',
|
||||
'description',
|
||||
@ -322,7 +323,6 @@ class PurchaseOrderSerializer(
|
||||
model = order.models.PurchaseOrder
|
||||
|
||||
fields = AbstractOrderSerializer.order_fields([
|
||||
'issue_date',
|
||||
'complete_date',
|
||||
'supplier',
|
||||
'supplier_detail',
|
||||
@ -1880,7 +1880,6 @@ class ReturnOrderSerializer(
|
||||
model = order.models.ReturnOrder
|
||||
|
||||
fields = AbstractOrderSerializer.order_fields([
|
||||
'issue_date',
|
||||
'complete_date',
|
||||
'customer',
|
||||
'customer_detail',
|
||||
|
Reference in New Issue
Block a user