2
0
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:
Oliver
2025-03-16 18:01:51 +11:00
committed by GitHub
parent 191c0b1007
commit 3afafe594b
54 changed files with 1287 additions and 342 deletions

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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 = {

View File

@ -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",
),
),
]

View File

@ -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,

View File

@ -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',