├── .python-version ├── inventory ├── migrations │ ├── __init__.py │ ├── 0006_rename_ptao_order_account.py │ ├── 0012_alter_order_ordered_on.py │ ├── 0014_remove_order_placed_by_order_requested_by.py │ ├── 0013_rename_ordered_by_order_placed_by_and_more.py │ ├── 0003_add_vendor_email.py │ ├── 0003_auto_20150626_1807.py │ ├── 0005_rename_ptao_account_alter_account_options_and_more.py │ ├── 0016_rename_expires_account_expires_on_and_more.py │ ├── 0002_auto_20150626_1805.py │ ├── 0010_alter_order_options_rename_created_order_created_at_and_more.py │ ├── 0009_alter_order_order_date.py │ ├── 0008_add_uniqueness_constraints.py │ ├── 0004_alter_category_id_alter_item_id_and_more.py │ ├── 0011_remove_item_date_added_item_created_at.py │ ├── 0007_remove_duplicates.py │ ├── 0002_auto_20170623_1716.py │ ├── 0015_auto_20250811_1413.py │ ├── 0001_initial.py │ └── 0001_squashed_0003_auto_20150626_1807.py ├── templates │ ├── inventory │ │ ├── order_table.html │ │ ├── orderitem_confirm_delete.html │ │ ├── index.html │ │ ├── orderitem_mark_received.html │ │ ├── item_entry.html │ │ ├── order_list.html │ │ ├── order_mark_placed.html │ │ ├── order_entry.html │ │ ├── base.html │ │ ├── item_list.html │ │ ├── order.html │ │ └── item.html │ └── base_view.html ├── tests │ ├── test_utils.py │ ├── urls.py │ ├── settings.py │ ├── test_forms.py │ ├── test_views.py │ └── test_models.py ├── __init__.py ├── fixtures │ └── inventory_starter_kit.json ├── urls.py ├── admin.py ├── forms.py ├── views.py └── models.py ├── .gitignore ├── setup.py ├── MANIFEST.in ├── .github ├── dependabot.yml └── workflows │ ├── tests.yml │ └── publish-to-pypi.yml ├── COPYING ├── pyproject.toml ├── README.rst └── uv.lock /.python-version: -------------------------------------------------------------------------------- 1 | 3.11 2 | -------------------------------------------------------------------------------- /inventory/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /inventory/templates/inventory/order_table.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /inventory/tests/test_utils.py: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | /django_lab_inventory.egg-info/ 3 | *.pyc -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # -*- mode: python -*- 3 | from setuptools import setup 4 | 5 | setup() 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include COPYING 3 | include inventory/migrations/*.py 4 | recursive-include inventory/templates * 5 | recursive-include inventory/tests *.py 6 | recursive-include inventory/fixtures * 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Set update schedule for GitHub Actions 2 | 3 | version: 2 4 | updates: 5 | 6 | - package-ecosystem: "github-actions" 7 | directory: "/" 8 | schedule: 9 | # Check for updates to GitHub Actions every week 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /inventory/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # -*- mode: python -*- 3 | try: 4 | from importlib.metadata import version 5 | 6 | __version__ = version("django-lab-inventory") 7 | except Exception: 8 | # If package is not installed (e.g. during development) 9 | __version__ = "unknown" 10 | -------------------------------------------------------------------------------- /inventory/tests/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth import views as authviews 3 | from django.urls import include, re_path 4 | 5 | urlpatterns = [ 6 | re_path(r"^inventory/", include("inventory.urls")), 7 | re_path(r"^admin/", admin.site.urls), 8 | re_path(r"^accounts/login/$", authviews.LoginView.as_view(), name="login"), 9 | re_path(r"^accounts/logout/$", authviews.LogoutView.as_view(), name="logout"), 10 | ] 11 | -------------------------------------------------------------------------------- /inventory/templates/base_view.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% block title %} {% endblock %} 7 | 8 | 9 |
10 | {% block content %}{% endblock %} 11 |
12 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /inventory/migrations/0006_rename_ptao_order_account.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.5 on 2023-07-20 17:36 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("inventory", "0005_rename_ptao_account_alter_account_options_and_more"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RenameField( 13 | model_name="order", 14 | old_name="ptao", 15 | new_name="account", 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /inventory/migrations/0012_alter_order_ordered_on.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.17 on 2025-07-29 22:11 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("inventory", "0011_remove_item_date_added_item_created_at"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="order", 14 | name="ordered_on", 15 | field=models.DateField(blank=True, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /inventory/migrations/0014_remove_order_placed_by_order_requested_by.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.4 on 2025-08-11 15:33 2 | 3 | from django.conf import settings 4 | from django.db import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("inventory", "0013_rename_ordered_by_order_placed_by_and_more"), 10 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 11 | ] 12 | 13 | operations = [ 14 | migrations.RenameField( 15 | model_name="order", old_name="placed_by", new_name="requested_by" 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /inventory/migrations/0013_rename_ordered_by_order_placed_by_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.17 on 2025-07-29 22:14 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("inventory", "0012_alter_order_ordered_on"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RenameField( 13 | model_name="order", 14 | old_name="ordered_by", 15 | new_name="placed_by", 16 | ), 17 | migrations.RenameField( 18 | model_name="order", 19 | old_name="ordered_on", 20 | new_name="placed_on", 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /inventory/migrations/0003_add_vendor_email.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.2 on 2018-03-09 17:39 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("inventory", "0002_auto_20170623_1716"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name="manufacturer", 16 | name="rep_email", 17 | field=models.CharField(blank=True, max_length=64, null=True), 18 | ), 19 | migrations.AddField( 20 | model_name="vendor", 21 | name="rep_email", 22 | field=models.CharField(blank=True, max_length=64, null=True), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /inventory/migrations/0003_auto_20150626_1807.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("inventory", "0002_auto_20150626_1805"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="manufacturer", 15 | name="rep", 16 | field=models.CharField(null=True, max_length=128, blank=True), 17 | ), 18 | migrations.AlterField( 19 | model_name="vendor", 20 | name="lookup_url", 21 | field=models.CharField( 22 | null=True, 23 | help_text="url pattern to look up catalog number", 24 | max_length=128, 25 | blank=True, 26 | ), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /inventory/migrations/0005_rename_ptao_account_alter_account_options_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.5 on 2023-07-20 17:30 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("inventory", "0004_alter_category_id_alter_item_id_and_more"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RenameModel( 13 | old_name="PTAO", 14 | new_name="Account", 15 | ), 16 | migrations.AlterModelOptions( 17 | name="account", 18 | options={ 19 | "ordering": ["code"], 20 | "verbose_name": "Account", 21 | "verbose_name_plural": "Accounts", 22 | }, 23 | ), 24 | migrations.AlterModelOptions( 25 | name="order", 26 | options={"ordering": ["-created"]}, 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /inventory/templates/inventory/orderitem_confirm_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "base_view.html" %} 2 | {% load widget_tweaks %} 3 | 4 | {% block title %} meliza-lab : remove item from order {% endblock %} 5 | 6 | {% block content %} 7 | 8 |
9 |
10 |

Remove item from order

11 |
12 |
13 |
14 | {% csrf_token %} 15 |

Are you sure you want to remove the item {{object.item.name}} from the in-progress order {{object.order.name}}?

16 | 17 |
18 |
19 | 20 |
21 |
22 | 23 |
24 |
25 |
26 | 27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /inventory/migrations/0016_rename_expires_account_expires_on_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.4 on 2025-08-11 20:40 2 | 3 | import django.db.models.deletion 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | dependencies = [ 10 | ("inventory", "0015_auto_20250811_1413"), 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ] 13 | 14 | operations = [ 15 | migrations.RenameField( 16 | model_name="account", 17 | old_name="expires", 18 | new_name="expires_on", 19 | ), 20 | migrations.AlterField( 21 | model_name="order", 22 | name="requested_by", 23 | field=models.ForeignKey( 24 | on_delete=django.db.models.deletion.PROTECT, 25 | to=settings.AUTH_USER_MODEL, 26 | verbose_name="Requested by", 27 | ), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /inventory/migrations/0002_auto_20150626_1805.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("inventory", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="manufacturer", 15 | name="lookup_url", 16 | field=models.CharField( 17 | blank=True, 18 | max_length=64, 19 | null=True, 20 | help_text="url pattern to look up part number", 21 | ), 22 | ), 23 | migrations.AddField( 24 | model_name="vendor", 25 | name="lookup_url", 26 | field=models.CharField( 27 | blank=True, 28 | max_length=64, 29 | null=True, 30 | help_text="url pattern to look up catalog number", 31 | ), 32 | ), 33 | ] 34 | -------------------------------------------------------------------------------- /inventory/migrations/0010_alter_order_options_rename_created_order_created_at_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.17 on 2025-07-29 21:18 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("inventory", "0009_alter_order_order_date"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelOptions( 13 | name="order", 14 | options={"ordering": ["-created_at"]}, 15 | ), 16 | migrations.RenameField( 17 | model_name="order", 18 | old_name="created", 19 | new_name="created_at", 20 | ), 21 | migrations.RenameField( 22 | model_name="order", 23 | old_name="order_date", 24 | new_name="ordered_on", 25 | ), 26 | migrations.RenameField( 27 | model_name="orderitem", 28 | old_name="date_arrived", 29 | new_name="arrived_on", 30 | ), 31 | migrations.RemoveField( 32 | model_name="order", 33 | name="ordered", 34 | ), 35 | ] 36 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test app 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | python-version: ["3.10", "3.11", "3.12", "3.13"] 12 | django-version: ["4.2", "5.2"] 13 | 14 | services: 15 | postgres: 16 | image: postgres:17 17 | env: 18 | POSTGRES_USER: postgres 19 | POSTGRES_PASSWORD: postgres 20 | POSTGRES_DB: test_db 21 | ports: 22 | - 5432:5432 23 | # Add a health check to ensure postgres is ready 24 | options: >- 25 | --health-cmd pg_isready 26 | --health-interval 10s 27 | --health-timeout 5s 28 | --health-retries 5 29 | 30 | steps: 31 | - uses: actions/checkout@v5 32 | - name: Install uv 33 | uses: astral-sh/setup-uv@v7 34 | with: 35 | enable-cache: true 36 | version: "latest" 37 | python-version: ${{ matrix.python-version }} 38 | - name: Install dependencies 39 | env: 40 | DJANGO_VERSION: ${{ matrix.django-version }} 41 | run: | 42 | uv sync --frozen 43 | uv add "Django~=${DJANGO_VERSION}" 44 | - name: Run tests on python ${{ matrix.python-version }} 45 | run: uv run pytest 46 | -------------------------------------------------------------------------------- /inventory/migrations/0009_alter_order_order_date.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.17 on 2025-07-29 20:51 2 | 3 | import datetime 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | def make_unordered_order_dates_null(apps, schema_editor): 9 | """Set order_date to null if ordered is False - prepare for removing ordered field""" 10 | Order = apps.get_model("inventory", "Order") 11 | unordered_orders = Order.objects.filter(ordered=False) 12 | unordered_orders.update(order_date=None) 13 | 14 | 15 | def make_unordered_order_dates_not_null(apps, schema_editor): 16 | """Set order_date to created date if ordered is False (reverses above migration)""" 17 | Order = apps.get_model("inventory", "Order") 18 | for order in Order.objects.filter(order_date__isnull=True): 19 | order.order_date = order.created.date 20 | order.save() 21 | 22 | 23 | class Migration(migrations.Migration): 24 | dependencies = [ 25 | ("inventory", "0008_add_uniqueness_constraints"), 26 | ] 27 | 28 | operations = [ 29 | migrations.AlterField( 30 | model_name="order", 31 | name="order_date", 32 | field=models.DateField(blank=True, default=datetime.date.today, null=True), 33 | ), 34 | migrations.RunPython( 35 | make_unordered_order_dates_null, make_unordered_order_dates_not_null 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /inventory/templates/inventory/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base_view.html" %} 2 | 3 | {% block title %} meliza-lab : inventory {% endblock %} 4 | 5 | {% block content %} 6 | 7 |

This web application allows you to interact with the inventory database. The database contains records of items, which are specific things or services sold by vendors. Items are associated with orders, which are requests by an individual to make a purchase using funds from an account. 8 | 9 |

14 | 15 |

To order something, you create an order and assign items to it. When an item is added to an order, you enter per-order information like price and quantity. If an item doesn't exist in the database, you'll need to create it and then add it to the order.

16 | 17 | 22 | 23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /inventory/templates/inventory/orderitem_mark_received.html: -------------------------------------------------------------------------------- 1 | {% extends "base_view.html" %} 2 | {% load widget_tweaks %} 3 | 4 | {% block title %} meliza-lab : mark order item as received {% endblock %} 5 | 6 | {% block content %} 7 | 8 |
9 |
10 |

Mark order item as received

11 |
12 |
13 |
14 | {% csrf_token %} 15 |

Use this form to mark the item {{orderitem.item}} from order {{ orderitem.order }} as received and to set other properties for the ordered item.

16 | 17 | {% for field in form %} 18 |
19 | 20 |
21 | {% render_field field class+="form-control" %} 22 |
23 |
24 | {% endfor %} 25 | 26 |
27 |
28 | 29 |
30 |
31 | 32 |
33 |
34 |
35 | 36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /inventory/fixtures/inventory_starter_kit.json: -------------------------------------------------------------------------------- 1 | [{"model": "inventory.category", "pk": 1, "fields": {"name": "reagents"}}, {"model": "inventory.unit", "pk": 1, "fields": {"name": "each"}}, {"model": "inventory.unit", "pk": 2, "fields": {"name": "g"}}, {"model": "inventory.manufacturer", "pk": 1, "fields": {"name": "Unicorn Farms LLC", "url": "https://unicornfarms.co.uk", "lookup_url": null, "rep": null, "rep_phone": null, "rep_email": null, "support_phone": null}}, {"model": "inventory.vendor", "pk": 1, "fields": {"name": "totally legal powders", "url": "http://totally.legalpowders.com", "lookup_url": null, "phone": null, "rep": null, "rep_phone": null, "rep_email": null}}, {"model": "inventory.account", "pk": 1, "fields": {"code": "12345", "description": "dummy", "expires": null}}, {"model": "inventory.item", "pk": 1, "fields": {"name": "unicorn powder", "chem_formula": null, "vendor": 1, "catalog": "12345", "manufacturer": 1, "manufacturer_number": "pretty-pony-1", "size": "200.00", "unit": 2, "category": 1, "date_added": "2023-06-06", "parent_item": null, "comments": ""}}, {"model": "inventory.order", "pk": 1, "fields": {"name": "miscellaneous", "created": "2023-06-06T14:24:57.236Z", "account": 1, "ordered": true, "order_date": "2023-06-06", "ordered_by": 1}}, {"model": "inventory.orderitem", "pk": 1, "fields": {"item": 1, "order": 1, "units_purchased": 1, "cost": "200.00", "date_arrived": null, "serial": null, "uva_equip": null, "location": null, "expiry_years": null, "reconciled": false}}] 2 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright 2023 C Daniel Meliza 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | 13 | -------------------------------------------------------------------------------- /inventory/migrations/0008_add_uniqueness_constraints.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.5 on 2023-07-21 18:17 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | dependencies = [ 7 | ("inventory", "0007_remove_duplicates"), 8 | ] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name="category", 13 | name="name", 14 | field=models.CharField(max_length=45, unique=True), 15 | ), 16 | migrations.AlterField( 17 | model_name="manufacturer", 18 | name="name", 19 | field=models.CharField(max_length=64, unique=True), 20 | ), 21 | migrations.AlterField( 22 | model_name="order", 23 | name="ordered", 24 | field=models.BooleanField(default=False), 25 | ), 26 | migrations.AlterField( 27 | model_name="orderitem", 28 | name="reconciled", 29 | field=models.BooleanField(default=False), 30 | ), 31 | migrations.AlterField( 32 | model_name="unit", 33 | name="name", 34 | field=models.CharField(max_length=45, unique=True), 35 | ), 36 | migrations.AlterField( 37 | model_name="vendor", 38 | name="name", 39 | field=models.CharField(max_length=64, unique=True), 40 | ), 41 | migrations.AddConstraint( 42 | model_name="item", 43 | constraint=models.UniqueConstraint( 44 | fields=("vendor", "catalog"), name="unique_vendor_catalog_number" 45 | ), 46 | ), 47 | ] 48 | -------------------------------------------------------------------------------- /inventory/templates/inventory/item_entry.html: -------------------------------------------------------------------------------- 1 | {% extends "base_view.html" %} 2 | {% load widget_tweaks %} 3 | 4 | {% block title %} meliza-lab : new item {% endblock %} 5 | 6 | {% block content %} 7 | 8 | 9 |
10 |
11 |

Create a new item

12 |
13 |
14 |
15 | {% csrf_token %} 16 |

Instructions: This form is used to create a new item. Items are specific things or services sold by vendors. 17 | After the item is created, you can add it to an order. Before creating the item, you may need to add a vendor or add a manufacturer.

18 | 19 | {% if form.non_field_errors %} 20 |
{{ form.non_field_errors }}
21 | {% endif %} 22 | 23 | {% for field in form %} 24 |
25 | 26 |
27 | {% render_field field class+="form-control" %} 28 |
29 |
30 | {% endfor %} 31 | 32 |
33 |
34 | 35 |
36 |
37 | 38 |
39 |
40 |
41 | 42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /inventory/urls.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # -*- mode: python -*- 3 | from django.contrib.auth.decorators import login_required 4 | from django.urls import path 5 | 6 | from inventory import views 7 | 8 | app_name = "inventory" 9 | urlpatterns = [ 10 | path("", views.index, name="index"), 11 | path("orders/", views.OrderList.as_view(), name="orders"), 12 | path("orders/unplaced/", views.UnplacedOrderList.as_view(), name="unplaced-orders"), 13 | path( 14 | "orders/incomplete/", 15 | views.IncompleteOrderList.as_view(), 16 | name="incomplete-orders", 17 | ), 18 | path("orders/new/", login_required(views.order_entry), name="new_order"), 19 | path(r"orders//", views.OrderView.as_view(), name="order"), 20 | path( 21 | "orders//place/", 22 | login_required(views.mark_order_placed), 23 | name="mark_order_placed", 24 | ), 25 | path("items/", views.ItemList.as_view(), name="items"), 26 | path("items/new/", login_required(views.item_entry), name="new_item"), 27 | path("items//", views.ItemView.as_view(), name="item"), 28 | path( 29 | "items//copy/", login_required(views.item_copy), name="copy_item" 30 | ), 31 | path( 32 | "items//order/", 33 | login_required(views.order_item_entry), 34 | name="add_item_to_order", 35 | ), 36 | path( 37 | "orderitems//delete/", 38 | login_required(views.OrderItemDelete.as_view()), 39 | name="remove_item_from_order", 40 | ), 41 | path( 42 | "orderitems//mark-received/", 43 | login_required(views.mark_orderitem_received), 44 | name="mark_received", 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /inventory/migrations/0004_alter_category_id_alter_item_id_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.5 on 2023-01-20 21:11 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("inventory", "0003_add_vendor_email"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterField( 13 | model_name="category", 14 | name="id", 15 | field=models.AutoField(primary_key=True, serialize=False), 16 | ), 17 | migrations.AlterField( 18 | model_name="item", 19 | name="id", 20 | field=models.AutoField(primary_key=True, serialize=False), 21 | ), 22 | migrations.AlterField( 23 | model_name="manufacturer", 24 | name="id", 25 | field=models.AutoField(primary_key=True, serialize=False), 26 | ), 27 | migrations.AlterField( 28 | model_name="order", 29 | name="id", 30 | field=models.AutoField(primary_key=True, serialize=False), 31 | ), 32 | migrations.AlterField( 33 | model_name="orderitem", 34 | name="id", 35 | field=models.AutoField(primary_key=True, serialize=False), 36 | ), 37 | migrations.AlterField( 38 | model_name="ptao", 39 | name="id", 40 | field=models.AutoField(primary_key=True, serialize=False), 41 | ), 42 | migrations.AlterField( 43 | model_name="unit", 44 | name="id", 45 | field=models.AutoField(primary_key=True, serialize=False), 46 | ), 47 | migrations.AlterField( 48 | model_name="vendor", 49 | name="id", 50 | field=models.AutoField(primary_key=True, serialize=False), 51 | ), 52 | ] 53 | -------------------------------------------------------------------------------- /inventory/tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | DATABASES = { 4 | "default": { 5 | "ENGINE": "django.db.backends.postgresql", 6 | "NAME": os.environ.get("POSTGRES_DB", "test_db"), 7 | "USER": os.environ.get("POSTGRES_USER", "postgres"), 8 | "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "postgres"), 9 | "HOST": os.environ.get("POSTGRES_HOST", "localhost"), 10 | "PORT": os.environ.get("POSTGRES_PORT", "5432"), 11 | } 12 | } 13 | 14 | INSTALLED_APPS = [ 15 | "django.contrib.admin", 16 | "django.contrib.auth", 17 | "django.contrib.contenttypes", 18 | "django.contrib.sessions", 19 | "django.contrib.messages", 20 | "django.contrib.staticfiles", 21 | "django_filters", 22 | "widget_tweaks", 23 | "inventory", 24 | ] 25 | 26 | SECRET_KEY = "django-insecure-test-key-not-for-production" 27 | 28 | MIDDLEWARE = [ 29 | "django.middleware.security.SecurityMiddleware", 30 | "django.contrib.sessions.middleware.SessionMiddleware", 31 | "django.middleware.common.CommonMiddleware", 32 | "django.middleware.csrf.CsrfViewMiddleware", 33 | "django.contrib.auth.middleware.AuthenticationMiddleware", 34 | "django.contrib.messages.middleware.MessageMiddleware", 35 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 36 | ] 37 | 38 | TEMPLATES = [ 39 | { 40 | "BACKEND": "django.template.backends.django.DjangoTemplates", 41 | "DIRS": [], 42 | "APP_DIRS": True, 43 | "OPTIONS": { 44 | "context_processors": [ 45 | "django.template.context_processors.debug", 46 | "django.template.context_processors.request", 47 | "django.contrib.auth.context_processors.auth", 48 | "django.contrib.messages.context_processors.messages", 49 | ], 50 | }, 51 | }, 52 | ] 53 | 54 | DEBUG = True 55 | ROOT_URLCONF = "inventory.tests.urls" 56 | -------------------------------------------------------------------------------- /inventory/templates/inventory/order_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base_view.html" %} 2 | {% load widget_tweaks %} 3 | 4 | {% block title %} meliza-lab : orders {% endblock %} 5 | 6 | {% block content %} 7 |

Orders

8 | 9 |
10 |
11 | {% for field in filter.form %} 12 |
13 | {% render_field field placeholder=field.label class+="form-control" %} 14 |
15 | {% endfor %} 16 | 19 |
20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 30 | 31 | 32 | {% for order in order_list %} 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | {% endfor %} 42 | 43 |
NamePlaced onItemsItems ReceivedRequested by 29 | Account(s)
{{ order.name }}{{ order.placed_on|default_if_none:"—" }}{{ order.item_count }}{{ order.item_received_count }}{{ order.requested_by.get_full_name }}{{ order.account_codes }}
44 | 45 | 60 | 61 |

add a new order

62 | 63 | {% endblock %} 64 | 65 | -------------------------------------------------------------------------------- /inventory/templates/inventory/order_mark_placed.html: -------------------------------------------------------------------------------- 1 | {% extends "base_view.html" %} 2 | {% load widget_tweaks %} 3 | 4 | {% block title %} meliza-lab : mark order as placed {% endblock %} 5 | 6 | {% block content %} 7 | 8 |
9 |
10 |

Mark order as placed

11 |
12 |
13 |
14 | {% csrf_token %} 15 |

Click the Confirm button to mark the in-progress order {{order.name}} as placed. You can update account and user information using the fields below.

16 | 17 | {% for field in form %} 18 |
19 | 20 |
21 | {% if field.field.widget.input_type == 'checkbox' %} 22 | {# Handle multiple checkboxes #} 23 | {% if field.field.choices %} 24 |
25 | {% for choice in field %} 26 |
27 | 30 |
31 | {% endfor %} 32 |
33 | {% else %} 34 | {# Single checkbox #} 35 |
36 | {% render_field field %} 37 |
38 | {% endif %} 39 | {% else %} 40 | {# Regular form control #} 41 | {% render_field field class+="form-control" %} 42 | {% endif %} 43 | {% if field.help_text %} 44 |

{{ field.help_text }}

45 | {% endif %} 46 | {% if field.errors %} 47 | {{ field.errors }} 48 | {% endif %} 49 |
50 |
51 | {% endfor %} 52 | 53 |
54 |
55 | 56 |
57 |
58 | 59 |
60 |
61 |
62 | 63 | {% endblock %} 64 | -------------------------------------------------------------------------------- /inventory/migrations/0011_remove_item_date_added_item_created_at.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.2.17 on 2025-07-29 21:27 2 | import datetime 3 | 4 | import django.utils.timezone 5 | from django.db import migrations, models 6 | 7 | 8 | def convert_date_to_datetime(apps, schema_editor): 9 | """ 10 | Convert existing date values to datetime with time set to 00:00:00 11 | """ 12 | Item = apps.get_model("inventory", "Item") 13 | 14 | for item in Item.objects.all(): 15 | if item.date_added: 16 | # Convert date to datetime at midnight 17 | item.created_at = django.utils.timezone.make_aware( 18 | datetime.datetime.combine(item.date_added, datetime.time.min) 19 | ) 20 | item.save() 21 | 22 | 23 | def reverse_datetime_to_date(apps, schema_editor): 24 | """ 25 | Reverse operation: convert datetime back to date 26 | """ 27 | Item = apps.get_model("inventory", "Item") 28 | 29 | for item in Item.objects.all(): 30 | if item.created_at: 31 | # Extract just the date part 32 | item.date_added = item.created_at.date() 33 | item.save() 34 | 35 | 36 | class Migration(migrations.Migration): 37 | dependencies = [ 38 | ( 39 | "inventory", 40 | "0010_alter_order_options_rename_created_order_created_at_and_more", 41 | ), 42 | ] 43 | 44 | operations = [ 45 | # Step 1: Add the new datetime field (without auto_now_add initially) 46 | migrations.AddField( 47 | model_name="item", 48 | name="created_at", 49 | field=models.DateTimeField(null=True, blank=True), 50 | ), 51 | # Step 2: Convert data from date_added to created_at 52 | migrations.RunPython( 53 | convert_date_to_datetime, 54 | reverse_datetime_to_date, 55 | ), 56 | # Step 3: Remove the original date field 57 | migrations.RemoveField( 58 | model_name="item", 59 | name="date_added", 60 | ), 61 | # Step 4: Update the field definition with auto_now_add and remove null/blank 62 | migrations.AlterField( 63 | model_name="item", 64 | name="created_at", 65 | field=models.DateTimeField(auto_now_add=True), 66 | ), 67 | ] 68 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "django-lab-inventory" 7 | version = "0.6.0" 8 | description = "A simple Django app for managing lab inventory" 9 | readme = "README.rst" 10 | requires-python = ">=3.10" 11 | license = {text = "BSD 3-Clause License"} 12 | authors = [ 13 | {name = "C Daniel Meliza", email = "dan@meliza.org"}, 14 | ] 15 | maintainers = [ 16 | {name = "C Daniel Meliza", email = "dan@meliza.org"}, 17 | ] 18 | classifiers = [ 19 | "Development Status :: 4 - Beta", 20 | "Framework :: Django", 21 | "Intended Audience :: Science/Research", 22 | "License :: OSI Approved :: BSD License", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 3.10", 25 | "Programming Language :: Python :: 3.11", 26 | "Programming Language :: Python :: 3.12", 27 | "Topic :: Scientific/Engineering", 28 | "Topic :: Internet :: WWW/HTTP", 29 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 30 | ] 31 | dependencies = [ 32 | "django>=4.2.17", 33 | "django-filter>=22.1", 34 | "django-widget-tweaks>=1.4.12", 35 | ] 36 | 37 | [project.urls] 38 | Homepage = "https://github.com/melizalab/django-lab-inventory" 39 | 40 | [tool.pytest.ini_options] 41 | DJANGO_SETTINGS_MODULE = "inventory.tests.settings" 42 | django_find_project = false 43 | python_files = ["test_*.py", "*_test.py"] 44 | addopts = "-v --cov=inventory --cov-report=term-missing" 45 | testpaths = ["inventory/tests"] 46 | env_viles = [".env", ".test.env", "deploy.env"] 47 | 48 | [tool.ruff] 49 | line-length = 88 50 | target-version = "py310" 51 | lint.select = [ 52 | "E", # pycodestyle errors 53 | "W", # pycodestyle warnings 54 | "F", # pyflakes 55 | "I", # isort 56 | "B", # flake8-bugbear 57 | ] 58 | lint.ignore = ["E221", "E501", "E701"] # Matching your pep8 ignores 59 | 60 | [tool.ruff.lint.per-file-ignores] 61 | "__init__.py" = ["F401"] 62 | 63 | [tool.mypy] 64 | python_version = "3.8" 65 | ignore_missing_imports = true 66 | strict_optional = true 67 | check_untyped_defs = true 68 | 69 | [dependency-groups] 70 | dev = [ 71 | "psycopg2-binary>=2.9.10,<3", 72 | "pytest-cov>=5.0.0", 73 | "pytest>=8.3.3,<9", 74 | "pytest-django>=4.9.0", 75 | "ruff>=0.7.0", 76 | "pytest-dotenv>=0.5.2", 77 | ] 78 | 79 | [tool.hatch.build] 80 | include = ["inventory/**"] 81 | exclude = ["*test*"] 82 | artifacts = ["README.rst"] 83 | 84 | 85 | -------------------------------------------------------------------------------- /inventory/templates/inventory/order_entry.html: -------------------------------------------------------------------------------- 1 | {% extends "base_view.html" %} 2 | {% load widget_tweaks %} 3 | 4 | {% block title %} meliza-lab : new order {% endblock %} 5 | 6 | {% block content %} 7 | 8 | 9 |
10 |
11 |

Create a new order

12 |
13 |
14 |
15 | {% csrf_token %} 16 |

Instructions: This form is used to create a new order. 17 | Leave the account field blank if you are not sure what it should be. After 18 | the order is created, you can add items to it.

19 | 20 | {% if form.non_field_errors %} 21 |
{{ form.non_field_errors }}
22 | {% endif %} 23 | 24 | {% for field in form %} 25 |
26 | 27 |
28 | {% if field.field.widget.input_type == 'checkbox' %} 29 | {# Handle multiple checkboxes #} 30 | {% if field.field.choices %} 31 |
32 | {% for choice in field %} 33 |
34 | 37 |
38 | {% endfor %} 39 |
40 | {% else %} 41 | {# Single checkbox #} 42 |
43 | {% render_field field %} 44 |
45 | {% endif %} 46 | {% else %} 47 | {# Regular form control #} 48 | {% render_field field class+="form-control" %} 49 | {% endif %} 50 | {% if field.help_text %} 51 |

{{ field.help_text }}

52 | {% endif %} 53 | {% if field.errors %} 54 | {{ field.errors }} 55 | {% endif %} 56 |
57 |
58 | {% endfor %} 59 | 60 |
61 |
62 | 63 |
64 |
65 | 66 |
67 |
68 |
69 | 70 | {% endblock %} 71 | -------------------------------------------------------------------------------- /inventory/migrations/0007_remove_duplicates.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.5 on 2023-07-21 18:17 2 | from django.db import migrations 3 | from django.db.models import Count 4 | 5 | 6 | def purge_duplicate_named(apps, schema_editor): 7 | for model_name in ("Category", "Manufacturer", "Unit", "Vendor"): 8 | field_name = model_name.lower() 9 | model = apps.get_model("inventory", model_name) 10 | duplicates = ( 11 | model.objects.values("name").annotate(count=Count("id")).filter(count__gt=1) 12 | ) 13 | for duplicate in duplicates: 14 | print( 15 | f"found {duplicate['count']} duplicates for {model_name} with name {duplicate['name']}" 16 | ) 17 | keep, *destroy = model.objects.filter(name=duplicate["name"]) 18 | for object in destroy: 19 | for item in object.item_set.all(): 20 | print( 21 | f"{item}: reassigned {field_name} from {getattr(item, field_name)} to {keep}" 22 | ) 23 | setattr(item, field_name, keep) 24 | item.save() 25 | if object.item_set.count() == 0: 26 | print(f"{object}: no more associated records; deleting") 27 | object.delete() 28 | 29 | 30 | def purge_duplicate_catalog(apps, schema_editor): 31 | Item = apps.get_model("inventory", "Item") 32 | duplicates = ( 33 | Item.objects.values("vendor", "catalog") 34 | .annotate(count=Count("id")) 35 | .filter(count__gt=1) 36 | ) 37 | for duplicate in duplicates: 38 | print( 39 | f"found {duplicate['count']} duplicates for Item with catalog {duplicate['catalog']} for vendor {duplicate['vendor']}" 40 | ) 41 | keep, *destroy = Item.objects.filter( 42 | vendor=duplicate["vendor"], catalog=duplicate["catalog"] 43 | ) 44 | for object in destroy: 45 | for orderitem in object.orderitem_set.all(): 46 | print(f"{orderitem}: reassigned item from {orderitem.item} to {keep}") 47 | orderitem.item = keep 48 | orderitem.save() 49 | if object.item_set.count() == 0: 50 | print(f"{object}: no more associated records; deleting") 51 | object.delete() 52 | 53 | 54 | class Migration(migrations.Migration): 55 | dependencies = [ 56 | ("inventory", "0006_rename_ptao_order_account"), 57 | ] 58 | 59 | operations = [ 60 | migrations.RunPython(purge_duplicate_named), 61 | migrations.RunPython(purge_duplicate_catalog), 62 | ] 63 | -------------------------------------------------------------------------------- /inventory/templates/inventory/base.html: -------------------------------------------------------------------------------- 1 | {% load staticfiles %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {% block title %}meliza-lab{% endblock %} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 25 | 26 | 27 | 28 | 29 | 30 | 52 | 53 |
54 | {% block content %}{% endblock %} 55 |
56 | 57 | 59 | 60 | 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /inventory/migrations/0002_auto_20170623_1716.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.2 on 2017-06-23 21:16 3 | from __future__ import unicode_literals 4 | 5 | import django.db.models.deletion 6 | from django.conf import settings 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | dependencies = [ 12 | ("inventory", "0001_squashed_0003_auto_20150626_1807"), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name="item", 18 | name="category", 19 | field=models.ForeignKey( 20 | on_delete=django.db.models.deletion.PROTECT, to="inventory.Category" 21 | ), 22 | ), 23 | migrations.AlterField( 24 | model_name="item", 25 | name="manufacturer", 26 | field=models.ForeignKey( 27 | blank=True, 28 | help_text="leave blank if unknown or same as vendor", 29 | null=True, 30 | on_delete=django.db.models.deletion.SET_NULL, 31 | to="inventory.Manufacturer", 32 | ), 33 | ), 34 | migrations.AlterField( 35 | model_name="item", 36 | name="parent_item", 37 | field=models.ForeignKey( 38 | blank=True, 39 | help_text="example: for printer cartriges, select printer", 40 | null=True, 41 | on_delete=django.db.models.deletion.SET_NULL, 42 | to="inventory.Item", 43 | ), 44 | ), 45 | migrations.AlterField( 46 | model_name="item", 47 | name="unit", 48 | field=models.ForeignKey( 49 | on_delete=django.db.models.deletion.PROTECT, to="inventory.Unit" 50 | ), 51 | ), 52 | migrations.AlterField( 53 | model_name="item", 54 | name="vendor", 55 | field=models.ForeignKey( 56 | on_delete=django.db.models.deletion.PROTECT, to="inventory.Vendor" 57 | ), 58 | ), 59 | migrations.AlterField( 60 | model_name="order", 61 | name="ordered_by", 62 | field=models.ForeignKey( 63 | on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL 64 | ), 65 | ), 66 | migrations.AlterField( 67 | model_name="order", 68 | name="ptao", 69 | field=models.ForeignKey( 70 | blank=True, 71 | null=True, 72 | on_delete=django.db.models.deletion.SET_NULL, 73 | to="inventory.PTAO", 74 | ), 75 | ), 76 | ] 77 | -------------------------------------------------------------------------------- /inventory/templates/inventory/item_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base_view.html" %} 2 | {% load widget_tweaks %} 3 | 4 | {% block title %} meliza-lab : items {% endblock %} 5 | 6 | {% block content %} 7 |

Items

8 | 9 |
10 |
11 | {% for field in filter.form %} 12 |
13 | {% render_field field placeholder=field.label class+="form-control" %} 14 |
15 | {% endfor %} 16 | 19 |
20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | {% for item in item_list %} 34 | 35 | 36 | 37 | 41 | 45 | 49 | 53 | 54 | 55 | {% endfor %} 56 | 57 |
DescriptionUnitVendorCatalog numberManufacturerPart NumberCategory
{{ item.name }}{{ item.unit_size }} 38 | {% if item.vendor.url %}{{ item.vendor }} 39 | {% else %}{{ item.vendor }}{% endif %} 40 | 42 | {% if item.vendor_url %}{{ item.catalog }} 43 | {% else %}{{ item.catalog }}{% endif %} 44 | 46 | {% if item.manufacturer.url %}{{ item.manufacturer }} 47 | {% else %}{{ item.manufacturer|default:"" }}{% endif %} 48 | 50 | {% if item.manufacturer_url %}{{ item.catalog }} 51 | {% else %}{{ item.manufacturer_number|default:"" }}{% endif %} 52 | {{ item.category }}
58 | 59 | 74 | 75 |

add a new item

76 | 77 | {% endblock %} 78 | 79 | -------------------------------------------------------------------------------- /inventory/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from inventory.models import ( 4 | Account, 5 | Category, 6 | Item, 7 | Manufacturer, 8 | Order, 9 | OrderAccount, 10 | OrderItem, 11 | Unit, 12 | Vendor, 13 | ) 14 | 15 | 16 | class OrderItemInline(admin.StackedInline): 17 | model = OrderItem 18 | extra = 1 19 | 20 | 21 | class OrderAccountInline(admin.StackedInline): 22 | model = OrderAccount 23 | extra = 1 24 | 25 | 26 | class ItemAdmin(admin.ModelAdmin): 27 | date_hierarchy = "created_at" 28 | 29 | fieldsets = [ 30 | (None, {"fields": ["name", "chem_formula", "category"]}), 31 | ( 32 | "Vendor Information", 33 | { 34 | "fields": [ 35 | "vendor", 36 | "catalog", 37 | "manufacturer", 38 | "manufacturer_number", 39 | "size", 40 | "unit", 41 | ] 42 | }, 43 | ), 44 | (None, {"fields": ["parent_item", "comments"]}), 45 | ] 46 | 47 | list_display = ( 48 | "name", 49 | "category", 50 | "vendor", 51 | "catalog", 52 | "created_at", 53 | ) 54 | list_filter = ("category", "vendor", "manufacturer", "created_at") 55 | search_fields = ("name", "chem_formula", "manufacturer_number", "comments") 56 | inlines = (OrderItemInline,) 57 | 58 | 59 | class OrderAdmin(admin.ModelAdmin): 60 | date_hierarchy = "placed_on" 61 | fields = ( 62 | "name", 63 | "placed_on", 64 | "requested_by", 65 | ) 66 | list_display = ("name", "placed_on", "display_accounts") 67 | list_filter = ("accounts", "requested_by") 68 | search_fields = ("name",) 69 | inlines = (OrderItemInline, OrderAccountInline) 70 | 71 | def display_accounts(self, obj): 72 | return ", ".join([acc.code for acc in obj.accounts.all()]) 73 | 74 | display_accounts.short_description = "Accounts" 75 | 76 | 77 | class OrderItemAdmin(admin.ModelAdmin): 78 | date_hierarchy = "arrived_on" 79 | fields = ( 80 | "units_purchased", 81 | "cost", 82 | "arrived_on", 83 | "serial", 84 | "uva_equip", 85 | "location", 86 | "expiry_years", 87 | "reconciled", 88 | ) 89 | list_display = ("name", "ordered_on", "arrived_on", "total_cost", "location") 90 | list_filter = ("item__name", "location") 91 | 92 | 93 | class AccountAdmin(admin.ModelAdmin): 94 | fields = ("code", "description", "expires_on") 95 | list_display = fields 96 | 97 | 98 | admin.site.register(Item, ItemAdmin) 99 | admin.site.register(Account, AccountAdmin) 100 | admin.site.register(Order, OrderAdmin) 101 | admin.site.register(OrderItem, OrderItemAdmin) 102 | 103 | for model in (Category, Unit, Manufacturer, Vendor): 104 | admin.site.register(model) 105 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | lab-inventory 2 | ------------- 3 | 4 | |ProjectStatus|_ |Version|_ |BuildStatus|_ |License|_ |PythonVersions|_ 5 | 6 | .. |ProjectStatus| image:: https://www.repostatus.org/badges/latest/active.svg 7 | .. _ProjectStatus: https://www.repostatus.org/#active 8 | 9 | .. |Version| image:: https://img.shields.io/pypi/v/django-lab-inventory.svg 10 | .. _Version: https://pypi.python.org/pypi/django-lab-inventory/ 11 | 12 | .. |BuildStatus| image:: https://github.com/melizalab/django-lab-inventory/actions/workflows/tests.yml/badge.svg 13 | .. _BuildStatus: https://github.com/melizalab/django-lab-inventory/actions/workflows/tests.yml 14 | 15 | .. |License| image:: https://img.shields.io/pypi/l/django-lab-inventory.svg 16 | .. _License: https://opensource.org/license/bsd-3-clause/ 17 | 18 | .. |PythonVersions| image:: https://img.shields.io/pypi/pyversions/django-lab-inventory.svg 19 | .. _PythonVersions: https://pypi.python.org/pypi/django-lab-inventory/ 20 | 21 | lab-inventory is a Django application used by our lab to to track 22 | inventory and orders. The basic idea is that users add items that can be 23 | purchased to the database, and then associate them with orders when they 24 | want to get the item. This allows us to quickly locate information about 25 | things we have purchased throughout the history of the lab. There is 26 | also some rudimentary support for keeping track of where items are 27 | located in the lab, when their warranties expire, and other useful 28 | information. 29 | 30 | You’ll probably need some familiarity with 31 | `Django `__ and some knowledge about how 32 | to deploy a web application to use it. 33 | 34 | lab-inventory is licensed for you to use under the BSD 3-Clause License. 35 | See COPYING for details 36 | 37 | Quick start 38 | ~~~~~~~~~~~ 39 | 40 | 1. Requires Python 3.10+. Runs on Django 4.2 LTS and 5.1. 41 | 42 | 2. Install the package from pypi: ``pip install django-lab-inventory``. 43 | Worth putting in a virtualenv. 44 | 45 | 3. Add ``inventory`` to your INSTALLED_APPS setting like this: 46 | 47 | .. code:: python 48 | 49 | INSTALLED_APPS = ( 50 | ... 51 | 'widget_tweaks', # For form tweaking 52 | 'django_filters', 53 | 'inventory', 54 | ) 55 | 56 | 2. Include inventory in ``urlpatterns`` in your project ``urls.py``. Some of 57 | the views link to the admin interface, so make sure that is included, 58 | too: 59 | 60 | .. code:: python 61 | 62 | path("inventory/", include("inventory.urls")), 63 | path("admin/", admin.site.urls), 64 | 65 | 3. Run ``python manage.py migrate`` to create the inventory models. 66 | 67 | 4. Start the development server and visit 68 | http://127.0.0.1:8000/admin/inventory/ to create items, vendors, 69 | manufacturers, etc. (you’ll need the Admin app enabled). 70 | 71 | 5. Visit http://127.0.0.1:8000/inventory/ to use views. 72 | 73 | Development 74 | ~~~~~~~~~~~ 75 | 76 | Recommend using `uv `__ for development. 77 | 78 | Run ``uv sync`` to create a virtual environment and install 79 | dependencies. ``uv sync --no-dev --frozen`` for deployment. 80 | 81 | Testing: ``uv run pytest``. Requires a test database, will use settings 82 | from ``inventory/test/settings.py``. 83 | -------------------------------------------------------------------------------- /inventory/migrations/0015_auto_20250811_1413.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 5.1.4 on 2025-08-11 18:13 2 | import django.db.models.deletion 3 | from django.db import migrations, models 4 | 5 | 6 | def migrate_accounts_forward(apps, schema_editor): 7 | """ 8 | Migrate existing single account ForeignKey relationships 9 | to ManyToMany relationships through OrderAccount 10 | """ 11 | Order = apps.get_model("inventory", "Order") 12 | OrderAccount = apps.get_model("inventory", "OrderAccount") 13 | 14 | for order in Order.objects.all(): 15 | if order.account: 16 | # Create OrderAccount instance instead of using .add() 17 | OrderAccount.objects.create(order=order, account=order.account) 18 | 19 | 20 | def migrate_accounts_reverse(apps, schema_editor): 21 | """ 22 | Reverse migration: take the first account from through model 23 | and set it as the ForeignKey 24 | """ 25 | Order = apps.get_model("inventory", "Order") 26 | OrderAccount = apps.get_model("inventory", "OrderAccount") 27 | 28 | for order in Order.objects.all(): 29 | first_order_account = OrderAccount.objects.filter(order=order).first() 30 | if first_order_account: 31 | order.account = first_order_account.account 32 | order.save() 33 | 34 | 35 | class Migration(migrations.Migration): 36 | dependencies = [ 37 | ("inventory", "0014_remove_order_placed_by_order_requested_by"), 38 | ] 39 | 40 | operations = [ 41 | # Step 1: Create the through model FIRST 42 | migrations.CreateModel( 43 | name="OrderAccount", 44 | fields=[ 45 | ("id", models.AutoField(primary_key=True, serialize=False)), 46 | ("created_at", models.DateTimeField(auto_now_add=True)), 47 | ( 48 | "account", 49 | models.ForeignKey( 50 | on_delete=django.db.models.deletion.CASCADE, 51 | to="inventory.account", 52 | ), 53 | ), 54 | ( 55 | "order", 56 | models.ForeignKey( 57 | on_delete=django.db.models.deletion.CASCADE, 58 | to="inventory.order", 59 | ), 60 | ), 61 | ], 62 | options={ 63 | "db_table": "inventory_order_accounts", 64 | "ordering": ["account__code"], 65 | "unique_together": {("order", "account")}, 66 | }, 67 | ), 68 | # Step 2: Add the new ManyToMany field with through parameter 69 | migrations.AddField( 70 | model_name="order", 71 | name="accounts", 72 | field=models.ManyToManyField( 73 | blank=True, 74 | related_name="orders", 75 | through="inventory.OrderAccount", # Specify the through model 76 | to="inventory.Account", 77 | ), 78 | ), 79 | # Step 3: Migrate data from ForeignKey to through model 80 | migrations.RunPython( 81 | migrate_accounts_forward, 82 | migrate_accounts_reverse, 83 | ), 84 | # Step 4: Remove the old ForeignKey field 85 | migrations.RemoveField( 86 | model_name="order", 87 | name="account", 88 | ), 89 | ] 90 | -------------------------------------------------------------------------------- /inventory/templates/inventory/order.html: -------------------------------------------------------------------------------- 1 | {% extends "base_view.html" %} 2 | {% load widget_tweaks %} 3 | 4 | {% block title %} meliza-lab : order : {{ order.name }} {% endblock %} 5 | 6 | {% block content %} 7 | 8 |

order: {{ order.name }}

9 |
10 | 11 |
12 |
created on
{{ order.created_at.date }}
13 |
account(s)
{{ order.account_descriptions }}
14 |
placed on
{{ order.placed_on|default_if_none:"(in progress)" }}
15 |
requested by
{{ order.requested_by.get_full_name}}
16 |
total cost
{{ order.total_cost }}
17 |
18 | 19 |

items

20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {% if order.placed_on %} 31 | 32 | {% endif %} 33 | 34 | 35 | 36 | 37 | {% for oit in orderitem_list %} 38 | 39 | 40 | 44 | 48 | 49 | 50 | 51 | 52 | {% if order.placed_on %} 53 | 54 | {% endif %} 55 | 57 | {% if order.placed_on and not oit.arrived_on %} 58 | 59 | {% elif not order.placed_on %} 60 | 62 | {% else %} 63 | 64 | {% endif %} 65 | 66 | {% endfor %} 67 | 68 |
DescriptionVendorCatalogUnitPriceQuantityCostReceived
{{ oit.item.name }} 41 | {% if oit.item.vendor.url %}{{ oit.item.vendor }} 42 | {% else %}{{ oit.item.vendor }}{% endif %} 43 | 45 | {% if oit.item.vendor_url %}{{ oit.item.catalog }} 46 | {% else %}{{ oit.item.catalog }}{% endif %} 47 | {{ oit.item.unit_size }}{{ oit.cost }}{{ oit.units_purchased }}{{ oit.total_cost }}{{ oit.arrived_on|default:"no" }}
69 | 70 | 71 |
72 | 73 |

To update information about an item after it has been received, click the icon. {% if not order.placed_on %} To add items to this order, search the items list or add a new item. To remove an item from the order, click the icon. {% else %} To mark an item as received, click the icon. {% endif %}

74 | back to order list
75 | {% if not order.placed_on %}mark this order as placed
{% endif %} 76 | edit order (advanced users only)
77 | 78 | {% endblock %} 79 | -------------------------------------------------------------------------------- /inventory/tests/test_forms.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # -*- mode: python -*- 3 | import datetime 4 | 5 | import pytest 6 | from django.contrib.auth.models import User 7 | 8 | from inventory.forms import ConfirmOrderForm 9 | from inventory.models import Account, Order 10 | 11 | 12 | @pytest.fixture 13 | def user(db): 14 | """Create a test user""" 15 | return User.objects.create_user(username="testuser", password="testpass") 16 | 17 | 18 | @pytest.fixture 19 | def account(db): 20 | """Create a test account""" 21 | return Account.objects.create( 22 | code="TEST001", 23 | description="Test Account", 24 | expires_on=datetime.date.today() + datetime.timedelta(days=365), 25 | ) 26 | 27 | 28 | @pytest.fixture 29 | def expired_account(db): 30 | """Create an expired account""" 31 | return Account.objects.create( 32 | code="EXP001", 33 | description="Expired Account", 34 | expires_on=datetime.date.today() - datetime.timedelta(days=1), 35 | ) 36 | 37 | 38 | @pytest.fixture 39 | def order(db, user): 40 | """Create a test order""" 41 | return Order.objects.create(name="Test Order", requested_by=user) 42 | 43 | 44 | @pytest.mark.django_db 45 | def test_confirm_order_form_invalid_without_accounts(order, user): 46 | """Test that the form is invalid if no accounts are selected""" 47 | form_data = { 48 | "accounts": [], 49 | "requested_by": user.id, 50 | } 51 | form = ConfirmOrderForm(data=form_data, instance=order) 52 | assert not form.is_valid() 53 | assert "accounts" in form.errors 54 | assert "At least one account must be selected." in form.errors["accounts"] 55 | 56 | 57 | @pytest.mark.django_db 58 | def test_confirm_order_form_valid_with_one_account(order, user, account): 59 | """Test that the form is valid with one account selected""" 60 | form_data = { 61 | "accounts": [account.id], 62 | "requested_by": user.id, 63 | } 64 | form = ConfirmOrderForm(data=form_data, instance=order) 65 | assert form.is_valid() 66 | 67 | 68 | @pytest.mark.django_db 69 | def test_confirm_order_form_valid_with_multiple_accounts(order, user, account, db): 70 | """Test that the form is valid with multiple accounts selected""" 71 | account2 = Account.objects.create( 72 | code="TEST002", 73 | description="Test Account 2", 74 | expires_on=datetime.date.today() + datetime.timedelta(days=365), 75 | ) 76 | form_data = { 77 | "accounts": [account.id, account2.id], 78 | "requested_by": user.id, 79 | } 80 | form = ConfirmOrderForm(data=form_data, instance=order) 81 | assert form.is_valid() 82 | 83 | 84 | @pytest.mark.django_db 85 | def test_confirm_order_form_excludes_expired_accounts( 86 | order, user, account, expired_account 87 | ): 88 | """Test that expired accounts are not in the queryset""" 89 | form = ConfirmOrderForm(instance=order) 90 | account_ids = [acc.id for acc in form.fields["accounts"].queryset] 91 | assert account.id in account_ids 92 | assert expired_account.id not in account_ids 93 | 94 | 95 | @pytest.mark.django_db 96 | def test_confirm_order_form_saves_accounts_correctly(order, user, account): 97 | """Test that saving the form creates OrderAccount relationships""" 98 | form_data = { 99 | "accounts": [account.id], 100 | "requested_by": user.id, 101 | } 102 | form = ConfirmOrderForm(data=form_data, instance=order) 103 | assert form.is_valid() 104 | saved_order = form.save() 105 | assert saved_order.accounts.count() == 1 106 | assert account in saved_order.accounts.all() 107 | 108 | 109 | @pytest.mark.django_db 110 | def test_confirm_order_form_initializes_with_existing_accounts(order, user, account): 111 | """Test that the form initializes with the order's existing accounts""" 112 | order.accounts.add(account) 113 | form = ConfirmOrderForm(instance=order) 114 | assert list(form.fields["accounts"].initial) == list(order.accounts.all()) 115 | -------------------------------------------------------------------------------- /.github/workflows/publish-to-pypi.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish distribution to pypi 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | name: Build distribution 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v5 12 | - name: Set version suffix for TestPyPI 13 | id: set_version 14 | run: | 15 | if [[ ! "${{ github.ref }}" =~ ^refs/tags/ ]]; then 16 | BUILD_NUM=${{ github.run_number }} 17 | VERSION=$(grep '^version = ' pyproject.toml | sed -E 's/version = "(.*)"/\1/') 18 | sed -i -e "s/^version = \"${VERSION}\"/version = \"${VERSION}.dev${BUILD_NUM}\"/" pyproject.toml 19 | fi 20 | - name: Install uv 21 | uses: astral-sh/setup-uv@v7 22 | with: 23 | enable-cache: true 24 | version: "latest" 25 | - name: Build wheels 26 | run: uv build 27 | env: 28 | HATCH_BUILD_HOOK_FORCE_VERSION: ${{ env.HATCH_VERSION }} 29 | - name: Upload artifacts 30 | uses: actions/upload-artifact@v5 31 | with: 32 | name: python-package-distributions 33 | path: dist/ 34 | 35 | publish-to-pypi: 36 | name: >- 37 | Publish Python distribution to PyPI 38 | if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes 39 | needs: 40 | - build 41 | runs-on: ubuntu-latest 42 | environment: 43 | name: pypi 44 | url: https://pypi.org/p/django-lab-inventory 45 | permissions: 46 | id-token: write # IMPORTANT: mandatory for trusted publishing 47 | 48 | steps: 49 | - name: Download all the dists 50 | uses: actions/download-artifact@v6 51 | with: 52 | name: python-package-distributions 53 | path: dist/ 54 | - name: Publish distribution to PyPI 55 | uses: pypa/gh-action-pypi-publish@release/v1 56 | 57 | github-release: 58 | name: >- 59 | Sign the Python distribution with Sigstore 60 | and to GitHub Release 61 | needs: 62 | - publish-to-pypi 63 | runs-on: ubuntu-latest 64 | 65 | permissions: 66 | contents: write # IMPORTANT: mandatory for making GitHub Releases 67 | id-token: write # IMPORTANT: mandatory for sigstore 68 | 69 | steps: 70 | - name: Download all the dists 71 | uses: actions/download-artifact@v6 72 | with: 73 | name: python-package-distributions 74 | path: dist/ 75 | - name: Sign the dists with Sigstore 76 | uses: sigstore/gh-action-sigstore-python@v3.1.0 77 | with: 78 | inputs: >- 79 | ./dist/*.tar.gz 80 | ./dist/*.whl 81 | - name: Create GitHub Release 82 | env: 83 | GITHUB_TOKEN: ${{ github.token }} 84 | run: >- 85 | gh release create 86 | '${{ github.ref_name }}' 87 | --repo '${{ github.repository }}' 88 | --notes "" 89 | - name: Upload artifact signatures to GitHub Release 90 | env: 91 | GITHUB_TOKEN: ${{ github.token }} 92 | # Upload to GitHub Release using the `gh` CLI. 93 | # `dist/` contains the built packages, and the 94 | # sigstore-produced signatures and certificates. 95 | run: >- 96 | gh release upload 97 | '${{ github.ref_name }}' dist/** 98 | --repo '${{ github.repository }}' 99 | 100 | publish-to-testpypi: 101 | name: Publish Python distribution to TestPyPI 102 | needs: 103 | - build 104 | runs-on: ubuntu-latest 105 | 106 | environment: 107 | name: testpypi 108 | url: https://test.pypi.org/p/django-lab-inventory 109 | 110 | permissions: 111 | id-token: write # IMPORTANT: mandatory for trusted publishing 112 | 113 | steps: 114 | - name: Download all the dists 115 | uses: actions/download-artifact@v6 116 | with: 117 | name: python-package-distributions 118 | path: dist/ 119 | - name: Publish distribution 📦 to TestPyPI 120 | uses: pypa/gh-action-pypi-publish@release/v1 121 | with: 122 | repository-url: https://test.pypi.org/legacy/ 123 | verbose: true 124 | -------------------------------------------------------------------------------- /inventory/forms.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # -*- mode: python -*- 3 | import datetime 4 | 5 | from django import forms 6 | from django.contrib.auth.models import User 7 | 8 | from inventory.models import Account, Item, Order, OrderAccount, OrderItem, Vendor 9 | 10 | 11 | class NewOrderForm(forms.ModelForm): 12 | name = forms.CharField(label="Order Name") 13 | requested_by = forms.ModelChoiceField( 14 | queryset=User.objects.filter(is_active=True), label="Requested by" 15 | ) 16 | accounts = forms.ModelMultipleChoiceField( 17 | queryset=Account.objects.exclude(expires_on__lt=datetime.date.today()), 18 | required=False, 19 | widget=forms.CheckboxSelectMultiple, 20 | label="Accounts (select all that apply)", 21 | ) 22 | 23 | def save(self, commit=True): 24 | order = super().save(commit=commit) 25 | if commit: 26 | # Clear existing accounts and add selected ones 27 | order.accounts.clear() 28 | for account in self.cleaned_data["accounts"]: 29 | OrderAccount.objects.create(order=order, account=account) 30 | return order 31 | 32 | class Meta: 33 | model = Order 34 | fields = ["name", "requested_by", "accounts"] 35 | 36 | 37 | class ConfirmOrderForm(forms.ModelForm): 38 | accounts = forms.ModelMultipleChoiceField( 39 | queryset=Account.objects.exclude(expires_on__lt=datetime.date.today()), 40 | required=False, 41 | widget=forms.CheckboxSelectMultiple, 42 | label="Accounts (select at least one)", 43 | ) 44 | requested_by = forms.ModelChoiceField( 45 | queryset=User.objects.filter(is_active=True), 46 | required=True, 47 | label="Requested by", 48 | ) 49 | 50 | def __init__(self, *args, **kwargs): 51 | super().__init__(*args, **kwargs) 52 | if self.instance.pk: 53 | self.fields["accounts"].initial = self.instance.accounts.all() 54 | 55 | def clean_accounts(self): 56 | """Ensure at least one account is selected when confirming order""" 57 | accounts = self.cleaned_data.get("accounts") 58 | if not accounts: 59 | raise forms.ValidationError("At least one account must be selected.") 60 | return accounts 61 | 62 | def save(self, commit=True): 63 | order = super().save(commit=commit) 64 | if commit: 65 | order.accounts.clear() 66 | for account in self.cleaned_data["accounts"]: 67 | OrderAccount.objects.create(order=order, account=account) 68 | return order 69 | 70 | class Meta: 71 | model = Order 72 | fields = ["accounts", "requested_by"] 73 | 74 | 75 | class OrderItemReceivedForm(forms.ModelForm): 76 | arrived_on = forms.DateField(widget=forms.widgets.DateInput(attrs={"type": "date"})) 77 | 78 | class Meta: 79 | model = OrderItem 80 | fields = ["arrived_on", "location", "serial", "uva_equip"] 81 | 82 | 83 | class NewItemForm(forms.ModelForm): 84 | name = forms.CharField(label="Item Name") 85 | vendor = forms.ModelChoiceField(queryset=Vendor.objects.all(), required=True) 86 | catalog = forms.CharField(label="Vendor catalog number") 87 | manufacturer_number = forms.CharField( 88 | label="Manufacturer part number", required=False 89 | ) 90 | 91 | class Meta: 92 | model = Item 93 | fields = [ 94 | "name", 95 | "category", 96 | "vendor", 97 | "catalog", 98 | "manufacturer", 99 | "manufacturer_number", 100 | "size", 101 | "unit", 102 | "parent_item", 103 | "comments", 104 | ] 105 | 106 | 107 | class NewOrderItemForm(forms.ModelForm): 108 | order = forms.ModelChoiceField( 109 | queryset=Order.objects.not_placed(), 110 | required=True, 111 | label="Choose an in-progress order", 112 | ) 113 | units_purchased = forms.IntegerField(label="Number of units to order") 114 | cost = forms.DecimalField(label="Current price per unit") 115 | 116 | class Meta: 117 | model = OrderItem 118 | fields = ["order", "units_purchased", "cost"] 119 | -------------------------------------------------------------------------------- /inventory/templates/inventory/item.html: -------------------------------------------------------------------------------- 1 | {% extends "base_view.html" %} 2 | {% load widget_tweaks %} 3 | 4 | {% block title %} meliza-lab : item : {{ item.name }} {% endblock %} 5 | 6 | {% block content %} 7 | 8 |

item: {{ item.name }}

9 | 10 |
11 | 12 |
13 |
vendor
14 |
15 | {% if item.vendor.url %}{{ item.vendor }} 16 | {% else %}{{ item.vendor }}{% endif %} 17 |
18 |
catalog
19 |
20 | {% if item.vendor_url %}{{ item.catalog }} 21 | {% else %}{{ item.catalog }}{% endif %} 22 |
23 |
manufacturer
24 |
25 | {% if item.manufacturer.url %}{{ item.manufacturer }} 26 | {% else %}{{ item.manufacturer|default:"" }}{% endif %} 27 |
28 |
part number
29 |
30 | {% if item.mfg_url %}{{ item.manufacturer_number }} 31 | {% else %}{{ item.manufacturer_number|default:"" }}{% endif %} 32 |
33 |
unit size
{{ item.unit_size }}
34 |
35 | 36 |
37 |
category
{{ item.category }}
38 |
part of
{{ item.parent_item|default:"" }}
39 |
40 | 41 |
42 |
comments
{{ item.comments }}
43 |
44 | 45 |

orders

46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | {% for oit in lineitems.iterator %} 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 72 | 73 | {% endfor %} 74 | 75 |
Order DatePriceQuantityTotal PriceReceivedLocationSerialEquipment #
{{ oit.order.placed_on|default_if_none:"(in progress)" }}{{ oit.cost }}{{ oit.units_purchased }}{{ oit.total_cost }}{{ oit.arrived_on|default:"no" }}{{ oit.location|default:"" }}{{ oit.serial|default:"" }}{{ oit.uva_equip|default:"" }}
76 | 77 |

To enter or update information about an item after it has been received, click the icon. To place a new order for the item, use the form below.

78 | 79 |
80 | 81 | 82 |
83 |
84 |

Order this item

85 |
86 |
87 |
88 | {% csrf_token %} 89 | {% if form.non_field_errors %} 90 |
{{ form.non_field_errors }}
91 | {% endif %} 92 | 93 | {% for field in form %} 94 |
95 | 96 |
97 | {% render_field field class+="form-control" %} 98 |
99 |
100 | {% endfor %} 101 | 102 |
103 |
104 | 105 |
106 |
107 | 108 |
109 |
110 |
111 | 112 |
113 | back to item list
114 | add a new item
115 | create a copy of this item
116 | edit item (advanced users only)
117 | 118 | 119 | {% endblock %} 120 | -------------------------------------------------------------------------------- /inventory/tests/test_views.py: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | import datetime 3 | 4 | import pytest 5 | from django.contrib.auth.models import User 6 | from django.urls import reverse 7 | 8 | from inventory.models import ( 9 | Account, 10 | Category, 11 | Item, 12 | Manufacturer, 13 | Order, 14 | Unit, 15 | Vendor, 16 | ) 17 | 18 | 19 | @pytest.fixture 20 | def user(db): 21 | """Create a test user""" 22 | return User.objects.create_user(username="testuser", password="testpass") 23 | 24 | 25 | @pytest.fixture 26 | def category(db): 27 | """Create a test category""" 28 | return Category.objects.create(name="Test Category") 29 | 30 | 31 | @pytest.fixture 32 | def unit(db): 33 | """Create a test unit""" 34 | return Unit.objects.create(name="each") 35 | 36 | 37 | @pytest.fixture 38 | def vendor(db): 39 | """Create a test vendor""" 40 | return Vendor.objects.create(name="Test Vendor") 41 | 42 | 43 | @pytest.fixture 44 | def manufacturer(db): 45 | """Create a test manufacturer""" 46 | return Manufacturer.objects.create(name="Test Manufacturer") 47 | 48 | 49 | @pytest.fixture 50 | def item(db, vendor, manufacturer, category, unit): 51 | """Create a test item""" 52 | return Item.objects.create( 53 | name="Test Item", 54 | vendor=vendor, 55 | catalog="CAT123", 56 | manufacturer=manufacturer, 57 | manufacturer_number="MFG456", 58 | category=category, 59 | unit=unit, 60 | size=1.0, 61 | ) 62 | 63 | 64 | @pytest.fixture 65 | def account(db): 66 | """Create a test account""" 67 | return Account.objects.create( 68 | code="TEST001", 69 | description="Test Account", 70 | expires_on=datetime.date.today() + datetime.timedelta(days=365), 71 | ) 72 | 73 | 74 | @pytest.mark.django_db 75 | def test_index(client): 76 | response = client.get(reverse("inventory:index")) 77 | assert response.status_code == 200 78 | 79 | 80 | @pytest.mark.django_db 81 | def test_order_list_at_desired_location(client): 82 | response = client.get("/inventory/orders/") 83 | assert response.status_code == 200 84 | 85 | 86 | @pytest.mark.django_db 87 | def test_unplaced_order_list_at_desired_location(client): 88 | response = client.get("/inventory/orders/unplaced/") 89 | assert response.status_code == 200 90 | 91 | 92 | @pytest.mark.django_db 93 | def test_incomplete_order_list_at_desired_location(client): 94 | response = client.get("/inventory/orders/incomplete/") 95 | assert response.status_code == 200 96 | 97 | 98 | @pytest.mark.django_db 99 | def test_item_list_at_desired_location(client): 100 | response = client.get("/inventory/items/") 101 | assert response.status_code == 200 102 | 103 | 104 | @pytest.mark.django_db 105 | def test_order_404_invalid_order(client): 106 | response = client.get(reverse("inventory:order", args=[1])) 107 | assert response.status_code == 404 108 | 109 | 110 | @pytest.mark.django_db 111 | def test_order_404_invalid_item(client): 112 | response = client.get(reverse("inventory:item", args=[1])) 113 | assert response.status_code == 404 114 | 115 | 116 | @pytest.mark.django_db 117 | def test_unplaced_orders_view_shows_only_unplaced_orders(client, user, account): 118 | """Test that the unplaced orders view only shows orders that have not been marked as placed""" 119 | # Create an unplaced order 120 | unplaced_order = Order.objects.create( 121 | name="Unplaced Order", requested_by=user, placed_on=None 122 | ) 123 | unplaced_order.accounts.add(account) 124 | 125 | # Create a placed order 126 | placed_order = Order.objects.create( 127 | name="Placed Order", requested_by=user, placed_on=datetime.date.today() 128 | ) 129 | placed_order.accounts.add(account) 130 | 131 | # Get the unplaced orders view 132 | response = client.get(reverse("inventory:unplaced-orders")) 133 | assert response.status_code == 200 134 | 135 | # Check that only the unplaced order appears in the view 136 | order_list = list(response.context["order_list"]) 137 | assert unplaced_order in order_list 138 | assert placed_order not in order_list 139 | 140 | 141 | @pytest.mark.django_db 142 | def test_incomplete_orders_view_shows_only_incomplete_orders( 143 | client, user, item, account 144 | ): 145 | """Test that the incomplete orders view only shows orders that have been placed but not all items received""" 146 | # Create a complete order (all items received) 147 | complete_order = Order.objects.create(name="Complete Order", requested_by=user) 148 | complete_order.accounts.add(account) 149 | complete_order.add_item(item=item, n_units=2, cost_per_unit=10.00) 150 | complete_order.mark_placed() 151 | for order_item in complete_order.orderitem_set.all(): 152 | order_item.mark_arrived() 153 | 154 | # Create an incomplete order (not all items received) 155 | incomplete_order = Order.objects.create(name="Incomplete Order", requested_by=user) 156 | incomplete_order.accounts.add(account) 157 | incomplete_order.add_item(item=item, n_units=1, cost_per_unit=10.00) 158 | incomplete_order.add_item(item=item, n_units=1, cost_per_unit=10.00) 159 | incomplete_order.mark_placed() 160 | # Mark only one item as arrived 161 | incomplete_order.orderitem_set.first().mark_arrived() 162 | 163 | # Create an unplaced order (should not appear) 164 | unplaced_order = Order.objects.create(name="Unplaced Order", requested_by=user) 165 | unplaced_order.accounts.add(account) 166 | unplaced_order.add_item(item=item, n_units=1, cost_per_unit=10.00) 167 | 168 | # Get the incomplete orders view 169 | response = client.get(reverse("inventory:incomplete-orders")) 170 | assert response.status_code == 200 171 | 172 | # Check that only the incomplete order appears in the view 173 | order_list = list(response.context["order_list"]) 174 | assert incomplete_order in order_list 175 | assert complete_order not in order_list 176 | assert unplaced_order not in order_list 177 | -------------------------------------------------------------------------------- /inventory/tests/test_models.py: -------------------------------------------------------------------------------- 1 | # -*- mode: python -*- 2 | import pytest 3 | from django.contrib.auth import get_user_model 4 | 5 | from inventory.models import ( 6 | Account, 7 | Category, 8 | Item, 9 | Order, 10 | OrderItem, 11 | Unit, 12 | Vendor, 13 | ) 14 | 15 | 16 | @pytest.fixture 17 | def sentinel_user(): 18 | return get_user_model().objects.get_or_create(username="deleted")[0] 19 | 20 | 21 | @pytest.mark.django_db 22 | def test_orderitem_cost(sentinel_user): 23 | category = Category.objects.create(name="glues and pastes") 24 | unit = Unit.objects.create(name="each") 25 | vendor = Vendor.objects.create(name="Unicorn Dispensary") 26 | account = Account.objects.create(code="1234", description="unicorn paste fund") 27 | item = Item.objects.create( 28 | name="unicorn paste", 29 | category=category, 30 | unit=unit, 31 | vendor=vendor, 32 | catalog="UPASTE1", 33 | ) 34 | order = Order.objects.create( 35 | name="yearly unicorn paste supply", requested_by=sentinel_user 36 | ) 37 | order.add_account(account) 38 | 39 | orderitem = OrderItem.objects.create( 40 | item=item, order=order, units_purchased=10, cost=20 41 | ) 42 | 43 | assert orderitem.total_cost() == 200 44 | assert order.total_cost() == 200 45 | 46 | 47 | @pytest.mark.django_db 48 | def test_order_cost(sentinel_user): 49 | category = Category.objects.create(name="glues and pastes") 50 | unit = Unit.objects.create(name="each") 51 | vendor = Vendor.objects.create(name="Unicorn Dispensary") 52 | account = Account.objects.create(code="1234", description="unicorn paste fund") 53 | order = Order.objects.create( 54 | name="yearly unicorn paste supply", requested_by=sentinel_user 55 | ) 56 | order.add_account(account) 57 | 58 | item_1 = Item.objects.create( 59 | name="unicorn paste", 60 | category=category, 61 | unit=unit, 62 | vendor=vendor, 63 | catalog="UPASTE1", 64 | ) 65 | item_2 = Item.objects.create( 66 | name="unicorn paste adapter", 67 | category=category, 68 | unit=unit, 69 | vendor=vendor, 70 | catalog="UPADAPT", 71 | ) 72 | order.add_item(item=item_1, n_units=10, cost_per_unit=20) 73 | order.add_item(item=item_2, n_units=1, cost_per_unit=200) 74 | 75 | assert order.total_cost() == 400 76 | 77 | 78 | @pytest.mark.django_db 79 | def test_order_counts(sentinel_user): 80 | category = Category.objects.create(name="glues and pastes") 81 | unit = Unit.objects.create(name="each") 82 | vendor = Vendor.objects.create(name="Unicorn Dispensary") 83 | account = Account.objects.create(code="1234", description="unicorn paste fund") 84 | order = Order.objects.create( 85 | name="yearly unicorn paste supply", requested_by=sentinel_user 86 | ) 87 | order.add_account(account) 88 | 89 | item_1 = Item.objects.create( 90 | name="unicorn paste", 91 | category=category, 92 | unit=unit, 93 | vendor=vendor, 94 | catalog="UPASTE1", 95 | ) 96 | item_2 = Item.objects.create( 97 | name="unicorn paste adapter", 98 | category=category, 99 | unit=unit, 100 | vendor=vendor, 101 | catalog="UPADAPT", 102 | ) 103 | order.add_item(item=item_1, n_units=10, cost_per_unit=20) 104 | order.add_item(item=item_2, n_units=1, cost_per_unit=200) 105 | 106 | a_order = Order.objects.with_counts().get(id=order.id) 107 | assert a_order.item_count == 2 108 | assert a_order.item_received_count == 0 109 | 110 | 111 | @pytest.mark.django_db 112 | def test_order_unplaced(sentinel_user): 113 | account = Account.objects.create(code="1234", description="unicorn paste fund") 114 | order = Order.objects.create( 115 | name="yearly unicorn paste supply", requested_by=sentinel_user 116 | ) 117 | order.add_account(account) 118 | 119 | assert order in Order.objects.not_placed() 120 | assert order not in Order.objects.placed() 121 | assert order not in Order.objects.not_completed() 122 | assert order not in Order.objects.completed() 123 | 124 | 125 | @pytest.mark.django_db 126 | def test_order_placed(sentinel_user): 127 | account = Account.objects.create(code="1234", description="unicorn paste fund") 128 | order = Order.objects.create( 129 | name="yearly unicorn paste supply", requested_by=sentinel_user 130 | ) 131 | order.add_account(account) 132 | order.mark_placed() 133 | 134 | assert order not in Order.objects.not_placed() 135 | assert order in Order.objects.placed() 136 | assert order not in Order.objects.not_completed() 137 | assert order in Order.objects.completed() 138 | 139 | 140 | @pytest.mark.django_db 141 | def test_order_completed(sentinel_user): 142 | category = Category.objects.create(name="glues and pastes") 143 | unit = Unit.objects.create(name="each") 144 | vendor = Vendor.objects.create(name="Unicorn Dispensary") 145 | account = Account.objects.create(code="1234", description="unicorn paste fund") 146 | order = Order.objects.create( 147 | name="yearly unicorn paste supply", requested_by=sentinel_user 148 | ) 149 | order.add_account(account) 150 | 151 | item_1 = Item.objects.create( 152 | name="unicorn paste", 153 | category=category, 154 | unit=unit, 155 | vendor=vendor, 156 | catalog="UPASTE1", 157 | ) 158 | item_2 = Item.objects.create( 159 | name="unicorn paste adapter", 160 | category=category, 161 | unit=unit, 162 | vendor=vendor, 163 | catalog="UPADAPT", 164 | ) 165 | oitem_1 = order.add_item(item=item_1, n_units=10, cost_per_unit=20) 166 | oitem_2 = order.add_item(item=item_2, n_units=1, cost_per_unit=200) 167 | order.mark_placed() 168 | oitem_1.mark_arrived() 169 | oitem_2.mark_arrived() 170 | 171 | assert order not in Order.objects.not_placed() 172 | assert order in Order.objects.placed() 173 | assert order not in Order.objects.not_completed() 174 | assert order in Order.objects.completed() 175 | 176 | 177 | @pytest.mark.django_db 178 | def test_order_not_completed(sentinel_user): 179 | category = Category.objects.create(name="glues and pastes") 180 | unit = Unit.objects.create(name="each") 181 | vendor = Vendor.objects.create(name="Unicorn Dispensary") 182 | account = Account.objects.create(code="1234", description="unicorn paste fund") 183 | order = Order.objects.create( 184 | name="yearly unicorn paste supply", requested_by=sentinel_user 185 | ) 186 | order.add_account(account) 187 | 188 | item_1 = Item.objects.create( 189 | name="unicorn paste", 190 | category=category, 191 | unit=unit, 192 | vendor=vendor, 193 | catalog="UPASTE1", 194 | ) 195 | item_2 = Item.objects.create( 196 | name="unicorn paste adapter", 197 | category=category, 198 | unit=unit, 199 | vendor=vendor, 200 | catalog="UPADAPT", 201 | ) 202 | oitem_1 = order.add_item(item=item_1, n_units=10, cost_per_unit=20) 203 | _oitem_2 = order.add_item(item=item_2, n_units=1, cost_per_unit=200) 204 | order.mark_placed() 205 | oitem_1.mark_arrived() 206 | 207 | assert order not in Order.objects.not_placed() 208 | assert order in Order.objects.placed() 209 | assert order in Order.objects.not_completed() 210 | assert order not in Order.objects.completed() 211 | 212 | 213 | @pytest.mark.django_db 214 | def test_order_with_multiple_accounts(sentinel_user): 215 | account1 = Account.objects.create(code="1234", description="fund 1") 216 | account2 = Account.objects.create(code="5678", description="fund 2") 217 | account3 = Account.objects.create(code="9101", description="fund 3") 218 | order = Order.objects.create(name="multi-account order", requested_by=sentinel_user) 219 | order.add_account(account1) 220 | order.add_account(account2) 221 | 222 | assert order.accounts.count() == 2 223 | assert account1 in order.accounts.all() 224 | assert account2 in order.accounts.all() 225 | assert account3 not in order.accounts.all() 226 | assert order in account1.orders.all() 227 | assert order in account2.orders.all() 228 | assert order not in account3.orders.all() 229 | assert order.account_codes() == "1234, 5678" 230 | -------------------------------------------------------------------------------- /inventory/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # -*- mode: python -*- 3 | import django_filters as filters 4 | from django.db.models import Q 5 | from django.shortcuts import get_object_or_404, redirect 6 | from django.template.response import TemplateResponse 7 | from django.views import generic 8 | from django_filters.views import FilterView 9 | 10 | from inventory.forms import ( 11 | ConfirmOrderForm, 12 | NewItemForm, 13 | NewOrderForm, 14 | NewOrderItemForm, 15 | OrderItemReceivedForm, 16 | ) 17 | from inventory.models import Item, Order, OrderItem 18 | 19 | 20 | def index(request): 21 | return TemplateResponse(request, "inventory/index.html") 22 | 23 | 24 | class PaginatedFilterView(FilterView): 25 | paginate_by = 25 26 | 27 | def get_context_data(self, **kwargs): 28 | # This strips the page from the query parameters; otherwise pagination leads to ever-growing URL 29 | context = super().get_context_data(**kwargs) 30 | context["query"] = self.request.GET.copy() 31 | try: 32 | del context["query"]["page"] 33 | except KeyError: 34 | pass 35 | return context 36 | 37 | 38 | class OrderFilter(filters.FilterSet): 39 | name = filters.CharFilter(field_name="name", lookup_expr="icontains", label="Name") 40 | requested_by = filters.CharFilter( 41 | field_name="requested_by__username", 42 | lookup_expr="istartswith", 43 | label="Placed by", 44 | ) 45 | account = filters.CharFilter( 46 | field_name="accounts__description", lookup_expr="icontains", label="Account" 47 | ) 48 | 49 | class Meta: 50 | model = Order 51 | fields = ["name", "requested_by", "account"] 52 | 53 | 54 | class ItemFilter(filters.FilterSet): 55 | name = filters.CharFilter( 56 | field_name="name", lookup_expr="icontains", label="Description" 57 | ) 58 | vendor_or_mfg = filters.CharFilter( 59 | method="by_vendor_or_mfg", label="Vendor/Manufacturer" 60 | ) 61 | catalog_or_part = filters.CharFilter( 62 | method="by_catalog_or_part", label="Catalog/Part Number" 63 | ) 64 | category = filters.CharFilter( 65 | field_name="category__name", lookup_expr="icontains", label="Category" 66 | ) 67 | 68 | def by_vendor_or_mfg(self, queryset, name, value): 69 | return queryset.filter( 70 | Q(vendor__name__icontains=value) | Q(manufacturer__name__icontains=value) 71 | ) 72 | 73 | def by_catalog_or_part(self, queryset, name, value): 74 | return queryset.filter( 75 | Q(catalog__icontains=value) | Q(manufacturer_number__icontains=value) 76 | ) 77 | 78 | class Meta: 79 | model = Item 80 | fields = [ 81 | "name", 82 | "vendor_or_mfg", 83 | "catalog_or_part", 84 | "category", 85 | ] 86 | 87 | 88 | class OrderList(PaginatedFilterView): 89 | model = Order 90 | filterset_class = OrderFilter 91 | template_name = "inventory/order_list.html" 92 | context_object_name = "order_list" 93 | 94 | def get_queryset(self): 95 | qs = Order.objects.with_counts().filter(**self.kwargs) 96 | return qs.order_by("-created_at") 97 | 98 | 99 | class UnplacedOrderList(OrderList): 100 | def get_queryset(self): 101 | qs = Order.objects.with_counts().not_placed().filter(**self.kwargs) 102 | return qs.order_by("-created_at") 103 | 104 | 105 | class IncompleteOrderList(OrderList): 106 | def get_queryset(self): 107 | qs = Order.objects.with_counts().not_completed().filter(**self.kwargs) 108 | return qs.order_by("-created_at") 109 | 110 | 111 | class OrderView(generic.DetailView): 112 | model = Order 113 | template_name = "inventory/order.html" 114 | 115 | def get_context_data(self, **kwargs): 116 | context = super(OrderView, self).get_context_data(**kwargs) 117 | context["orderitem_list"] = context["order"].orderitem_set.order_by( 118 | "item__vendor" 119 | ) 120 | return context 121 | 122 | 123 | def order_entry(request): 124 | if request.method == "POST": 125 | form = NewOrderForm(request.POST) 126 | if form.is_valid(): 127 | order = form.save() 128 | return redirect("inventory:order", pk=order.id) 129 | else: 130 | form = NewOrderForm(initial={"requested_by": request.user}) 131 | return TemplateResponse(request, "inventory/order_entry.html", {"form": form}) 132 | 133 | 134 | def mark_order_placed(request, order_id): 135 | order = get_object_or_404(Order, id=order_id) 136 | if request.method == "POST": 137 | form = ConfirmOrderForm(request.POST, instance=order) 138 | if form.is_valid(): 139 | # order = form.save(commit=False) 140 | order.mark_placed() 141 | return redirect("inventory:order", pk=order_id) 142 | else: 143 | form = ConfirmOrderForm(instance=order) 144 | return TemplateResponse( 145 | request, "inventory/order_mark_placed.html", {"order": order, "form": form} 146 | ) 147 | 148 | 149 | def mark_orderitem_received(request, orderitem_id): 150 | orderitem = get_object_or_404(OrderItem, id=orderitem_id) 151 | if request.method == "POST": 152 | form = OrderItemReceivedForm(request.POST, instance=orderitem) 153 | if form.is_valid(): 154 | orderitem.mark_arrived(form.cleaned_data["arrived_on"]) 155 | return redirect("inventory:order", pk=orderitem.order.id) 156 | else: 157 | form = OrderItemReceivedForm(instance=orderitem) 158 | return TemplateResponse( 159 | request, 160 | "inventory/orderitem_mark_received.html", 161 | {"orderitem": orderitem, "form": form}, 162 | ) 163 | 164 | 165 | class ItemList(PaginatedFilterView): 166 | model = Item 167 | filterset_class = ItemFilter 168 | template_name = "inventory/item_list.html" 169 | context_object_name = "item_list" 170 | 171 | def get_queryset(self): 172 | qs = Item.objects.filter(**self.kwargs) 173 | return qs.order_by("-created_at") 174 | 175 | 176 | class ItemView(generic.DetailView, generic.FormView): 177 | model = Item 178 | template_name = "inventory/item.html" 179 | form_class = NewOrderItemForm 180 | 181 | def get_context_data(self, **kwargs): 182 | context = super().get_context_data(**kwargs) 183 | context["lineitems"] = context["item"].orderitem_set.order_by( 184 | "-order__placed_on" 185 | ) 186 | return context 187 | 188 | 189 | def item_entry(request): 190 | if request.method == "POST": 191 | form = NewItemForm(request.POST) 192 | if form.is_valid(): 193 | item = form.save() 194 | return redirect("inventory:item", pk=item.id) 195 | else: 196 | form = NewItemForm() 197 | return TemplateResponse(request, "inventory/item_entry.html", {"form": form}) 198 | 199 | 200 | def item_copy(request, item_id): 201 | """Make a copy of an item - populates the form with existing values""" 202 | if request.method == "POST": 203 | form = NewItemForm(request.POST) 204 | if form.is_valid(): 205 | item = form.save() 206 | return redirect("inventory:item", pk=item.id) 207 | else: 208 | original_item = get_object_or_404(Item, id=item_id) 209 | initial_data = {} 210 | for field_name in NewItemForm.Meta.fields: 211 | if hasattr(original_item, field_name): 212 | value = getattr(original_item, field_name) 213 | initial_data[field_name] = value 214 | 215 | # Modify the name to indicate it's a copy 216 | initial_data["name"] = f"Copy of {original_item.name}" 217 | form = NewItemForm(initial=initial_data) 218 | return TemplateResponse(request, "inventory/item_entry.html", {"form": form}) 219 | 220 | 221 | def order_item_entry(request, item_id): 222 | if request.method == "POST": 223 | item = get_object_or_404(Item, id=item_id) 224 | form = NewOrderItemForm(request.POST) 225 | if form.is_valid(): 226 | oitem = form.save(commit=False) 227 | oitem.item = item 228 | oitem.save() 229 | return redirect("inventory:order", pk=oitem.order.id) 230 | return TemplateResponse( 231 | request, "inventory/item.html", {"item": item, "form": form} 232 | ) 233 | 234 | 235 | class OrderItemDelete(generic.DeleteView): 236 | model = OrderItem 237 | template_name = "inventory/orderitem_confirm_delete.html" 238 | success_url = "/inventory/orders/{order_id}/" 239 | -------------------------------------------------------------------------------- /inventory/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # -*- mode: python -*- 3 | import datetime 4 | 5 | from django.contrib.auth.models import User 6 | from django.db import models 7 | from django.db.models import Count, F, Q, Sum 8 | from django.urls import reverse 9 | 10 | 11 | class Category(models.Model): 12 | id = models.AutoField(primary_key=True) 13 | name = models.CharField(max_length=45, unique=True) 14 | 15 | def __str__(self): 16 | return self.name 17 | 18 | class Meta: 19 | verbose_name_plural = "categories" 20 | ordering = ["name"] 21 | 22 | 23 | class Unit(models.Model): 24 | id = models.AutoField(primary_key=True) 25 | name = models.CharField(max_length=45, unique=True) 26 | 27 | def __str__(self): 28 | return self.name 29 | 30 | class Meta: 31 | ordering = ["name"] 32 | 33 | 34 | class Manufacturer(models.Model): 35 | id = models.AutoField(primary_key=True) 36 | name = models.CharField(max_length=64, unique=True) 37 | url = models.CharField(max_length=64, blank=True, null=True) 38 | lookup_url = models.CharField( 39 | max_length=64, 40 | blank=True, 41 | null=True, 42 | help_text="url pattern to look up part number", 43 | ) 44 | rep = models.CharField(max_length=128, blank=True, null=True) 45 | rep_phone = models.CharField(max_length=16, blank=True, null=True) 46 | rep_email = models.CharField(max_length=64, blank=True, null=True) 47 | support_phone = models.CharField(max_length=16, blank=True, null=True) 48 | 49 | def __str__(self): 50 | return self.name 51 | 52 | class Meta: 53 | ordering = ["name"] 54 | 55 | 56 | class Vendor(models.Model): 57 | id = models.AutoField(primary_key=True) 58 | name = models.CharField(max_length=64, unique=True) 59 | url = models.CharField(max_length=64, blank=True, null=True) 60 | lookup_url = models.CharField( 61 | max_length=128, 62 | blank=True, 63 | null=True, 64 | help_text="url pattern to look up catalog number", 65 | ) 66 | phone = models.CharField(max_length=16, blank=True, null=True) 67 | rep = models.CharField(max_length=45, blank=True, null=True) 68 | rep_phone = models.CharField(max_length=16, blank=True, null=True) 69 | rep_email = models.CharField(max_length=64, blank=True, null=True) 70 | 71 | def __str__(self): 72 | return self.name 73 | 74 | class Meta: 75 | ordering = ["name"] 76 | 77 | 78 | class Account(models.Model): 79 | id = models.AutoField(primary_key=True) 80 | code = models.CharField(max_length=64, unique=True) 81 | description = models.CharField(max_length=128) 82 | expires_on = models.DateField(blank=True, null=True) 83 | 84 | def __str__(self): 85 | return "%s (%s)" % (self.description, self.code) 86 | 87 | class Meta: 88 | ordering = ["code"] 89 | verbose_name = "Account" 90 | verbose_name_plural = "Accounts" 91 | 92 | 93 | class Item(models.Model): 94 | id = models.AutoField(primary_key=True) 95 | name = models.CharField(max_length=128) 96 | chem_formula = models.CharField( 97 | "Chemical formula", max_length=45, blank=True, null=True 98 | ) 99 | 100 | vendor = models.ForeignKey(Vendor, on_delete=models.PROTECT) 101 | catalog = models.CharField("Catalog number", max_length=45, blank=True, null=True) 102 | manufacturer = models.ForeignKey( 103 | "Manufacturer", 104 | blank=True, 105 | null=True, 106 | on_delete=models.SET_NULL, 107 | help_text="leave blank if unknown or same as vendor", 108 | ) 109 | manufacturer_number = models.CharField(max_length=45, blank=True, null=True) 110 | size = models.DecimalField( 111 | "Size of unit", max_digits=10, decimal_places=2, blank=True, null=True 112 | ) 113 | unit = models.ForeignKey(Unit, on_delete=models.PROTECT) 114 | category = models.ForeignKey(Category, on_delete=models.PROTECT) 115 | created_at = models.DateTimeField(auto_now_add=True) 116 | parent_item = models.ForeignKey( 117 | "self", 118 | blank=True, 119 | null=True, 120 | on_delete=models.SET_NULL, 121 | help_text="example: for printer cartriges, select printer", 122 | ) 123 | comments = models.TextField(blank=True) 124 | 125 | def __str__(self): 126 | return self.name 127 | 128 | def unit_size(self): 129 | if self.unit.name == "each": 130 | return self.unit.name 131 | space = "" if str(self.unit).startswith("/") else " " 132 | return f"{self.size or ''}{space}{self.unit}" 133 | 134 | def vendor_url(self): 135 | try: 136 | return self.vendor.lookup_url % self.catalog 137 | except (ValueError, AttributeError, TypeError): 138 | return None 139 | 140 | def mfg_url(self): 141 | try: 142 | return self.manufacturer.lookup_url % self.manufacturer_number 143 | except (ValueError, AttributeError, TypeError): 144 | return None 145 | 146 | def get_absolute_url(self): 147 | return reverse("inventory:item", kwargs={"pk": self.pk}) 148 | 149 | class Meta: 150 | constraints = [ 151 | models.UniqueConstraint( 152 | fields=["vendor", "catalog"], name="unique_vendor_catalog_number" 153 | ) 154 | ] 155 | 156 | 157 | class OrderQuerySet(models.QuerySet): 158 | def with_counts(self, on_date: datetime.date | None = None): 159 | on_date = on_date or datetime.date.today() 160 | return self.annotate( 161 | item_count=Count("orderitem"), 162 | item_received_count=Count( 163 | "orderitem", filter=Q(orderitem__arrived_on__lte=on_date) 164 | ), 165 | ) 166 | 167 | def placed(self, on_date: datetime.date | None = None): 168 | return self.filter(placed_on__lte=on_date or datetime.date.today()) 169 | 170 | def not_placed(self, on_date: datetime.date | None = None): 171 | return self.exclude(placed_on__lte=on_date or datetime.date.today()) 172 | 173 | def completed(self, on_date: datetime.date | None = None): 174 | return ( 175 | self.placed() 176 | .with_counts(on_date) 177 | .filter(item_count=F("item_received_count")) 178 | ) 179 | 180 | def not_completed(self, on_date: datetime.date | None = None): 181 | return ( 182 | self.placed() 183 | .with_counts(on_date) 184 | .filter(item_count__gt=F("item_received_count")) 185 | ) 186 | 187 | 188 | class Order(models.Model): 189 | id = models.AutoField(primary_key=True) 190 | name = models.CharField(max_length=64) 191 | created_at = models.DateTimeField(auto_now_add=True) 192 | items = models.ManyToManyField(Item, through="OrderItem") 193 | accounts = models.ManyToManyField( 194 | Account, through="OrderAccount", blank=True, related_name="orders" 195 | ) 196 | placed_on = models.DateField(blank=True, null=True) 197 | requested_by = models.ForeignKey( 198 | User, on_delete=models.PROTECT, verbose_name="Requested by" 199 | ) 200 | objects = OrderQuerySet.as_manager() 201 | 202 | def __str__(self): 203 | status = self.placed_on or "in progress" 204 | return f"{self.name} ({status})" 205 | 206 | def get_absolute_url(self): 207 | return reverse("inventory:order", kwargs={"pk": self.pk}) 208 | 209 | def total_cost(self): 210 | totals = self.orderitem_set.with_totals().aggregate( 211 | Sum("total_cost", default=0) 212 | ) 213 | return totals["total_cost__sum"] 214 | 215 | def mark_placed(self, on_date: datetime.date | None = None): 216 | self.placed_on = on_date or datetime.date.today() 217 | self.save() 218 | 219 | def add_item(self, item: Item, n_units: int, cost_per_unit: float) -> "OrderItem": 220 | return OrderItem.objects.create( 221 | item=item, order=self, units_purchased=n_units, cost=cost_per_unit 222 | ) 223 | 224 | def add_account(self, account: Account) -> "OrderAccount": 225 | return OrderAccount.objects.create(order=self, account=account) 226 | 227 | def account_codes(self) -> str: 228 | return ", ".join(acct.code for acct in self.accounts.all()) 229 | 230 | def account_descriptions(self) -> str: 231 | return ", ".join(str(acct) for acct in self.accounts.all()) 232 | 233 | class Meta: 234 | ordering = ["-created_at"] 235 | 236 | 237 | class OrderItemQuerySet(models.QuerySet): 238 | def with_totals(self): 239 | return self.annotate(total_cost=F("cost") * F("units_purchased")) 240 | 241 | 242 | class OrderItem(models.Model): 243 | id = models.AutoField(primary_key=True) 244 | item = models.ForeignKey(Item, on_delete=models.CASCADE) 245 | order = models.ForeignKey(Order, on_delete=models.CASCADE) 246 | 247 | units_purchased = models.IntegerField() 248 | cost = models.DecimalField( 249 | "Cost per unit", max_digits=10, decimal_places=2, blank=True, null=True 250 | ) 251 | 252 | arrived_on = models.DateField(blank=True, null=True) 253 | serial = models.CharField("Serial number", max_length=45, blank=True, null=True) 254 | uva_equip = models.CharField( 255 | "UVa equipment number", max_length=32, blank=True, null=True 256 | ) 257 | location = models.CharField( 258 | max_length=45, 259 | blank=True, 260 | null=True, 261 | help_text="example: -80 freezer, refrigerator, Gilmer 283", 262 | ) 263 | expiry_years = models.DecimalField( 264 | "Warranty or Item expiration (y)", 265 | max_digits=4, 266 | decimal_places=2, 267 | blank=True, 268 | null=True, 269 | ) 270 | reconciled = models.BooleanField(default=False) 271 | 272 | objects = OrderItemQuerySet.as_manager() 273 | 274 | def total_cost(self): 275 | return (self.cost or 0) * self.units_purchased 276 | 277 | def name(self): 278 | return self.item.name 279 | 280 | def ordered_on(self): 281 | return self.order.placed_on 282 | 283 | def __str__(self): 284 | return f"{self.name()} [{self.ordered_on()}]" 285 | 286 | def mark_arrived(self, on_date: datetime.date | None = None): 287 | self.arrived_on = on_date or datetime.date.today() 288 | self.save() 289 | 290 | class Meta: 291 | db_table = "inventory_order_items" 292 | 293 | 294 | class OrderAccount(models.Model): 295 | """ 296 | Through model for Order-Account relationship. 297 | Allows for future additions like allocation percentages, notes, etc. 298 | """ 299 | 300 | id = models.AutoField(primary_key=True) 301 | order = models.ForeignKey(Order, on_delete=models.CASCADE) 302 | account = models.ForeignKey(Account, on_delete=models.CASCADE) 303 | created_at = models.DateTimeField(auto_now_add=True) 304 | 305 | # Future fields can be added here without changing the relationship structure 306 | # e.g., allocation_percentage, notes, etc. 307 | 308 | class Meta: 309 | db_table = "inventory_order_accounts" 310 | unique_together = ["order", "account"] 311 | ordering = ["account__code"] 312 | 313 | def __str__(self): 314 | return f"{self.order.name} - {self.account.code}" 315 | -------------------------------------------------------------------------------- /inventory/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import datetime 5 | 6 | from django.conf import settings 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | dependencies = [ 12 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="Category", 18 | fields=[ 19 | ( 20 | "id", 21 | models.AutoField( 22 | serialize=False, 23 | verbose_name="ID", 24 | primary_key=True, 25 | auto_created=True, 26 | ), 27 | ), 28 | ("name", models.CharField(max_length=45)), 29 | ], 30 | options={ 31 | "ordering": ["name"], 32 | "verbose_name_plural": "categories", 33 | }, 34 | ), 35 | migrations.CreateModel( 36 | name="Item", 37 | fields=[ 38 | ( 39 | "id", 40 | models.AutoField( 41 | serialize=False, 42 | verbose_name="ID", 43 | primary_key=True, 44 | auto_created=True, 45 | ), 46 | ), 47 | ("name", models.CharField(max_length=128)), 48 | ( 49 | "chem_formula", 50 | models.CharField( 51 | max_length=45, 52 | null=True, 53 | verbose_name="Chemical formula", 54 | blank=True, 55 | ), 56 | ), 57 | ( 58 | "catalog", 59 | models.CharField( 60 | max_length=45, 61 | null=True, 62 | verbose_name="Catalog number", 63 | blank=True, 64 | ), 65 | ), 66 | ( 67 | "manufacturer_number", 68 | models.CharField(max_length=45, null=True, blank=True), 69 | ), 70 | ( 71 | "size", 72 | models.DecimalField( 73 | max_digits=10, 74 | decimal_places=2, 75 | null=True, 76 | verbose_name="Size of unit", 77 | blank=True, 78 | ), 79 | ), 80 | ("date_added", models.DateField(auto_now_add=True)), 81 | ("comments", models.TextField(blank=True)), 82 | ( 83 | "category", 84 | models.ForeignKey( 85 | to="inventory.Category", on_delete=models.CASCADE 86 | ), 87 | ), 88 | ], 89 | ), 90 | migrations.CreateModel( 91 | name="Manufacturer", 92 | fields=[ 93 | ( 94 | "id", 95 | models.AutoField( 96 | serialize=False, 97 | verbose_name="ID", 98 | primary_key=True, 99 | auto_created=True, 100 | ), 101 | ), 102 | ("name", models.CharField(max_length=64)), 103 | ("url", models.CharField(max_length=64, null=True, blank=True)), 104 | ("rep", models.CharField(max_length=45, null=True, blank=True)), 105 | ("rep_phone", models.CharField(max_length=16, null=True, blank=True)), 106 | ( 107 | "support_phone", 108 | models.CharField(max_length=16, null=True, blank=True), 109 | ), 110 | ], 111 | options={ 112 | "ordering": ["name"], 113 | }, 114 | ), 115 | migrations.CreateModel( 116 | name="Order", 117 | fields=[ 118 | ( 119 | "id", 120 | models.AutoField( 121 | serialize=False, 122 | verbose_name="ID", 123 | primary_key=True, 124 | auto_created=True, 125 | ), 126 | ), 127 | ("name", models.CharField(max_length=64)), 128 | ("created", models.DateTimeField(auto_now_add=True)), 129 | ("ordered", models.BooleanField()), 130 | ("order_date", models.DateField(default=datetime.date.today)), 131 | ], 132 | options={ 133 | "ordering": ["-order_date", "name"], 134 | }, 135 | ), 136 | migrations.CreateModel( 137 | name="OrderItem", 138 | fields=[ 139 | ( 140 | "id", 141 | models.AutoField( 142 | serialize=False, 143 | verbose_name="ID", 144 | primary_key=True, 145 | auto_created=True, 146 | ), 147 | ), 148 | ("units_purchased", models.IntegerField()), 149 | ( 150 | "cost", 151 | models.DecimalField( 152 | max_digits=10, 153 | decimal_places=2, 154 | null=True, 155 | verbose_name="Cost per unit", 156 | blank=True, 157 | ), 158 | ), 159 | ("date_arrived", models.DateField(null=True, blank=True)), 160 | ( 161 | "serial", 162 | models.CharField( 163 | max_length=45, 164 | null=True, 165 | verbose_name="Serial number", 166 | blank=True, 167 | ), 168 | ), 169 | ( 170 | "uva_equip", 171 | models.CharField( 172 | max_length=32, 173 | null=True, 174 | verbose_name="UVa equipment number", 175 | blank=True, 176 | ), 177 | ), 178 | ( 179 | "location", 180 | models.CharField( 181 | max_length=45, 182 | null=True, 183 | help_text="example: -80 freezer, refrigerator, Gilmer 283", 184 | blank=True, 185 | ), 186 | ), 187 | ( 188 | "expiry_years", 189 | models.DecimalField( 190 | max_digits=4, 191 | decimal_places=2, 192 | null=True, 193 | verbose_name="Warranty or Item expiration (y)", 194 | blank=True, 195 | ), 196 | ), 197 | ("reconciled", models.BooleanField()), 198 | ( 199 | "item", 200 | models.ForeignKey(to="inventory.Item", on_delete=models.CASCADE), 201 | ), 202 | ( 203 | "order", 204 | models.ForeignKey(to="inventory.Order", on_delete=models.CASCADE), 205 | ), 206 | ], 207 | options={ 208 | "db_table": "inventory_order_items", 209 | }, 210 | ), 211 | migrations.CreateModel( 212 | name="PTAO", 213 | fields=[ 214 | ( 215 | "id", 216 | models.AutoField( 217 | serialize=False, 218 | verbose_name="ID", 219 | primary_key=True, 220 | auto_created=True, 221 | ), 222 | ), 223 | ("code", models.CharField(max_length=64, unique=True)), 224 | ("description", models.CharField(max_length=128)), 225 | ("expires", models.DateField(null=True, blank=True)), 226 | ], 227 | options={ 228 | "ordering": ["code"], 229 | "verbose_name": "PTAO", 230 | "verbose_name_plural": "PTAOs", 231 | }, 232 | ), 233 | migrations.CreateModel( 234 | name="Unit", 235 | fields=[ 236 | ( 237 | "id", 238 | models.AutoField( 239 | serialize=False, 240 | verbose_name="ID", 241 | primary_key=True, 242 | auto_created=True, 243 | ), 244 | ), 245 | ("name", models.CharField(max_length=45)), 246 | ], 247 | options={ 248 | "ordering": ["name"], 249 | }, 250 | ), 251 | migrations.CreateModel( 252 | name="Vendor", 253 | fields=[ 254 | ( 255 | "id", 256 | models.AutoField( 257 | serialize=False, 258 | verbose_name="ID", 259 | primary_key=True, 260 | auto_created=True, 261 | ), 262 | ), 263 | ("name", models.CharField(max_length=64)), 264 | ("url", models.CharField(max_length=64, null=True, blank=True)), 265 | ("phone", models.CharField(max_length=16, null=True, blank=True)), 266 | ("rep", models.CharField(max_length=45, null=True, blank=True)), 267 | ("rep_phone", models.CharField(max_length=16, null=True, blank=True)), 268 | ], 269 | options={ 270 | "ordering": ["name"], 271 | }, 272 | ), 273 | migrations.AddField( 274 | model_name="order", 275 | name="items", 276 | field=models.ManyToManyField( 277 | through="inventory.OrderItem", to="inventory.Item" 278 | ), 279 | ), 280 | migrations.AddField( 281 | model_name="order", 282 | name="ordered_by", 283 | field=models.ForeignKey( 284 | to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE 285 | ), 286 | ), 287 | migrations.AddField( 288 | model_name="order", 289 | name="ptao", 290 | field=models.ForeignKey( 291 | blank=True, to="inventory.PTAO", null=True, on_delete=models.SET_NULL 292 | ), 293 | ), 294 | migrations.AddField( 295 | model_name="item", 296 | name="manufacturer", 297 | field=models.ForeignKey( 298 | help_text="leave blank if unknown or same as vendor", 299 | blank=True, 300 | to="inventory.Manufacturer", 301 | null=True, 302 | on_delete=models.SET_NULL, 303 | ), 304 | ), 305 | migrations.AddField( 306 | model_name="item", 307 | name="parent_item", 308 | field=models.ForeignKey( 309 | help_text="example: for printer cartriges, select printer", 310 | blank=True, 311 | to="inventory.Item", 312 | null=True, 313 | on_delete=models.SET_NULL, 314 | ), 315 | ), 316 | migrations.AddField( 317 | model_name="item", 318 | name="unit", 319 | field=models.ForeignKey(to="inventory.Unit", on_delete=models.CASCADE), 320 | ), 321 | migrations.AddField( 322 | model_name="item", 323 | name="vendor", 324 | field=models.ForeignKey(to="inventory.Vendor", on_delete=models.CASCADE), 325 | ), 326 | ] 327 | -------------------------------------------------------------------------------- /inventory/migrations/0001_squashed_0003_auto_20150626_1807.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import datetime 5 | 6 | from django.conf import settings 7 | from django.db import migrations, models 8 | 9 | 10 | class Migration(migrations.Migration): 11 | replaces = [ 12 | ("inventory", "0001_initial"), 13 | ("inventory", "0002_auto_20150626_1805"), 14 | ("inventory", "0003_auto_20150626_1807"), 15 | ] 16 | 17 | dependencies = [ 18 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 19 | ] 20 | 21 | operations = [ 22 | migrations.CreateModel( 23 | name="Category", 24 | fields=[ 25 | ( 26 | "id", 27 | models.AutoField( 28 | serialize=False, 29 | primary_key=True, 30 | verbose_name="ID", 31 | auto_created=True, 32 | ), 33 | ), 34 | ("name", models.CharField(max_length=45)), 35 | ], 36 | options={ 37 | "ordering": ["name"], 38 | "verbose_name_plural": "categories", 39 | }, 40 | ), 41 | migrations.CreateModel( 42 | name="Item", 43 | fields=[ 44 | ( 45 | "id", 46 | models.AutoField( 47 | serialize=False, 48 | primary_key=True, 49 | verbose_name="ID", 50 | auto_created=True, 51 | ), 52 | ), 53 | ("name", models.CharField(max_length=128)), 54 | ( 55 | "chem_formula", 56 | models.CharField( 57 | blank=True, 58 | max_length=45, 59 | verbose_name="Chemical formula", 60 | null=True, 61 | ), 62 | ), 63 | ( 64 | "catalog", 65 | models.CharField( 66 | blank=True, 67 | max_length=45, 68 | verbose_name="Catalog number", 69 | null=True, 70 | ), 71 | ), 72 | ( 73 | "manufacturer_number", 74 | models.CharField(blank=True, max_length=45, null=True), 75 | ), 76 | ( 77 | "size", 78 | models.DecimalField( 79 | blank=True, 80 | null=True, 81 | verbose_name="Size of unit", 82 | decimal_places=2, 83 | max_digits=10, 84 | ), 85 | ), 86 | ("date_added", models.DateField(auto_now_add=True)), 87 | ("comments", models.TextField(blank=True)), 88 | ( 89 | "category", 90 | models.ForeignKey( 91 | to="inventory.Category", on_delete=models.CASCADE 92 | ), 93 | ), 94 | ], 95 | ), 96 | migrations.CreateModel( 97 | name="Manufacturer", 98 | fields=[ 99 | ( 100 | "id", 101 | models.AutoField( 102 | serialize=False, 103 | primary_key=True, 104 | verbose_name="ID", 105 | auto_created=True, 106 | ), 107 | ), 108 | ("name", models.CharField(max_length=64)), 109 | ("url", models.CharField(blank=True, max_length=64, null=True)), 110 | ("rep", models.CharField(blank=True, max_length=128, null=True)), 111 | ("rep_phone", models.CharField(blank=True, max_length=16, null=True)), 112 | ( 113 | "support_phone", 114 | models.CharField(blank=True, max_length=16, null=True), 115 | ), 116 | ( 117 | "lookup_url", 118 | models.CharField( 119 | blank=True, 120 | max_length=64, 121 | help_text="url pattern to look up part number", 122 | null=True, 123 | ), 124 | ), 125 | ], 126 | options={ 127 | "ordering": ["name"], 128 | }, 129 | ), 130 | migrations.CreateModel( 131 | name="Order", 132 | fields=[ 133 | ( 134 | "id", 135 | models.AutoField( 136 | serialize=False, 137 | primary_key=True, 138 | verbose_name="ID", 139 | auto_created=True, 140 | ), 141 | ), 142 | ("name", models.CharField(max_length=64)), 143 | ("created", models.DateTimeField(auto_now_add=True)), 144 | ("ordered", models.BooleanField()), 145 | ("order_date", models.DateField(default=datetime.date.today)), 146 | ], 147 | options={ 148 | "ordering": ["-order_date", "name"], 149 | }, 150 | ), 151 | migrations.CreateModel( 152 | name="OrderItem", 153 | fields=[ 154 | ( 155 | "id", 156 | models.AutoField( 157 | serialize=False, 158 | primary_key=True, 159 | verbose_name="ID", 160 | auto_created=True, 161 | ), 162 | ), 163 | ("units_purchased", models.IntegerField()), 164 | ( 165 | "cost", 166 | models.DecimalField( 167 | blank=True, 168 | null=True, 169 | verbose_name="Cost per unit", 170 | decimal_places=2, 171 | max_digits=10, 172 | ), 173 | ), 174 | ("date_arrived", models.DateField(blank=True, null=True)), 175 | ( 176 | "serial", 177 | models.CharField( 178 | blank=True, 179 | max_length=45, 180 | verbose_name="Serial number", 181 | null=True, 182 | ), 183 | ), 184 | ( 185 | "uva_equip", 186 | models.CharField( 187 | blank=True, 188 | max_length=32, 189 | verbose_name="UVa equipment number", 190 | null=True, 191 | ), 192 | ), 193 | ( 194 | "location", 195 | models.CharField( 196 | blank=True, 197 | max_length=45, 198 | help_text="example: -80 freezer, refrigerator, Gilmer 283", 199 | null=True, 200 | ), 201 | ), 202 | ( 203 | "expiry_years", 204 | models.DecimalField( 205 | blank=True, 206 | null=True, 207 | verbose_name="Warranty or Item expiration (y)", 208 | decimal_places=2, 209 | max_digits=4, 210 | ), 211 | ), 212 | ("reconciled", models.BooleanField()), 213 | ( 214 | "item", 215 | models.ForeignKey(to="inventory.Item", on_delete=models.CASCADE), 216 | ), 217 | ( 218 | "order", 219 | models.ForeignKey(to="inventory.Order", on_delete=models.CASCADE), 220 | ), 221 | ], 222 | options={ 223 | "db_table": "inventory_order_items", 224 | }, 225 | ), 226 | migrations.CreateModel( 227 | name="PTAO", 228 | fields=[ 229 | ( 230 | "id", 231 | models.AutoField( 232 | serialize=False, 233 | primary_key=True, 234 | verbose_name="ID", 235 | auto_created=True, 236 | ), 237 | ), 238 | ("code", models.CharField(max_length=64, unique=True)), 239 | ("description", models.CharField(max_length=128)), 240 | ("expires", models.DateField(blank=True, null=True)), 241 | ], 242 | options={ 243 | "ordering": ["code"], 244 | "verbose_name": "PTAO", 245 | "verbose_name_plural": "PTAOs", 246 | }, 247 | ), 248 | migrations.CreateModel( 249 | name="Unit", 250 | fields=[ 251 | ( 252 | "id", 253 | models.AutoField( 254 | serialize=False, 255 | primary_key=True, 256 | verbose_name="ID", 257 | auto_created=True, 258 | ), 259 | ), 260 | ("name", models.CharField(max_length=45)), 261 | ], 262 | options={ 263 | "ordering": ["name"], 264 | }, 265 | ), 266 | migrations.CreateModel( 267 | name="Vendor", 268 | fields=[ 269 | ( 270 | "id", 271 | models.AutoField( 272 | serialize=False, 273 | primary_key=True, 274 | verbose_name="ID", 275 | auto_created=True, 276 | ), 277 | ), 278 | ("name", models.CharField(max_length=64)), 279 | ("url", models.CharField(blank=True, max_length=64, null=True)), 280 | ("phone", models.CharField(blank=True, max_length=16, null=True)), 281 | ("rep", models.CharField(blank=True, max_length=45, null=True)), 282 | ("rep_phone", models.CharField(blank=True, max_length=16, null=True)), 283 | ( 284 | "lookup_url", 285 | models.CharField( 286 | blank=True, 287 | max_length=128, 288 | help_text="url pattern to look up catalog number", 289 | null=True, 290 | ), 291 | ), 292 | ], 293 | options={ 294 | "ordering": ["name"], 295 | }, 296 | ), 297 | migrations.AddField( 298 | model_name="order", 299 | name="items", 300 | field=models.ManyToManyField( 301 | to="inventory.Item", through="inventory.OrderItem" 302 | ), 303 | ), 304 | migrations.AddField( 305 | model_name="order", 306 | name="ordered_by", 307 | field=models.ForeignKey( 308 | to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE 309 | ), 310 | ), 311 | migrations.AddField( 312 | model_name="order", 313 | name="ptao", 314 | field=models.ForeignKey( 315 | blank=True, to="inventory.PTAO", null=True, on_delete=models.SET_NULL 316 | ), 317 | ), 318 | migrations.AddField( 319 | model_name="item", 320 | name="manufacturer", 321 | field=models.ForeignKey( 322 | help_text="leave blank if unknown or same as vendor", 323 | blank=True, 324 | to="inventory.Manufacturer", 325 | null=True, 326 | on_delete=models.SET_NULL, 327 | ), 328 | ), 329 | migrations.AddField( 330 | model_name="item", 331 | name="parent_item", 332 | field=models.ForeignKey( 333 | help_text="example: for printer cartriges, select printer", 334 | blank=True, 335 | to="inventory.Item", 336 | null=True, 337 | on_delete=models.SET_NULL, 338 | ), 339 | ), 340 | migrations.AddField( 341 | model_name="item", 342 | name="unit", 343 | field=models.ForeignKey(to="inventory.Unit", on_delete=models.CASCADE), 344 | ), 345 | migrations.AddField( 346 | model_name="item", 347 | name="vendor", 348 | field=models.ForeignKey(to="inventory.Vendor", on_delete=models.CASCADE), 349 | ), 350 | ] 351 | -------------------------------------------------------------------------------- /uv.lock: -------------------------------------------------------------------------------- 1 | version = 1 2 | revision = 2 3 | requires-python = ">=3.10" 4 | 5 | [[package]] 6 | name = "asgiref" 7 | version = "3.8.1" 8 | source = { registry = "https://pypi.org/simple" } 9 | dependencies = [ 10 | { name = "typing-extensions", marker = "python_full_version < '3.11'" }, 11 | ] 12 | sdist = { url = "https://files.pythonhosted.org/packages/29/38/b3395cc9ad1b56d2ddac9970bc8f4141312dbaec28bc7c218b0dfafd0f42/asgiref-3.8.1.tar.gz", hash = "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590", size = 35186, upload-time = "2024-03-22T14:39:36.863Z" } 13 | wheels = [ 14 | { url = "https://files.pythonhosted.org/packages/39/e3/893e8757be2612e6c266d9bb58ad2e3651524b5b40cf56761e985a28b13e/asgiref-3.8.1-py3-none-any.whl", hash = "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", size = 23828, upload-time = "2024-03-22T14:39:34.521Z" }, 15 | ] 16 | 17 | [[package]] 18 | name = "colorama" 19 | version = "0.4.6" 20 | source = { registry = "https://pypi.org/simple" } 21 | sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } 22 | wheels = [ 23 | { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, 24 | ] 25 | 26 | [[package]] 27 | name = "coverage" 28 | version = "7.6.1" 29 | source = { registry = "https://pypi.org/simple" } 30 | sdist = { url = "https://files.pythonhosted.org/packages/f7/08/7e37f82e4d1aead42a7443ff06a1e406aabf7302c4f00a546e4b320b994c/coverage-7.6.1.tar.gz", hash = "sha256:953510dfb7b12ab69d20135a0662397f077c59b1e6379a768e97c59d852ee51d", size = 798791, upload-time = "2024-08-04T19:45:30.9Z" } 31 | wheels = [ 32 | { url = "https://files.pythonhosted.org/packages/7e/61/eb7ce5ed62bacf21beca4937a90fe32545c91a3c8a42a30c6616d48fc70d/coverage-7.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b06079abebbc0e89e6163b8e8f0e16270124c154dc6e4a47b413dd538859af16", size = 206690, upload-time = "2024-08-04T19:43:07.695Z" }, 33 | { url = "https://files.pythonhosted.org/packages/7d/73/041928e434442bd3afde5584bdc3f932fb4562b1597629f537387cec6f3d/coverage-7.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf4b19715bccd7ee27b6b120e7e9dd56037b9c0681dcc1adc9ba9db3d417fa36", size = 207127, upload-time = "2024-08-04T19:43:10.15Z" }, 34 | { url = "https://files.pythonhosted.org/packages/c7/c8/6ca52b5147828e45ad0242388477fdb90df2c6cbb9a441701a12b3c71bc8/coverage-7.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61c0abb4c85b095a784ef23fdd4aede7a2628478e7baba7c5e3deba61070a02", size = 235654, upload-time = "2024-08-04T19:43:12.405Z" }, 35 | { url = "https://files.pythonhosted.org/packages/d5/da/9ac2b62557f4340270942011d6efeab9833648380109e897d48ab7c1035d/coverage-7.6.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd21f6ae3f08b41004dfb433fa895d858f3f5979e7762d052b12aef444e29afc", size = 233598, upload-time = "2024-08-04T19:43:14.078Z" }, 36 | { url = "https://files.pythonhosted.org/packages/53/23/9e2c114d0178abc42b6d8d5281f651a8e6519abfa0ef460a00a91f80879d/coverage-7.6.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f59d57baca39b32db42b83b2a7ba6f47ad9c394ec2076b084c3f029b7afca23", size = 234732, upload-time = "2024-08-04T19:43:16.632Z" }, 37 | { url = "https://files.pythonhosted.org/packages/0f/7e/a0230756fb133343a52716e8b855045f13342b70e48e8ad41d8a0d60ab98/coverage-7.6.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a1ac0ae2b8bd743b88ed0502544847c3053d7171a3cff9228af618a068ed9c34", size = 233816, upload-time = "2024-08-04T19:43:19.049Z" }, 38 | { url = "https://files.pythonhosted.org/packages/28/7c/3753c8b40d232b1e5eeaed798c875537cf3cb183fb5041017c1fdb7ec14e/coverage-7.6.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e6a08c0be454c3b3beb105c0596ebdc2371fab6bb90c0c0297f4e58fd7e1012c", size = 232325, upload-time = "2024-08-04T19:43:21.246Z" }, 39 | { url = "https://files.pythonhosted.org/packages/57/e3/818a2b2af5b7573b4b82cf3e9f137ab158c90ea750a8f053716a32f20f06/coverage-7.6.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f5796e664fe802da4f57a168c85359a8fbf3eab5e55cd4e4569fbacecc903959", size = 233418, upload-time = "2024-08-04T19:43:22.945Z" }, 40 | { url = "https://files.pythonhosted.org/packages/c8/fb/4532b0b0cefb3f06d201648715e03b0feb822907edab3935112b61b885e2/coverage-7.6.1-cp310-cp310-win32.whl", hash = "sha256:7bb65125fcbef8d989fa1dd0e8a060999497629ca5b0efbca209588a73356232", size = 209343, upload-time = "2024-08-04T19:43:25.121Z" }, 41 | { url = "https://files.pythonhosted.org/packages/5a/25/af337cc7421eca1c187cc9c315f0a755d48e755d2853715bfe8c418a45fa/coverage-7.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:3115a95daa9bdba70aea750db7b96b37259a81a709223c8448fa97727d546fe0", size = 210136, upload-time = "2024-08-04T19:43:26.851Z" }, 42 | { url = "https://files.pythonhosted.org/packages/ad/5f/67af7d60d7e8ce61a4e2ddcd1bd5fb787180c8d0ae0fbd073f903b3dd95d/coverage-7.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7dea0889685db8550f839fa202744652e87c60015029ce3f60e006f8c4462c93", size = 206796, upload-time = "2024-08-04T19:43:29.115Z" }, 43 | { url = "https://files.pythonhosted.org/packages/e1/0e/e52332389e057daa2e03be1fbfef25bb4d626b37d12ed42ae6281d0a274c/coverage-7.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed37bd3c3b063412f7620464a9ac1314d33100329f39799255fb8d3027da50d3", size = 207244, upload-time = "2024-08-04T19:43:31.285Z" }, 44 | { url = "https://files.pythonhosted.org/packages/aa/cd/766b45fb6e090f20f8927d9c7cb34237d41c73a939358bc881883fd3a40d/coverage-7.6.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d85f5e9a5f8b73e2350097c3756ef7e785f55bd71205defa0bfdaf96c31616ff", size = 239279, upload-time = "2024-08-04T19:43:33.581Z" }, 45 | { url = "https://files.pythonhosted.org/packages/70/6c/a9ccd6fe50ddaf13442a1e2dd519ca805cbe0f1fcd377fba6d8339b98ccb/coverage-7.6.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9bc572be474cafb617672c43fe989d6e48d3c83af02ce8de73fff1c6bb3c198d", size = 236859, upload-time = "2024-08-04T19:43:35.301Z" }, 46 | { url = "https://files.pythonhosted.org/packages/14/6f/8351b465febb4dbc1ca9929505202db909c5a635c6fdf33e089bbc3d7d85/coverage-7.6.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0420b573964c760df9e9e86d1a9a622d0d27f417e1a949a8a66dd7bcee7bc6", size = 238549, upload-time = "2024-08-04T19:43:37.578Z" }, 47 | { url = "https://files.pythonhosted.org/packages/68/3c/289b81fa18ad72138e6d78c4c11a82b5378a312c0e467e2f6b495c260907/coverage-7.6.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f4aa8219db826ce6be7099d559f8ec311549bfc4046f7f9fe9b5cea5c581c56", size = 237477, upload-time = "2024-08-04T19:43:39.92Z" }, 48 | { url = "https://files.pythonhosted.org/packages/ed/1c/aa1efa6459d822bd72c4abc0b9418cf268de3f60eeccd65dc4988553bd8d/coverage-7.6.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:fc5a77d0c516700ebad189b587de289a20a78324bc54baee03dd486f0855d234", size = 236134, upload-time = "2024-08-04T19:43:41.453Z" }, 49 | { url = "https://files.pythonhosted.org/packages/fb/c8/521c698f2d2796565fe9c789c2ee1ccdae610b3aa20b9b2ef980cc253640/coverage-7.6.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b48f312cca9621272ae49008c7f613337c53fadca647d6384cc129d2996d1133", size = 236910, upload-time = "2024-08-04T19:43:43.037Z" }, 50 | { url = "https://files.pythonhosted.org/packages/7d/30/033e663399ff17dca90d793ee8a2ea2890e7fdf085da58d82468b4220bf7/coverage-7.6.1-cp311-cp311-win32.whl", hash = "sha256:1125ca0e5fd475cbbba3bb67ae20bd2c23a98fac4e32412883f9bcbaa81c314c", size = 209348, upload-time = "2024-08-04T19:43:44.787Z" }, 51 | { url = "https://files.pythonhosted.org/packages/20/05/0d1ccbb52727ccdadaa3ff37e4d2dc1cd4d47f0c3df9eb58d9ec8508ca88/coverage-7.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:8ae539519c4c040c5ffd0632784e21b2f03fc1340752af711f33e5be83a9d6c6", size = 210230, upload-time = "2024-08-04T19:43:46.707Z" }, 52 | { url = "https://files.pythonhosted.org/packages/7e/d4/300fc921dff243cd518c7db3a4c614b7e4b2431b0d1145c1e274fd99bd70/coverage-7.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:95cae0efeb032af8458fc27d191f85d1717b1d4e49f7cb226cf526ff28179778", size = 206983, upload-time = "2024-08-04T19:43:49.082Z" }, 53 | { url = "https://files.pythonhosted.org/packages/e1/ab/6bf00de5327ecb8db205f9ae596885417a31535eeda6e7b99463108782e1/coverage-7.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5621a9175cf9d0b0c84c2ef2b12e9f5f5071357c4d2ea6ca1cf01814f45d2391", size = 207221, upload-time = "2024-08-04T19:43:52.15Z" }, 54 | { url = "https://files.pythonhosted.org/packages/92/8f/2ead05e735022d1a7f3a0a683ac7f737de14850395a826192f0288703472/coverage-7.6.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:260933720fdcd75340e7dbe9060655aff3af1f0c5d20f46b57f262ab6c86a5e8", size = 240342, upload-time = "2024-08-04T19:43:53.746Z" }, 55 | { url = "https://files.pythonhosted.org/packages/0f/ef/94043e478201ffa85b8ae2d2c79b4081e5a1b73438aafafccf3e9bafb6b5/coverage-7.6.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07e2ca0ad381b91350c0ed49d52699b625aab2b44b65e1b4e02fa9df0e92ad2d", size = 237371, upload-time = "2024-08-04T19:43:55.993Z" }, 56 | { url = "https://files.pythonhosted.org/packages/1f/0f/c890339dd605f3ebc269543247bdd43b703cce6825b5ed42ff5f2d6122c7/coverage-7.6.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c44fee9975f04b33331cb8eb272827111efc8930cfd582e0320613263ca849ca", size = 239455, upload-time = "2024-08-04T19:43:57.618Z" }, 57 | { url = "https://files.pythonhosted.org/packages/d1/04/7fd7b39ec7372a04efb0f70c70e35857a99b6a9188b5205efb4c77d6a57a/coverage-7.6.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877abb17e6339d96bf08e7a622d05095e72b71f8afd8a9fefc82cf30ed944163", size = 238924, upload-time = "2024-08-04T19:44:00.012Z" }, 58 | { url = "https://files.pythonhosted.org/packages/ed/bf/73ce346a9d32a09cf369f14d2a06651329c984e106f5992c89579d25b27e/coverage-7.6.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3e0cadcf6733c09154b461f1ca72d5416635e5e4ec4e536192180d34ec160f8a", size = 237252, upload-time = "2024-08-04T19:44:01.713Z" }, 59 | { url = "https://files.pythonhosted.org/packages/86/74/1dc7a20969725e917b1e07fe71a955eb34bc606b938316bcc799f228374b/coverage-7.6.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3c02d12f837d9683e5ab2f3d9844dc57655b92c74e286c262e0fc54213c216d", size = 238897, upload-time = "2024-08-04T19:44:03.898Z" }, 60 | { url = "https://files.pythonhosted.org/packages/b6/e9/d9cc3deceb361c491b81005c668578b0dfa51eed02cd081620e9a62f24ec/coverage-7.6.1-cp312-cp312-win32.whl", hash = "sha256:e05882b70b87a18d937ca6768ff33cc3f72847cbc4de4491c8e73880766718e5", size = 209606, upload-time = "2024-08-04T19:44:05.532Z" }, 61 | { url = "https://files.pythonhosted.org/packages/47/c8/5a2e41922ea6740f77d555c4d47544acd7dc3f251fe14199c09c0f5958d3/coverage-7.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:b5d7b556859dd85f3a541db6a4e0167b86e7273e1cdc973e5b175166bb634fdb", size = 210373, upload-time = "2024-08-04T19:44:07.079Z" }, 62 | { url = "https://files.pythonhosted.org/packages/8c/f9/9aa4dfb751cb01c949c990d136a0f92027fbcc5781c6e921df1cb1563f20/coverage-7.6.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a4acd025ecc06185ba2b801f2de85546e0b8ac787cf9d3b06e7e2a69f925b106", size = 207007, upload-time = "2024-08-04T19:44:09.453Z" }, 63 | { url = "https://files.pythonhosted.org/packages/b9/67/e1413d5a8591622a46dd04ff80873b04c849268831ed5c304c16433e7e30/coverage-7.6.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a6d3adcf24b624a7b778533480e32434a39ad8fa30c315208f6d3e5542aeb6e9", size = 207269, upload-time = "2024-08-04T19:44:11.045Z" }, 64 | { url = "https://files.pythonhosted.org/packages/14/5b/9dec847b305e44a5634d0fb8498d135ab1d88330482b74065fcec0622224/coverage-7.6.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0c212c49b6c10e6951362f7c6df3329f04c2b1c28499563d4035d964ab8e08c", size = 239886, upload-time = "2024-08-04T19:44:12.83Z" }, 65 | { url = "https://files.pythonhosted.org/packages/7b/b7/35760a67c168e29f454928f51f970342d23cf75a2bb0323e0f07334c85f3/coverage-7.6.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6e81d7a3e58882450ec4186ca59a3f20a5d4440f25b1cff6f0902ad890e6748a", size = 237037, upload-time = "2024-08-04T19:44:15.393Z" }, 66 | { url = "https://files.pythonhosted.org/packages/f7/95/d2fd31f1d638df806cae59d7daea5abf2b15b5234016a5ebb502c2f3f7ee/coverage-7.6.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78b260de9790fd81e69401c2dc8b17da47c8038176a79092a89cb2b7d945d060", size = 239038, upload-time = "2024-08-04T19:44:17.466Z" }, 67 | { url = "https://files.pythonhosted.org/packages/6e/bd/110689ff5752b67924efd5e2aedf5190cbbe245fc81b8dec1abaffba619d/coverage-7.6.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a78d169acd38300060b28d600344a803628c3fd585c912cacc9ea8790fe96862", size = 238690, upload-time = "2024-08-04T19:44:19.336Z" }, 68 | { url = "https://files.pythonhosted.org/packages/d3/a8/08d7b38e6ff8df52331c83130d0ab92d9c9a8b5462f9e99c9f051a4ae206/coverage-7.6.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c09f4ce52cb99dd7505cd0fc8e0e37c77b87f46bc9c1eb03fe3bc9991085388", size = 236765, upload-time = "2024-08-04T19:44:20.994Z" }, 69 | { url = "https://files.pythonhosted.org/packages/d6/6a/9cf96839d3147d55ae713eb2d877f4d777e7dc5ba2bce227167d0118dfe8/coverage-7.6.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6878ef48d4227aace338d88c48738a4258213cd7b74fd9a3d4d7582bb1d8a155", size = 238611, upload-time = "2024-08-04T19:44:22.616Z" }, 70 | { url = "https://files.pythonhosted.org/packages/74/e4/7ff20d6a0b59eeaab40b3140a71e38cf52547ba21dbcf1d79c5a32bba61b/coverage-7.6.1-cp313-cp313-win32.whl", hash = "sha256:44df346d5215a8c0e360307d46ffaabe0f5d3502c8a1cefd700b34baf31d411a", size = 209671, upload-time = "2024-08-04T19:44:24.418Z" }, 71 | { url = "https://files.pythonhosted.org/packages/35/59/1812f08a85b57c9fdb6d0b383d779e47b6f643bc278ed682859512517e83/coverage-7.6.1-cp313-cp313-win_amd64.whl", hash = "sha256:8284cf8c0dd272a247bc154eb6c95548722dce90d098c17a883ed36e67cdb129", size = 210368, upload-time = "2024-08-04T19:44:26.276Z" }, 72 | { url = "https://files.pythonhosted.org/packages/9c/15/08913be1c59d7562a3e39fce20661a98c0a3f59d5754312899acc6cb8a2d/coverage-7.6.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d3296782ca4eab572a1a4eca686d8bfb00226300dcefdf43faa25b5242ab8a3e", size = 207758, upload-time = "2024-08-04T19:44:29.028Z" }, 73 | { url = "https://files.pythonhosted.org/packages/c4/ae/b5d58dff26cade02ada6ca612a76447acd69dccdbb3a478e9e088eb3d4b9/coverage-7.6.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:502753043567491d3ff6d08629270127e0c31d4184c4c8d98f92c26f65019962", size = 208035, upload-time = "2024-08-04T19:44:30.673Z" }, 74 | { url = "https://files.pythonhosted.org/packages/b8/d7/62095e355ec0613b08dfb19206ce3033a0eedb6f4a67af5ed267a8800642/coverage-7.6.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a89ecca80709d4076b95f89f308544ec8f7b4727e8a547913a35f16717856cb", size = 250839, upload-time = "2024-08-04T19:44:32.412Z" }, 75 | { url = "https://files.pythonhosted.org/packages/7c/1e/c2967cb7991b112ba3766df0d9c21de46b476d103e32bb401b1b2adf3380/coverage-7.6.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a318d68e92e80af8b00fa99609796fdbcdfef3629c77c6283566c6f02c6d6704", size = 246569, upload-time = "2024-08-04T19:44:34.547Z" }, 76 | { url = "https://files.pythonhosted.org/packages/8b/61/a7a6a55dd266007ed3b1df7a3386a0d760d014542d72f7c2c6938483b7bd/coverage-7.6.1-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13b0a73a0896988f053e4fbb7de6d93388e6dd292b0d87ee51d106f2c11b465b", size = 248927, upload-time = "2024-08-04T19:44:36.313Z" }, 77 | { url = "https://files.pythonhosted.org/packages/c8/fa/13a6f56d72b429f56ef612eb3bc5ce1b75b7ee12864b3bd12526ab794847/coverage-7.6.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4421712dbfc5562150f7554f13dde997a2e932a6b5f352edcce948a815efee6f", size = 248401, upload-time = "2024-08-04T19:44:38.155Z" }, 78 | { url = "https://files.pythonhosted.org/packages/75/06/0429c652aa0fb761fc60e8c6b291338c9173c6aa0f4e40e1902345b42830/coverage-7.6.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:166811d20dfea725e2e4baa71fffd6c968a958577848d2131f39b60043400223", size = 246301, upload-time = "2024-08-04T19:44:39.883Z" }, 79 | { url = "https://files.pythonhosted.org/packages/52/76/1766bb8b803a88f93c3a2d07e30ffa359467810e5cbc68e375ebe6906efb/coverage-7.6.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:225667980479a17db1048cb2bf8bfb39b8e5be8f164b8f6628b64f78a72cf9d3", size = 247598, upload-time = "2024-08-04T19:44:41.59Z" }, 80 | { url = "https://files.pythonhosted.org/packages/66/8b/f54f8db2ae17188be9566e8166ac6df105c1c611e25da755738025708d54/coverage-7.6.1-cp313-cp313t-win32.whl", hash = "sha256:170d444ab405852903b7d04ea9ae9b98f98ab6d7e63e1115e82620807519797f", size = 210307, upload-time = "2024-08-04T19:44:43.301Z" }, 81 | { url = "https://files.pythonhosted.org/packages/9f/b0/e0dca6da9170aefc07515cce067b97178cefafb512d00a87a1c717d2efd5/coverage-7.6.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b9f222de8cded79c49bf184bdbc06630d4c58eec9459b939b4a690c82ed05657", size = 211453, upload-time = "2024-08-04T19:44:45.677Z" }, 82 | { url = "https://files.pythonhosted.org/packages/a5/2b/0354ed096bca64dc8e32a7cbcae28b34cb5ad0b1fe2125d6d99583313ac0/coverage-7.6.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:e9a6e0eb86070e8ccaedfbd9d38fec54864f3125ab95419970575b42af7541df", size = 198926, upload-time = "2024-08-04T19:45:28.875Z" }, 83 | ] 84 | 85 | [package.optional-dependencies] 86 | toml = [ 87 | { name = "tomli", marker = "python_full_version <= '3.11'" }, 88 | ] 89 | 90 | [[package]] 91 | name = "django" 92 | version = "4.2.17" 93 | source = { registry = "https://pypi.org/simple" } 94 | dependencies = [ 95 | { name = "asgiref" }, 96 | { name = "sqlparse" }, 97 | { name = "tzdata", marker = "sys_platform == 'win32'" }, 98 | ] 99 | sdist = { url = "https://files.pythonhosted.org/packages/63/58/709978ddf7e9393c0a89b57a5edbd764ee76eeea68697af3f77f3820980b/Django-4.2.17.tar.gz", hash = "sha256:6b56d834cc94c8b21a8f4e775064896be3b4a4ca387f2612d4406a5927cd2fdc", size = 10437674, upload-time = "2024-12-04T15:16:33.112Z" } 100 | wheels = [ 101 | { url = "https://files.pythonhosted.org/packages/5e/85/457360cb3de496382e35db4c2af054066df5c40e26df31400d0109a0500c/Django-4.2.17-py3-none-any.whl", hash = "sha256:3a93350214ba25f178d4045c0786c61573e7dbfa3c509b3551374f1e11ba8de0", size = 7993390, upload-time = "2024-12-04T15:16:27.478Z" }, 102 | ] 103 | 104 | [[package]] 105 | name = "django-filter" 106 | version = "24.3" 107 | source = { registry = "https://pypi.org/simple" } 108 | dependencies = [ 109 | { name = "django" }, 110 | ] 111 | sdist = { url = "https://files.pythonhosted.org/packages/50/bc/dc19ae39c235332926dd0efe0951f663fa1a9fc6be8430737ff7fd566b20/django_filter-24.3.tar.gz", hash = "sha256:d8ccaf6732afd21ca0542f6733b11591030fa98669f8d15599b358e24a2cd9c3", size = 144444, upload-time = "2024-08-02T13:27:58.132Z" } 112 | wheels = [ 113 | { url = "https://files.pythonhosted.org/packages/09/b1/92f1c30b47c1ebf510c35a2ccad9448f73437e5891bbd2b4febe357cc3de/django_filter-24.3-py3-none-any.whl", hash = "sha256:c4852822928ce17fb699bcfccd644b3574f1a2d80aeb2b4ff4f16b02dd49dc64", size = 95011, upload-time = "2024-08-02T13:27:55.616Z" }, 114 | ] 115 | 116 | [[package]] 117 | name = "django-lab-inventory" 118 | version = "0.6.0" 119 | source = { editable = "." } 120 | dependencies = [ 121 | { name = "django" }, 122 | { name = "django-filter" }, 123 | { name = "django-widget-tweaks" }, 124 | ] 125 | 126 | [package.dev-dependencies] 127 | dev = [ 128 | { name = "psycopg2-binary" }, 129 | { name = "pytest" }, 130 | { name = "pytest-cov" }, 131 | { name = "pytest-django" }, 132 | { name = "pytest-dotenv" }, 133 | { name = "ruff" }, 134 | ] 135 | 136 | [package.metadata] 137 | requires-dist = [ 138 | { name = "django", specifier = ">=4.2.17" }, 139 | { name = "django-filter", specifier = ">=22.1" }, 140 | { name = "django-widget-tweaks", specifier = ">=1.4.12" }, 141 | ] 142 | 143 | [package.metadata.requires-dev] 144 | dev = [ 145 | { name = "psycopg2-binary", specifier = ">=2.9.10,<3" }, 146 | { name = "pytest", specifier = ">=8.3.3,<9" }, 147 | { name = "pytest-cov", specifier = ">=5.0.0" }, 148 | { name = "pytest-django", specifier = ">=4.9.0" }, 149 | { name = "pytest-dotenv", specifier = ">=0.5.2" }, 150 | { name = "ruff", specifier = ">=0.7.0" }, 151 | ] 152 | 153 | [[package]] 154 | name = "django-widget-tweaks" 155 | version = "1.5.0" 156 | source = { registry = "https://pypi.org/simple" } 157 | sdist = { url = "https://files.pythonhosted.org/packages/a5/fe/26eb92fba83844e71bbec0ced7fc2e843e5990020e3cc676925204031654/django-widget-tweaks-1.5.0.tar.gz", hash = "sha256:1c2180681ebb994e922c754804c7ffebbe1245014777ac47897a81f57cc629c7", size = 14767, upload-time = "2023-08-25T15:29:12.778Z" } 158 | wheels = [ 159 | { url = "https://files.pythonhosted.org/packages/46/6a/6cb6deb5c38b785c77c3ba66f53051eada49205979c407323eb666930915/django_widget_tweaks-1.5.0-py3-none-any.whl", hash = "sha256:a41b7b2f05bd44d673d11ebd6c09a96f1d013ee98121cb98c384fe84e33b881e", size = 8960, upload-time = "2023-08-25T15:29:05.644Z" }, 160 | ] 161 | 162 | [[package]] 163 | name = "exceptiongroup" 164 | version = "1.2.2" 165 | source = { registry = "https://pypi.org/simple" } 166 | sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883, upload-time = "2024-07-12T22:26:00.161Z" } 167 | wheels = [ 168 | { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453, upload-time = "2024-07-12T22:25:58.476Z" }, 169 | ] 170 | 171 | [[package]] 172 | name = "iniconfig" 173 | version = "2.0.0" 174 | source = { registry = "https://pypi.org/simple" } 175 | sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646, upload-time = "2023-01-07T11:08:11.254Z" } 176 | wheels = [ 177 | { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892, upload-time = "2023-01-07T11:08:09.864Z" }, 178 | ] 179 | 180 | [[package]] 181 | name = "packaging" 182 | version = "24.2" 183 | source = { registry = "https://pypi.org/simple" } 184 | sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" } 185 | wheels = [ 186 | { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" }, 187 | ] 188 | 189 | [[package]] 190 | name = "pluggy" 191 | version = "1.5.0" 192 | source = { registry = "https://pypi.org/simple" } 193 | sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955, upload-time = "2024-04-20T21:34:42.531Z" } 194 | wheels = [ 195 | { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556, upload-time = "2024-04-20T21:34:40.434Z" }, 196 | ] 197 | 198 | [[package]] 199 | name = "psycopg2-binary" 200 | version = "2.9.10" 201 | source = { registry = "https://pypi.org/simple" } 202 | sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764, upload-time = "2024-10-16T11:24:58.126Z" } 203 | wheels = [ 204 | { url = "https://files.pythonhosted.org/packages/7a/81/331257dbf2801cdb82105306042f7a1637cc752f65f2bb688188e0de5f0b/psycopg2_binary-2.9.10-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:0ea8e3d0ae83564f2fc554955d327fa081d065c8ca5cc6d2abb643e2c9c1200f", size = 3043397, upload-time = "2024-10-16T11:18:58.647Z" }, 205 | { url = "https://files.pythonhosted.org/packages/e7/9a/7f4f2f031010bbfe6a02b4a15c01e12eb6b9b7b358ab33229f28baadbfc1/psycopg2_binary-2.9.10-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:3e9c76f0ac6f92ecfc79516a8034a544926430f7b080ec5a0537bca389ee0906", size = 3274806, upload-time = "2024-10-16T11:19:03.935Z" }, 206 | { url = "https://files.pythonhosted.org/packages/e5/57/8ddd4b374fa811a0b0a0f49b6abad1cde9cb34df73ea3348cc283fcd70b4/psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ad26b467a405c798aaa1458ba09d7e2b6e5f96b1ce0ac15d82fd9f95dc38a92", size = 2851361, upload-time = "2024-10-16T11:19:07.277Z" }, 207 | { url = "https://files.pythonhosted.org/packages/f9/66/d1e52c20d283f1f3a8e7e5c1e06851d432f123ef57b13043b4f9b21ffa1f/psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:270934a475a0e4b6925b5f804e3809dd5f90f8613621d062848dd82f9cd62007", size = 3080836, upload-time = "2024-10-16T11:19:11.033Z" }, 208 | { url = "https://files.pythonhosted.org/packages/a0/cb/592d44a9546aba78f8a1249021fe7c59d3afb8a0ba51434d6610cc3462b6/psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:48b338f08d93e7be4ab2b5f1dbe69dc5e9ef07170fe1f86514422076d9c010d0", size = 3264552, upload-time = "2024-10-16T11:19:14.606Z" }, 209 | { url = "https://files.pythonhosted.org/packages/64/33/c8548560b94b7617f203d7236d6cdf36fe1a5a3645600ada6efd79da946f/psycopg2_binary-2.9.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f4152f8f76d2023aac16285576a9ecd2b11a9895373a1f10fd9db54b3ff06b4", size = 3019789, upload-time = "2024-10-16T11:19:18.889Z" }, 210 | { url = "https://files.pythonhosted.org/packages/b0/0e/c2da0db5bea88a3be52307f88b75eec72c4de62814cbe9ee600c29c06334/psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32581b3020c72d7a421009ee1c6bf4a131ef5f0a968fab2e2de0c9d2bb4577f1", size = 2871776, upload-time = "2024-10-16T11:19:23.023Z" }, 211 | { url = "https://files.pythonhosted.org/packages/15/d7/774afa1eadb787ddf41aab52d4c62785563e29949613c958955031408ae6/psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2ce3e21dc3437b1d960521eca599d57408a695a0d3c26797ea0f72e834c7ffe5", size = 2820959, upload-time = "2024-10-16T11:19:26.906Z" }, 212 | { url = "https://files.pythonhosted.org/packages/5e/ed/440dc3f5991a8c6172a1cde44850ead0e483a375277a1aef7cfcec00af07/psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e984839e75e0b60cfe75e351db53d6db750b00de45644c5d1f7ee5d1f34a1ce5", size = 2919329, upload-time = "2024-10-16T11:19:30.027Z" }, 213 | { url = "https://files.pythonhosted.org/packages/03/be/2cc8f4282898306732d2ae7b7378ae14e8df3c1231b53579efa056aae887/psycopg2_binary-2.9.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c4745a90b78e51d9ba06e2088a2fe0c693ae19cc8cb051ccda44e8df8a6eb53", size = 2957659, upload-time = "2024-10-16T11:19:32.864Z" }, 214 | { url = "https://files.pythonhosted.org/packages/d0/12/fb8e4f485d98c570e00dad5800e9a2349cfe0f71a767c856857160d343a5/psycopg2_binary-2.9.10-cp310-cp310-win32.whl", hash = "sha256:e5720a5d25e3b99cd0dc5c8a440570469ff82659bb09431c1439b92caf184d3b", size = 1024605, upload-time = "2024-10-16T11:19:35.462Z" }, 215 | { url = "https://files.pythonhosted.org/packages/22/4f/217cd2471ecf45d82905dd09085e049af8de6cfdc008b6663c3226dc1c98/psycopg2_binary-2.9.10-cp310-cp310-win_amd64.whl", hash = "sha256:3c18f74eb4386bf35e92ab2354a12c17e5eb4d9798e4c0ad3a00783eae7cd9f1", size = 1163817, upload-time = "2024-10-16T11:19:37.384Z" }, 216 | { url = "https://files.pythonhosted.org/packages/9c/8f/9feb01291d0d7a0a4c6a6bab24094135c2b59c6a81943752f632c75896d6/psycopg2_binary-2.9.10-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:04392983d0bb89a8717772a193cfaac58871321e3ec69514e1c4e0d4957b5aff", size = 3043397, upload-time = "2024-10-16T11:19:40.033Z" }, 217 | { url = "https://files.pythonhosted.org/packages/15/30/346e4683532011561cd9c8dfeac6a8153dd96452fee0b12666058ab7893c/psycopg2_binary-2.9.10-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1a6784f0ce3fec4edc64e985865c17778514325074adf5ad8f80636cd029ef7c", size = 3274806, upload-time = "2024-10-16T11:19:43.5Z" }, 218 | { url = "https://files.pythonhosted.org/packages/66/6e/4efebe76f76aee7ec99166b6c023ff8abdc4e183f7b70913d7c047701b79/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5f86c56eeb91dc3135b3fd8a95dc7ae14c538a2f3ad77a19645cf55bab1799c", size = 2851370, upload-time = "2024-10-16T11:19:46.986Z" }, 219 | { url = "https://files.pythonhosted.org/packages/7f/fd/ff83313f86b50f7ca089b161b8e0a22bb3c319974096093cd50680433fdb/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b3d2491d4d78b6b14f76881905c7a8a8abcf974aad4a8a0b065273a0ed7a2cb", size = 3080780, upload-time = "2024-10-16T11:19:50.242Z" }, 220 | { url = "https://files.pythonhosted.org/packages/e6/c4/bfadd202dcda8333a7ccafdc51c541dbdfce7c2c7cda89fa2374455d795f/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2286791ececda3a723d1910441c793be44625d86d1a4e79942751197f4d30341", size = 3264583, upload-time = "2024-10-16T11:19:54.424Z" }, 221 | { url = "https://files.pythonhosted.org/packages/5d/f1/09f45ac25e704ac954862581f9f9ae21303cc5ded3d0b775532b407f0e90/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:512d29bb12608891e349af6a0cccedce51677725a921c07dba6342beaf576f9a", size = 3019831, upload-time = "2024-10-16T11:19:57.762Z" }, 222 | { url = "https://files.pythonhosted.org/packages/9e/2e/9beaea078095cc558f215e38f647c7114987d9febfc25cb2beed7c3582a5/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a507320c58903967ef7384355a4da7ff3f28132d679aeb23572753cbf2ec10b", size = 2871822, upload-time = "2024-10-16T11:20:04.693Z" }, 223 | { url = "https://files.pythonhosted.org/packages/01/9e/ef93c5d93f3dc9fc92786ffab39e323b9aed066ba59fdc34cf85e2722271/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6d4fa1079cab9018f4d0bd2db307beaa612b0d13ba73b5c6304b9fe2fb441ff7", size = 2820975, upload-time = "2024-10-16T11:20:11.401Z" }, 224 | { url = "https://files.pythonhosted.org/packages/a5/f0/049e9631e3268fe4c5a387f6fc27e267ebe199acf1bc1bc9cbde4bd6916c/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:851485a42dbb0bdc1edcdabdb8557c09c9655dfa2ca0460ff210522e073e319e", size = 2919320, upload-time = "2024-10-16T11:20:17.959Z" }, 225 | { url = "https://files.pythonhosted.org/packages/dc/9a/bcb8773b88e45fb5a5ea8339e2104d82c863a3b8558fbb2aadfe66df86b3/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:35958ec9e46432d9076286dda67942ed6d968b9c3a6a2fd62b48939d1d78bf68", size = 2957617, upload-time = "2024-10-16T11:20:24.711Z" }, 226 | { url = "https://files.pythonhosted.org/packages/e2/6b/144336a9bf08a67d217b3af3246abb1d027095dab726f0687f01f43e8c03/psycopg2_binary-2.9.10-cp311-cp311-win32.whl", hash = "sha256:ecced182e935529727401b24d76634a357c71c9275b356efafd8a2a91ec07392", size = 1024618, upload-time = "2024-10-16T11:20:27.718Z" }, 227 | { url = "https://files.pythonhosted.org/packages/61/69/3b3d7bd583c6d3cbe5100802efa5beacaacc86e37b653fc708bf3d6853b8/psycopg2_binary-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:ee0e8c683a7ff25d23b55b11161c2663d4b099770f6085ff0a20d4505778d6b4", size = 1163816, upload-time = "2024-10-16T11:20:30.777Z" }, 228 | { url = "https://files.pythonhosted.org/packages/49/7d/465cc9795cf76f6d329efdafca74693714556ea3891813701ac1fee87545/psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0", size = 3044771, upload-time = "2024-10-16T11:20:35.234Z" }, 229 | { url = "https://files.pythonhosted.org/packages/8b/31/6d225b7b641a1a2148e3ed65e1aa74fc86ba3fee850545e27be9e1de893d/psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a", size = 3275336, upload-time = "2024-10-16T11:20:38.742Z" }, 230 | { url = "https://files.pythonhosted.org/packages/30/b7/a68c2b4bff1cbb1728e3ec864b2d92327c77ad52edcd27922535a8366f68/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539", size = 2851637, upload-time = "2024-10-16T11:20:42.145Z" }, 231 | { url = "https://files.pythonhosted.org/packages/0b/b1/cfedc0e0e6f9ad61f8657fd173b2f831ce261c02a08c0b09c652b127d813/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526", size = 3082097, upload-time = "2024-10-16T11:20:46.185Z" }, 232 | { url = "https://files.pythonhosted.org/packages/18/ed/0a8e4153c9b769f59c02fb5e7914f20f0b2483a19dae7bf2db54b743d0d0/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1", size = 3264776, upload-time = "2024-10-16T11:20:50.879Z" }, 233 | { url = "https://files.pythonhosted.org/packages/10/db/d09da68c6a0cdab41566b74e0a6068a425f077169bed0946559b7348ebe9/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e", size = 3020968, upload-time = "2024-10-16T11:20:56.819Z" }, 234 | { url = "https://files.pythonhosted.org/packages/94/28/4d6f8c255f0dfffb410db2b3f9ac5218d959a66c715c34cac31081e19b95/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f", size = 2872334, upload-time = "2024-10-16T11:21:02.411Z" }, 235 | { url = "https://files.pythonhosted.org/packages/05/f7/20d7bf796593c4fea95e12119d6cc384ff1f6141a24fbb7df5a668d29d29/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00", size = 2822722, upload-time = "2024-10-16T11:21:09.01Z" }, 236 | { url = "https://files.pythonhosted.org/packages/4d/e4/0c407ae919ef626dbdb32835a03b6737013c3cc7240169843965cada2bdf/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5", size = 2920132, upload-time = "2024-10-16T11:21:16.339Z" }, 237 | { url = "https://files.pythonhosted.org/packages/2d/70/aa69c9f69cf09a01da224909ff6ce8b68faeef476f00f7ec377e8f03be70/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", size = 2959312, upload-time = "2024-10-16T11:21:25.584Z" }, 238 | { url = "https://files.pythonhosted.org/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", size = 1025191, upload-time = "2024-10-16T11:21:29.912Z" }, 239 | { url = "https://files.pythonhosted.org/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", size = 1164031, upload-time = "2024-10-16T11:21:34.211Z" }, 240 | { url = "https://files.pythonhosted.org/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699, upload-time = "2024-10-16T11:21:42.841Z" }, 241 | { url = "https://files.pythonhosted.org/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245, upload-time = "2024-10-16T11:21:51.989Z" }, 242 | { url = "https://files.pythonhosted.org/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631, upload-time = "2024-10-16T11:21:57.584Z" }, 243 | { url = "https://files.pythonhosted.org/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140, upload-time = "2024-10-16T11:22:02.005Z" }, 244 | { url = "https://files.pythonhosted.org/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762, upload-time = "2024-10-16T11:22:06.412Z" }, 245 | { url = "https://files.pythonhosted.org/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967, upload-time = "2024-10-16T11:22:11.583Z" }, 246 | { url = "https://files.pythonhosted.org/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326, upload-time = "2024-10-16T11:22:16.406Z" }, 247 | { url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712, upload-time = "2024-10-16T11:22:21.366Z" }, 248 | { url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155, upload-time = "2024-10-16T11:22:25.684Z" }, 249 | { url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356, upload-time = "2024-10-16T11:22:30.562Z" }, 250 | { url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224, upload-time = "2025-01-04T20:09:19.234Z" }, 251 | ] 252 | 253 | [[package]] 254 | name = "pytest" 255 | version = "8.3.3" 256 | source = { registry = "https://pypi.org/simple" } 257 | dependencies = [ 258 | { name = "colorama", marker = "sys_platform == 'win32'" }, 259 | { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, 260 | { name = "iniconfig" }, 261 | { name = "packaging" }, 262 | { name = "pluggy" }, 263 | { name = "tomli", marker = "python_full_version < '3.11'" }, 264 | ] 265 | sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487, upload-time = "2024-09-10T10:52:15.003Z" } 266 | wheels = [ 267 | { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341, upload-time = "2024-09-10T10:52:12.54Z" }, 268 | ] 269 | 270 | [[package]] 271 | name = "pytest-cov" 272 | version = "5.0.0" 273 | source = { registry = "https://pypi.org/simple" } 274 | dependencies = [ 275 | { name = "coverage", extra = ["toml"] }, 276 | { name = "pytest" }, 277 | ] 278 | sdist = { url = "https://files.pythonhosted.org/packages/74/67/00efc8d11b630c56f15f4ad9c7f9223f1e5ec275aaae3fa9118c6a223ad2/pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857", size = 63042, upload-time = "2024-03-24T20:16:34.856Z" } 279 | wheels = [ 280 | { url = "https://files.pythonhosted.org/packages/78/3a/af5b4fa5961d9a1e6237b530eb87dd04aea6eb83da09d2a4073d81b54ccf/pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", size = 21990, upload-time = "2024-03-24T20:16:32.444Z" }, 281 | ] 282 | 283 | [[package]] 284 | name = "pytest-django" 285 | version = "4.9.0" 286 | source = { registry = "https://pypi.org/simple" } 287 | dependencies = [ 288 | { name = "pytest" }, 289 | ] 290 | sdist = { url = "https://files.pythonhosted.org/packages/02/c0/43c8b2528c24d7f1a48a47e3f7381f5ab2ae8c64634b0c3f4bd843063955/pytest_django-4.9.0.tar.gz", hash = "sha256:8bf7bc358c9ae6f6fc51b6cebb190fe20212196e6807121f11bd6a3b03428314", size = 84067, upload-time = "2024-09-02T15:49:18.407Z" } 291 | wheels = [ 292 | { url = "https://files.pythonhosted.org/packages/47/fe/54f387ee1b41c9ad59e48fb8368a361fad0600fe404315e31a12bacaea7d/pytest_django-4.9.0-py3-none-any.whl", hash = "sha256:1d83692cb39188682dbb419ff0393867e9904094a549a7d38a3154d5731b2b99", size = 23723, upload-time = "2024-09-02T15:49:17.127Z" }, 293 | ] 294 | 295 | [[package]] 296 | name = "pytest-dotenv" 297 | version = "0.5.2" 298 | source = { registry = "https://pypi.org/simple" } 299 | dependencies = [ 300 | { name = "pytest" }, 301 | { name = "python-dotenv" }, 302 | ] 303 | sdist = { url = "https://files.pythonhosted.org/packages/cd/b0/cafee9c627c1bae228eb07c9977f679b3a7cb111b488307ab9594ba9e4da/pytest-dotenv-0.5.2.tar.gz", hash = "sha256:2dc6c3ac6d8764c71c6d2804e902d0ff810fa19692e95fe138aefc9b1aa73732", size = 3782, upload-time = "2020-06-16T12:38:03.4Z" } 304 | wheels = [ 305 | { url = "https://files.pythonhosted.org/packages/d0/da/9da67c67b3d0963160e3d2cbc7c38b6fae342670cc8e6d5936644b2cf944/pytest_dotenv-0.5.2-py3-none-any.whl", hash = "sha256:40a2cece120a213898afaa5407673f6bd924b1fa7eafce6bda0e8abffe2f710f", size = 3993, upload-time = "2020-06-16T12:38:01.139Z" }, 306 | ] 307 | 308 | [[package]] 309 | name = "python-dotenv" 310 | version = "1.1.1" 311 | source = { registry = "https://pypi.org/simple" } 312 | sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } 313 | wheels = [ 314 | { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, 315 | ] 316 | 317 | [[package]] 318 | name = "ruff" 319 | version = "0.7.4" 320 | source = { registry = "https://pypi.org/simple" } 321 | sdist = { url = "https://files.pythonhosted.org/packages/0b/8b/bc4e0dfa1245b07cf14300e10319b98e958a53ff074c1dd86b35253a8c2a/ruff-0.7.4.tar.gz", hash = "sha256:cd12e35031f5af6b9b93715d8c4f40360070b2041f81273d0527683d5708fce2", size = 3275547, upload-time = "2024-11-15T11:33:11.853Z" } 322 | wheels = [ 323 | { url = "https://files.pythonhosted.org/packages/e6/4b/f5094719e254829766b807dadb766841124daba75a37da83e292ae5ad12f/ruff-0.7.4-py3-none-linux_armv6l.whl", hash = "sha256:a4919925e7684a3f18e18243cd6bea7cfb8e968a6eaa8437971f681b7ec51478", size = 10447512, upload-time = "2024-11-15T11:32:27.812Z" }, 324 | { url = "https://files.pythonhosted.org/packages/9e/1d/3d2d2c9f601cf6044799c5349ff5267467224cefed9b35edf5f1f36486e9/ruff-0.7.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cfb365c135b830778dda8c04fb7d4280ed0b984e1aec27f574445231e20d6c63", size = 10235436, upload-time = "2024-11-15T11:32:30.6Z" }, 325 | { url = "https://files.pythonhosted.org/packages/62/83/42a6ec6216ded30b354b13e0e9327ef75a3c147751aaf10443756cb690e9/ruff-0.7.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:63a569b36bc66fbadec5beaa539dd81e0527cb258b94e29e0531ce41bacc1f20", size = 9888936, upload-time = "2024-11-15T11:32:33.287Z" }, 326 | { url = "https://files.pythonhosted.org/packages/4d/26/e1e54893b13046a6ad05ee9b89ee6f71542ba250f72b4c7a7d17c3dbf73d/ruff-0.7.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d06218747d361d06fd2fdac734e7fa92df36df93035db3dc2ad7aa9852cb109", size = 10697353, upload-time = "2024-11-15T11:32:35.895Z" }, 327 | { url = "https://files.pythonhosted.org/packages/21/24/98d2e109c4efc02bfef144ec6ea2c3e1217e7ce0cfddda8361d268dfd499/ruff-0.7.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e0cea28d0944f74ebc33e9f934238f15c758841f9f5edd180b5315c203293452", size = 10228078, upload-time = "2024-11-15T11:32:40.929Z" }, 328 | { url = "https://files.pythonhosted.org/packages/ad/b7/964c75be9bc2945fc3172241b371197bb6d948cc69e28bc4518448c368f3/ruff-0.7.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80094ecd4793c68b2571b128f91754d60f692d64bc0d7272ec9197fdd09bf9ea", size = 11264823, upload-time = "2024-11-15T11:32:43.31Z" }, 329 | { url = "https://files.pythonhosted.org/packages/12/8d/20abdbf705969914ce40988fe71a554a918deaab62c38ec07483e77866f6/ruff-0.7.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:997512325c6620d1c4c2b15db49ef59543ef9cd0f4aa8065ec2ae5103cedc7e7", size = 11951855, upload-time = "2024-11-15T11:32:46.038Z" }, 330 | { url = "https://files.pythonhosted.org/packages/b8/fc/6519ce58c57b4edafcdf40920b7273dfbba64fc6ebcaae7b88e4dc1bf0a8/ruff-0.7.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00b4cf3a6b5fad6d1a66e7574d78956bbd09abfd6c8a997798f01f5da3d46a05", size = 11516580, upload-time = "2024-11-15T11:32:48.17Z" }, 331 | { url = "https://files.pythonhosted.org/packages/37/1a/5ec1844e993e376a86eb2456496831ed91b4398c434d8244f89094758940/ruff-0.7.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7dbdc7d8274e1422722933d1edddfdc65b4336abf0b16dfcb9dedd6e6a517d06", size = 12692057, upload-time = "2024-11-15T11:32:50.623Z" }, 332 | { url = "https://files.pythonhosted.org/packages/50/90/76867152b0d3c05df29a74bb028413e90f704f0f6701c4801174ba47f959/ruff-0.7.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e92dfb5f00eaedb1501b2f906ccabfd67b2355bdf117fea9719fc99ac2145bc", size = 11085137, upload-time = "2024-11-15T11:32:52.819Z" }, 333 | { url = "https://files.pythonhosted.org/packages/c8/eb/0a7cb6059ac3555243bd026bb21785bbc812f7bbfa95a36c101bd72b47ae/ruff-0.7.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3bd726099f277d735dc38900b6a8d6cf070f80828877941983a57bca1cd92172", size = 10681243, upload-time = "2024-11-15T11:32:55.902Z" }, 334 | { url = "https://files.pythonhosted.org/packages/5e/76/2270719dbee0fd35780b05c08a07b7a726c3da9f67d9ae89ef21fc18e2e5/ruff-0.7.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2e32829c429dd081ee5ba39aef436603e5b22335c3d3fff013cd585806a6486a", size = 10319187, upload-time = "2024-11-15T11:32:58.255Z" }, 335 | { url = "https://files.pythonhosted.org/packages/9f/e5/39100f72f8ba70bec1bd329efc880dea8b6c1765ea1cb9d0c1c5f18b8d7f/ruff-0.7.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:662a63b4971807623f6f90c1fb664613f67cc182dc4d991471c23c541fee62dd", size = 10803715, upload-time = "2024-11-15T11:33:00.88Z" }, 336 | { url = "https://files.pythonhosted.org/packages/a5/89/40e904784f305fb56850063f70a998a64ebba68796d823dde67e89a24691/ruff-0.7.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:876f5e09eaae3eb76814c1d3b68879891d6fde4824c015d48e7a7da4cf066a3a", size = 11162912, upload-time = "2024-11-15T11:33:03.097Z" }, 337 | { url = "https://files.pythonhosted.org/packages/8d/1b/dd77503b3875c51e3dbc053fd8367b845ab8b01c9ca6d0c237082732856c/ruff-0.7.4-py3-none-win32.whl", hash = "sha256:75c53f54904be42dd52a548728a5b572344b50d9b2873d13a3f8c5e3b91f5cac", size = 8702767, upload-time = "2024-11-15T11:33:05.15Z" }, 338 | { url = "https://files.pythonhosted.org/packages/63/76/253ddc3e89e70165bba952ecca424b980b8d3c2598ceb4fc47904f424953/ruff-0.7.4-py3-none-win_amd64.whl", hash = "sha256:745775c7b39f914238ed1f1b0bebed0b9155a17cd8bc0b08d3c87e4703b990d6", size = 9497534, upload-time = "2024-11-15T11:33:07.359Z" }, 339 | { url = "https://files.pythonhosted.org/packages/aa/70/f8724f31abc0b329ca98b33d73c14020168babcf71b0cba3cded5d9d0e66/ruff-0.7.4-py3-none-win_arm64.whl", hash = "sha256:11bff065102c3ae9d3ea4dc9ecdfe5a5171349cdd0787c1fc64761212fc9cf1f", size = 8851590, upload-time = "2024-11-15T11:33:09.664Z" }, 340 | ] 341 | 342 | [[package]] 343 | name = "sqlparse" 344 | version = "0.5.2" 345 | source = { registry = "https://pypi.org/simple" } 346 | sdist = { url = "https://files.pythonhosted.org/packages/57/61/5bc3aff85dc5bf98291b37cf469dab74b3d0aef2dd88eade9070a200af05/sqlparse-0.5.2.tar.gz", hash = "sha256:9e37b35e16d1cc652a2545f0997c1deb23ea28fa1f3eefe609eee3063c3b105f", size = 84951, upload-time = "2024-11-14T10:06:31.941Z" } 347 | wheels = [ 348 | { url = "https://files.pythonhosted.org/packages/7a/13/5f6654c9d915077fae255686ca6fa42095b62b7337e3e1aa9e82caa6f43a/sqlparse-0.5.2-py3-none-any.whl", hash = "sha256:e99bc85c78160918c3e1d9230834ab8d80fc06c59d03f8db2618f65f65dda55e", size = 44407, upload-time = "2024-11-14T10:06:25.268Z" }, 349 | ] 350 | 351 | [[package]] 352 | name = "tomli" 353 | version = "2.1.0" 354 | source = { registry = "https://pypi.org/simple" } 355 | sdist = { url = "https://files.pythonhosted.org/packages/1e/e4/1b6cbcc82d8832dd0ce34767d5c560df8a3547ad8cbc427f34601415930a/tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8", size = 16622, upload-time = "2024-11-11T18:38:01.76Z" } 356 | wheels = [ 357 | { url = "https://files.pythonhosted.org/packages/de/f7/4da0ffe1892122c9ea096c57f64c2753ae5dd3ce85488802d11b0992cc6d/tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391", size = 13750, upload-time = "2024-11-11T18:38:00.19Z" }, 358 | ] 359 | 360 | [[package]] 361 | name = "typing-extensions" 362 | version = "4.12.2" 363 | source = { registry = "https://pypi.org/simple" } 364 | sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321, upload-time = "2024-06-07T18:52:15.995Z" } 365 | wheels = [ 366 | { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438, upload-time = "2024-06-07T18:52:13.582Z" }, 367 | ] 368 | 369 | [[package]] 370 | name = "tzdata" 371 | version = "2024.2" 372 | source = { registry = "https://pypi.org/simple" } 373 | sdist = { url = "https://files.pythonhosted.org/packages/e1/34/943888654477a574a86a98e9896bae89c7aa15078ec29f490fef2f1e5384/tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc", size = 193282, upload-time = "2024-09-23T18:56:46.89Z" } 374 | wheels = [ 375 | { url = "https://files.pythonhosted.org/packages/a6/ab/7e5f53c3b9d14972843a647d8d7a853969a58aecc7559cb3267302c94774/tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd", size = 346586, upload-time = "2024-09-23T18:56:45.478Z" }, 376 | ] 377 | --------------------------------------------------------------------------------