├── .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 |
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 |
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 |
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 |
21 |
22 |
23 |
24 | Name
25 | Placed on
26 | Items
27 | Items Received
28 | Requested by
29 | Account(s)
30 |
31 |
32 | {% for order in order_list %}
33 |
34 | {{ order.name }}
35 | {{ order.placed_on|default_if_none:"—" }}
36 | {{ order.item_count }}
37 | {{ order.item_received_count }}
38 | {{ order.requested_by.get_full_name }}
39 | {{ order.account_codes }}
40 |
41 | {% endfor %}
42 |
43 |
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 |
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 |
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 |
21 |
22 |
23 |
24 | Description
25 | Unit
26 | Vendor
27 | Catalog number
28 | Manufacturer
29 | Part Number
30 | Category
31 |
32 |
33 | {% for item in item_list %}
34 |
35 | {{ item.name }}
36 | {{ item.unit_size }}
37 |
38 | {% if item.vendor.url %}{{ item.vendor }}
39 | {% else %}{{ item.vendor }}{% endif %}
40 |
41 |
42 | {% if item.vendor_url %}{{ item.catalog }}
43 | {% else %}{{ item.catalog }}{% endif %}
44 |
45 |
46 | {% if item.manufacturer.url %}{{ item.manufacturer }}
47 | {% else %}{{ item.manufacturer|default:"" }}{% endif %}
48 |
49 |
50 | {% if item.manufacturer_url %}{{ item.catalog }}
51 | {% else %}{{ item.manufacturer_number|default:"" }}{% endif %}
52 |
53 | {{ item.category }}
54 |
55 | {% endfor %}
56 |
57 |
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 | Description
24 | Vendor
25 | Catalog
26 | Unit
27 | Price
28 | Quantity
29 | Cost
30 | {% if order.placed_on %}
31 | Received
32 | {% endif %}
33 |
34 |
35 |
36 |
37 | {% for oit in orderitem_list %}
38 |
39 | {{ oit.item.name }}
40 |
41 | {% if oit.item.vendor.url %}{{ oit.item.vendor }}
42 | {% else %}{{ oit.item.vendor }}{% endif %}
43 |
44 |
45 | {% if oit.item.vendor_url %}{{ oit.item.catalog }}
46 | {% else %}{{ oit.item.catalog }}{% endif %}
47 |
48 | {{ oit.item.unit_size }}
49 | {{ oit.cost }}
50 | {{ oit.units_purchased }}
51 | {{ oit.total_cost }}
52 | {% if order.placed_on %}
53 | {{ oit.arrived_on|default:"no" }}
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 |
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 | Order Date
50 | Price
51 | Quantity
52 | Total Price
53 | Received
54 | Location
55 | Serial
56 | Equipment #
57 |
58 |
59 |
60 | {% for oit in lineitems.iterator %}
61 |
62 | {{ oit.order.placed_on|default_if_none:"(in progress)" }}
63 | {{ oit.cost }}
64 | {{ oit.units_purchased }}
65 | {{ oit.total_cost }}
66 | {{ oit.arrived_on|default:"no" }}
67 | {{ oit.location|default:"" }}
68 | {{ oit.serial|default:"" }}
69 | {{ oit.uva_equip|default:"" }}
70 |
72 |
73 | {% endfor %}
74 |
75 |
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 |
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 |
--------------------------------------------------------------------------------