44 Commits

Author SHA1 Message Date
3ef9ea3a3b improved ssl lexer 2025-04-13 22:12:14 +02:00
8a676c096a report search errors even on partial success 2025-02-03 22:28:25 +01:00
65f25e282e Merge branch 'develop' into feature/#35-shimatta-search-language 2025-02-03 22:20:16 +01:00
8b3ef1af25 Merge pull request 'feature/#26_enhance_storage_stock_display' (#37) from feature/#26_enhance_storage_stock_display into develop
Reviewed-on: #37
2025-02-03 22:20:05 +01:00
bbcbf6ab3d added basic search lexer and parser enabling search for parameters and attributes - could be extended
the error handling is very basic, but effective
some missing features like ordering
...and maybe a potential security as the search will allow search for all attributes somehow related to the component model
2025-02-03 22:18:55 +01:00
1b48e8f283 added migration adding the help text 2025-02-02 11:52:54 +01:00
6dc8f3bfef added display of sub-storage when expanding sub-storages in stock view 2025-02-01 00:16:36 +01:00
871086c7b7 improved display of dynamic description 2025-01-31 23:59:34 +01:00
adf152938d rounded float values in short string, fixed displaying components without proper type 2025-01-31 23:58:58 +01:00
462ed0c101 added component dynamic description to stock rest api 2025-01-31 23:26:12 +01:00
032c4fc838 added dynamic description to rest api 2025-01-31 23:23:02 +01:00
e0c39d9d6e tweaked short parameter print 2025-01-31 23:22:50 +01:00
6ca0ce483f added template based dynamic description 2025-01-31 23:10:08 +01:00
35b99e1b18 fixed storage search 2025-01-31 22:10:13 +01:00
6ae94e9ea4 made the stock view more ugly by adding badges showing the number of stored parts, lots and substorages 2025-01-28 22:38:17 +01:00
aefcc472ea added feature to relocate stocks to a different storage
...had to deal with form prefixes at some places
...storage search does not work as expected yet :(
2025-01-27 22:55:05 +01:00
7e36059605 Fix another typo in the readme 2025-01-27 19:40:10 +01:00
ed1508f0ed Fix typo in README 2025-01-27 19:33:18 +01:00
2d78b4dcdd Merge pull request 'issue #23 add docu in readme' (#27) from issue/23-add-docu into develop
Reviewed-on: #27
Reviewed-by: Stefan Strobel <stefan.strobel@shimatta.net>
2025-01-27 19:27:24 +01:00
19852dd5ad Fix issues from PR #27. Ready to be merged. 2025-01-27 19:26:31 +01:00
3ec11cf092 typos 2025-01-25 16:23:55 +01:00
e4e7456a5d added link to template in stocks detail view 2025-01-25 15:40:08 +01:00
6eaef98c86 showing stock uuid in stocks detail list 2025-01-25 15:25:26 +01:00
3aa4225acb added form to change stock lot 2025-01-25 15:24:59 +01:00
39b64aeb71 fixed migrations 2025-01-25 15:01:50 +01:00
f6a878460d changed the prefix of stock uuid qr codes 2025-01-25 14:53:22 +01:00
171b6b83f4 added option to expand sub-storage stocks to display all components from sub_storages
this comes in handy for assortment boxes using sub storages for each individual compartment which usually only holds a single stock
2025-01-25 14:50:48 +01:00
550e996ae7 Merge pull request '#21: feature/21-add-package-params' (#32) from feature/21-add-package-params into develop
Reviewed-on: #32
Reviewed-by: Stefan Strobel <stefan.strobel@shimatta.net>
2025-01-05 16:35:42 +01:00
122ae4a731 Merge branch 'develop' into feature/21-add-package-params 2025-01-05 16:11:14 +01:00
0b26a81b94 Add disclaimer 2024-11-25 23:50:41 +01:00
befd5e452f Fix typo 2024-11-25 23:48:49 +01:00
cfb9970c26 Add documentation. Debugging and porting still missing. Initial setup explained 2024-11-25 23:39:15 +01:00
146c2da4f3 Make the container run as root by default, if env variables are missing, to be backwards compatible to old setup 2024-11-25 23:38:37 +01:00
e74a28b0a8 Merge branch 'develop' into issue/23-add-docu 2024-11-25 23:32:23 +01:00
46a94c9688 Merge pull request 'Fix #24: Solve recursion bug when importing templates from a fixture' (#29) from bugfix/24-fix-template-import-recursion-bug into develop
Reviewed-on: #29
2024-11-24 21:25:30 +01:00
21ac4a8c06 Merge pull request 'Fix #30: Update the django filter package. It was incompatible and bricked the web view of the rest API' (#31) from bugfix/30-fix-broken-api-web-rendering into develop
Reviewed-on: #31
2024-11-24 21:23:35 +01:00
207b5c3fb5 Fix #30: Update the django filter package. It was incompatible. 2024-11-23 23:37:10 +01:00
3450f7475a Fix #24: Solve recursion bug when importing templates from a fixture 2024-11-23 22:33:39 +01:00
2fdcfe8baf write docu about user setup 2024-11-23 22:03:28 +01:00
c1b9c966dd Merge branch 'develop' into issue/23-add-docu 2024-11-23 21:59:24 +01:00
08bae61fc0 Merge branch 'develop' into issue/23-add-docu 2024-11-23 01:14:55 +01:00
4ff71d2b21 Fix typo 2024-11-23 01:13:19 +01:00
b47c7ad38d Add readme. Rename example env, to unhide it in the fileexplorer. 2024-11-23 01:10:23 +01:00
63b8a66ebb Add restart policy to autostart the containers after boot 2024-11-23 01:09:57 +01:00
25 changed files with 755 additions and 82 deletions

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "sly"]
path = sly
url = git@git.shimatta.de:sst/sly.git

192
README.md Normal file
View File

@@ -0,0 +1,192 @@
# Shimatta Kenkyusho Parts Database
## Installation
### Prerequisites
Shimatta Kenkyusho (しまった・研究所) is a Django based web application. It is highly recommended to run it using the supplied docker setup. This removes the need of any special installation on the host system. This guide assumes, that `nginx` is running on the host system and can serve as a reverse proxy and webserver. For easiest download, it is recommended to clone the desired release with `git`.
Install the requirements:
**For Debian / Ubuntu:**
```
# apt-get update
# apt-get install docker docker-compose-plugin nginx git
```
**For Arch based Systems:**
```
# pacman -S nginx docker docker-compose git
```
### Setup Shimatta Kenkyusho
Clone this repository:
```
$ git clone https://git.shimatta.de/mhu/shimatta-kenkyusho.git
```
> Note: Shimatta Kenkyusho is currently not stable yet and the newest verison is in the `develop` branch. This will change once actual releases are done and merged to the `master` branch. You will be able to get the latest stable version from the `master` branch or a respective tag. For now, the `develop` is recommended.
Change directory into the `shimatta-kenkyusho` folder cloned by git.
Copy the `example.env` file to `.env` and edit it according to your needs:
The following settings are required to be adapted:
- `DJANGO_STATIC_VOL`: The directory the application will extract its static data into, which needs to be served by your webserver. See the example reverse proxy setup for more details.
- `DJANGO_MEDIA_VOL`: The directory all media files like images uploaded to the application are stored here. This folder must be served by your webserver on the configured media URL.
- `PGDATA_VOL`: The directory, the postgres database will store its files.
- `PORT`: The TCP/IP port that the whole setup will listen on. Use a reverse proxy to forward to this port. *Do not directly expose it to the internet!*
- `DJANGO_SECRET_KEY`: Provide a secret, and randomly generated key. Do not share this with anybody!
- `DJANGO_ALLOWED_HOST`: Set this to the domain, the application will be reached at. E.g: `lab.example.com`
- `DJANGO_MEDIA_URL`: Set this to the media URL at which your webserver serves the `DJANGO_MEDIA_VOL` diretory. E.g: `media.lab.example.com/` Note the **slash at the end**. It is important.
- `DJANGO_USER_ID`: The user ID to run the application inside the docker container. This is the user id, that is used to write the to `DJANGO_STATIC_VOL` and `DJANGO_MEDIA_VOL`. Make sure the user has access.
- `DJANGO_USER_GID`: The group ID to run the application inside the docker container. This is the group id, that is used to write the to `DJANGO_STATIC_VOL` and `DJANGO_MEDIA_VOL`.
> Note: It is not recommended to run the docker container without a set `DJANGO_USER_ID` and `DJANGO_USER_GID`. It will default to `0 (root)`.
Once the environment is set up, the docker containers can be built and started. Run
```
$ docker compose build
```
This will generate two container images:
1. `shimatta-kenkyusho-shimatta-kenkyusho-web`: The django application
2. `postgres`: A alpine based docker container containing the postgres database.
Start the application as a service with
```
$ docker compose up -d
```
> Note: The initial startup might need a minute because the whole database etc. needs to be initialized first.
Use
```
$ docker ps
```
to check if the `shimatta-kenkyusho-shimatta-kenkyusho-db` and the `shimatta-kenkyusho-shimatta-kenkyusho-web` container are running and report a *healthy status*.
### Setup Initial Login User
When started for the first time with a fresh database without any superuser configured, a superuser `admin` with password `admin` will be automatically generated.
Use this user to login for the first time. In the django admin panel you can then either change the password of the `admin` user or create a new superuser with your own username and delete the `admin` user.
As long as there is at least one superuser configured, no admin user will be regenerated upon startup.
### Example Reverse Proxy Setup Using nginx
Once the setup is configured the reverse proxy setup is needed. This setup serves three purposes:
1. Redirect incoming requests to the django application running on the port `PORT` configured in the `.env`
2. Serve static files at the URL: (e.g. `lab.example.com/static`). See `ALLOWED_HOST` configuration.
3. Serve the media volume at the media URL (e.g. `media.lab.example.com`). See `DJANGO_MEDIA_URL`
Example nginx configuration for `nginx >v2.25` with SSL and http2 / http3 support:
> Note: This is by no means a replacement for the documentation of nginx and only serves as an example. Consult the documentation of your nginx version reagrding security and other issues.
```
# Force redirection from http to https for application
server {
listen 80;
listen [::]:80;
server_name lab.example.com; # This must match your ALLOWED_HOST. Adapt domain.
allow all;
return 301 https://lab.example.com$request_uri; # Adapt domain
}
# Force redirection from http to https for media url
server {
listen 80;
listen [::]:80;
server_name media.lab.example.com; # Adapt domain name according to DJANGO_MEDIA_URL
allow all;
return 301 https://media.lab.example.com$request_uri; # Adapt domain name
}
# Reverse Proxy for application
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
# Add this for HTTP3. If your nginx is older than 2.25 this might not be available
######################################################################################
# listen 443 quic reuseport;
# listen [::]:443 quic reuseport;
# Enable QUIC and HTTP/3
# ssl_early_data on;
# add_header Alt-Svc 'h3=":443"; ma=86400';
#######################################################################################
server_name lab.example.com; # Adapt domain
# Use letsencrypt as SSL certificate provider.
ssl_certificate /etc/letsencrypt/live/lab.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/lab.example.com/privkey.pem;
ssl_protocols TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:1m;
ssl_session_timeout 5m;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
allow all;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:8000; # Adapt PORT from .env
}
location /static/ {
# Adapt path to static volume here. Note the slash at the end
alias /path/to/DJANGO_STATIC_VOL/;
allow all;
}
client_max_body_size 60m;
}
# Serve the media files
server {
listen 443 ssl;
listen [::]:443 ssl;
# Add this for HTTP3. If your nginx is older than 2.25 this might not be available
######################################################################################
# listen 443 quic reuseport;
# listen [::]:443 quic reuseport;
# Enable QUIC and HTTP/3
# ssl_early_data on;
# add_header Alt-Svc 'h3=":443"; ma=86400';
#######################################################################################
http2 on;
server_name media.lab.example.com; # Adapt according to DJANGO_MEDIA_URL
# Use letsencrypt as SSL certificate provider.
ssl_certificate /etc/letsencrypt/live/media.lab.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/media.lab.example.com/privkey.pem;
ssl_protocols TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:1m;
ssl_session_timeout 5m;
ssl_ciphers HIGH:!aNULL:!MD5;
error_page 502 /lab_down.html;
allow all;
root /path/to /DJANGO_MEDIA_VOL/; # Adapt this to the volume provided.
}
```
Congratulations. Your shimatta kenkyusho installation is now fully setup.
> Note that, the `compose.yaml` contains a restart-policy. By default the containers will restart automatically, even after a reboot of the host machine, if the docker service is enabled.
## Backup and Restore
> TODO
## Debugging and Development
> Todo

View File

@@ -5,7 +5,7 @@ services:
shimatta-kenkyusho-web:
<<: *restart_policy
build: .
user: "${DJANGO_USER_ID}:${DJANGO_USER_GID}"
user: "${DJANGO_USER_ID:-0}:${DJANGO_USER_GID:-0}"
volumes:
- "${DJANGO_STATIC_VOL:-./run/static}:/var/static"
- "${DJANGO_MEDIA_VOL:-./run/media}:/var/media"

View File

@@ -1,12 +1,13 @@
annotated-types==0.7.0
asgiref==3.8.1
astroid==2.6.5
astroid==3.3.5
certifi==2024.8.30
charset-normalizer==3.4.0
crispy-bootstrap5==2024.10
dill==0.3.9
Django==5.1.3
django-crispy-forms==2.3
django-filter==2.4.0
django-filter==24.3
django-qr-code==4.1.0
django-rest-framework==0.1.0
django-tex==1.1.10
@@ -21,15 +22,17 @@ mccabe==0.6.1
packaging==24.2
Pillow==8.3.1
pipdeptree==2.23.4
platformdirs==4.3.6
psycopg2-binary==2.9.9
pydantic==2.9.2
pydantic_core==2.23.4
pylint==2.9.6
pylint==3.3.1
requests==2.32.3
segno==1.6.1
setuptools==75.3.0
sqlparse==0.4.1
toml==0.10.2
tomlkit==0.13.2
typing_extensions==4.12.2
tzdata==2024.2
urllib3==2.2.3

View File

@@ -44,6 +44,7 @@ class ComponentSerializer(serializers.HyperlinkedModelSerializer):
ro_component_type = serializers.ReadOnlyField(source='component_type.class_name')
ro_parameters = ComponentParameterSerializer(many=True, source='componentparameter_set', read_only=True)
ro_distributor_numbers = ComponentDistributorNumSerializer(many=True, source='distributornum_set', read_only=True)
ro_dynamic_description = serializers.ReadOnlyField(source='dynamic_description')
class Meta:
model = parts_models.Component
@@ -60,12 +61,14 @@ class ComponentSerializer(serializers.HyperlinkedModelSerializer):
'ro_image',
'ro_component_type',
'ro_parameters',
'ro_distributor_numbers']
'ro_distributor_numbers',
'ro_dynamic_description']
class StockSerializer(serializers.HyperlinkedModelSerializer):
id = serializers.ReadOnlyField()
ro_package_name = serializers.ReadOnlyField(source='component.package.name')
ro_component_name = serializers.ReadOnlyField(source='component.name')
ro_component_dynamic_description = serializers.ReadOnlyField(source='component.dynamic_description')
ro_manufacturer_name = serializers.ReadOnlyField(source='component.manufacturer.name')
ro_image = serializers.ReadOnlyField(source='component.get_resolved_image')
class Meta:
@@ -80,7 +83,7 @@ class StorageSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = parts_models.Storage
fields = ['url', 'id', 'name', 'verbose_name', 'parent_storage', 'responsible', 'template', 'full_path']
fields = ['url', 'id', 'name', 'verbose_name', 'parent_storage', 'responsible', 'template', 'full_path', 'full_path_verbose']
class StorageSerializerStocksExpanded(StorageSerializer):
ro_stocks = StockSerializerExpandComponent(many=True, read_only=True, source='stock_set')

View File

@@ -43,8 +43,8 @@ class GroupViewSet(viewsets.ReadOnlyModelViewSet):
class PartsStorageViewSet(viewsets.ModelViewSet):
queryset = parts_models.Storage.objects.all()
permission_classes = [permissions.DjangoModelPermissions]
filter_backends = [django_filters.rest_framework.DjangoFilterBackend]
search_fields = ['id', 'name', 'parent_storage']
filter_backends = [django_filters.rest_framework.DjangoFilterBackend, filters.SearchFilter]
search_fields = ['id', 'name', 'verbose_name']
filterset_fields = ['id', 'name', 'parent_storage']
def get_serializer_class(self):

View File

@@ -53,7 +53,7 @@ class AutoCompleteWidget(widgets.Input):
'name_field_name': self.name_field_name,
'prepend': self.prepend,
'name': display_name,
}
}
return context
class AutocompleteForeingKeyField(forms.UUIDField):
@@ -72,7 +72,7 @@ class AutocompleteForeingKeyField(forms.UUIDField):
prepend)
self.foreign_model = foreign_model
def clean(self, value):
try:
pre_cleaned_uuid = super().clean(value)
@@ -101,7 +101,8 @@ class ChangeStorageForm(forms.Form):
foreign_model=get_user_model(),
prepend='@')
is_template = forms.BooleanField(label='is_template', required=False)
expand_sub_storage_stocks = forms.BooleanField(label='Expand sub storage Stocks', required=False)
is_template = forms.BooleanField(label='Is template', required=False)
class AddSubStorageForm(ChangeStorageForm):
template = AutocompleteForeingKeyField(api_search_url='storage-template-list',
@@ -112,15 +113,13 @@ class AddSubStorageForm(ChangeStorageForm):
class DeleteStockForm(forms.Form):
stock_uuid = forms.UUIDField()
class EditWatermarkForm(forms.Form):
class EditStockBaseForm(forms.Form):
stock_uuid = forms.UUIDField()
watermark_active = forms.BooleanField(required=False) #If it is false, the webbrowser won't send it at all. Therefore we have to set it to required=False
watermark = forms.IntegerField(min_value=0)
def clean(self):
cleaned_data = super().clean()
id = cleaned_data.get("stock_uuid")
if not id:
raise ValidationError("No stock UUID given")
@@ -133,6 +132,10 @@ class EditWatermarkForm(forms.Form):
return cleaned_data
class EditWatermarkForm(EditStockBaseForm):
watermark_active = forms.BooleanField(required=False) #If it is false, the webbrowser won't send it at all. Therefore we have to set it to required=False
watermark = forms.IntegerField(min_value=0)
def save(self):
stock = self.cleaned_data['stock']
active = self.cleaned_data['watermark_active']
@@ -144,32 +147,36 @@ class EditWatermarkForm(forms.Form):
stock.watermark = watermark
stock.save()
class EditStockAmountForm(forms.Form):
stock_uuid = forms.UUIDField()
class EditLotForm(EditStockBaseForm):
lot = forms.IntegerField(min_value=0)
def save(self):
stock = self.cleaned_data['stock']
lot = self.cleaned_data['lot']
stock.lot = lot
stock.save()
class RelocateStockForm(forms.ModelForm):
storage = AutocompleteForeingKeyField(api_search_url='storage-list',
foreign_model=parts_models.Storage,
name_field_name='full_path_verbose',
image_field_name=None,
required=True)
class Meta:
model = parts_models.Stock
fields = ['storage']
class EditStockAmountForm(EditStockBaseForm):
amount = forms.IntegerField(min_value=0)
def clean(self):
cleaned_data = super().clean()
id = cleaned_data.get("stock_uuid")
if not id:
raise ValidationError("No stock UUID given")
stock = None
try:
stock = parts_models.Stock.objects.get(id=id)
except:
raise ValidationError("Stock with uuid %s does not exist" % (id))
cleaned_data['stock'] = stock
return cleaned_data
def save(self, increase: bool):
stock = self.cleaned_data['stock']
amount = self.cleaned_data['amount']
if not increase:
amount = -amount
amount = -amount
return stock.atomic_increment(amount)
@@ -194,7 +201,7 @@ class AddStockForm(forms.Form):
cleaned_data['component'] = component
return cleaned_data
def save(self, storage):
component = self.cleaned_data.get('component')
amount = self.cleaned_data.get('amount')
@@ -253,7 +260,7 @@ class DistributorNumberDeleteForm(forms.Form):
class ComponentParameterDeleteForm(forms.Form):
param_num = forms.UUIDField(required=True)
model = parts_models.ComponentParameter
def clean_param_num(self):
my_uuid = self.cleaned_data['param_num']
try:
@@ -322,7 +329,7 @@ class ComponentParameterCreateForm(forms.Form):
if not ptype:
raise ValidationError('No valid parameter type selected')
if not value:
raise ValidationError('No valid parameter value')
@@ -334,7 +341,7 @@ class ComponentParameterCreateForm(forms.Form):
data['number_value'] = number
else:
pass
def save(self, component):
param_type = self.cleaned_data['parameter_type']
if param_type.parameter_type == 'F':
@@ -363,6 +370,6 @@ class QrSearchForm(forms.Form):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
qr_search = forms.CharField(label='qr_search', validators=[my_qr_validator])

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.3 on 2025-01-25 14:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('parts', '0013_packageparameter'),
]
operations = [
migrations.AddField(
model_name='storage',
name='expand_sub_storage_stocks',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.3 on 2025-01-31 21:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('parts', '0014_storage_expand_sub_storage_stocks'),
]
operations = [
migrations.AddField(
model_name='componenttype',
name='description_template',
field=models.TextField(blank=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.3 on 2025-01-31 21:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('parts', '0015_componenttype_description_template'),
]
operations = [
migrations.AddField(
model_name='componentparametertype',
name='interfix',
field=models.CharField(blank=True, max_length=10),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.1.3 on 2025-02-02 10:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('parts', '0016_componentparametertype_interfix'),
]
operations = [
migrations.AlterField(
model_name='componentparametertype',
name='interfix',
field=models.CharField(blank=True, help_text='char to be used as decimal point in dynamic description eg. 2R2', max_length=10),
),
migrations.AlterField(
model_name='componenttype',
name='description_template',
field=models.TextField(blank=True, help_text="Template to assemble the dynamic description. Use template syntax, access the component with 'object', parameters with 'param_*'."),
),
]

View File

@@ -6,6 +6,7 @@ from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.dispatch import receiver
from django.core.validators import MinValueValidator, MaxValueValidator
from django.template import engines
import os
import uuid
from shimatta_modules.EngineeringNumberConverter import EngineeringNumberConverter as NumConv
@@ -29,6 +30,7 @@ class ComponentParameterType(models.Model):
parameter_name = models.CharField(max_length=50, unique=True)
parameter_description = models.TextField(null=False, blank=True)
unit = models.CharField(max_length=10, null=False, blank=True)
interfix = models.CharField(max_length=10, null=False, blank=True, help_text="char to be used as decimal point in dynamic description eg. 2R2")
parameter_type = models.CharField(max_length=1, choices=TYPE_CHOICES, default='N')
def __str__(self):
@@ -49,6 +51,9 @@ class ComponentType(models.Model):
class_name = models.CharField(max_length=50, unique=True)
passive = models.BooleanField()
possible_parameter = models.ManyToManyField(ComponentParameterType, blank=True)
description_template = models.TextField(blank=True,
help_text="Template to assemble the dynamic description. "
"Use template syntax, access the component with 'object', parameters with 'param_*'.")
def __str__(self):
return '[' + self.class_name + ']'
@@ -65,6 +70,7 @@ class Storage(models.Model):
verbose_name = models.CharField(max_length=100, null=True, blank=True)
parent_storage = models.ForeignKey('self', on_delete=models.CASCADE, blank=True, null=True)
responsible = models.ForeignKey(get_user_model(), on_delete=models.SET_NULL, blank=True, null=True)
expand_sub_storage_stocks = models.BooleanField(default=False)
# allow storages to be templates which can be selected when adding new storages
is_template = models.BooleanField(default=False)
@@ -74,25 +80,40 @@ class Storage(models.Model):
null=True,
related_name='template_of')
def get_path_components(self):
# caching variable for subtrees
storage_list = None
def get_path_components(self, top_level=None):
chain = []
iterator = self
chain.append(self)
while iterator.parent_storage is not None:
if top_level and iterator.parent_storage == top_level:
break
chain.append(iterator.parent_storage)
iterator = iterator.parent_storage
return chain
def get_full_path(self):
def get_full_path(self, top_level=None):
output = ''
chain = self.get_path_components()
chain = self.get_path_components(top_level)
for i in range(len(chain) - 1, -1, -1):
output = output + '/' + chain[i].name
return output
@property
def full_path_verbose(self):
full_path = f'{self.get_full_path()} ({self.id})'
if self.verbose_name:
full_path += f' ({self.verbose_name})'
return full_path
def get_qr_code(self):
qrdata = '[stor_uuid]' + str(self.id)
return qrdata
@@ -106,6 +127,19 @@ class Storage(models.Model):
else:
return self.storage_set.all()
@classmethod
def get_substorage_list(cls, sub_storages):
sub_sub_storages = cls.objects.filter(parent_storage__in=sub_storages)
final_sub_storages = sub_storages | sub_sub_storages
if sub_sub_storages:
final_sub_storages |= cls.get_substorage_list(sub_sub_storages)
return final_sub_storages
def get_storage_list(self):
return Storage.objects.filter(id=self.id) | self.get_substorage_list(self.storage_set.all())
def validate_unique(self, exclude=None):
if Storage.objects.exclude(id=self.id).filter(name=self.name, parent_storage__isnull=True).exists():
if self.parent_storage is None:
@@ -116,12 +150,18 @@ class Storage(models.Model):
raise
def get_total_stock_amount(self):
stocks = Stock.objects.filter(storage=self)
stocks = Stock.objects.filter(storage__in=self.storage_list or self.get_storage_list())
sum = stocks.aggregate(Sum('amount'))['amount__sum']
if sum is None:
sum = 0
return sum
def get_total_stock_count(self):
return Stock.objects.filter(storage__in=self.storage_list or self.get_storage_list()).count()
def get_total_substorage_amount(self):
return len(self.storage_list or self.get_storage_list()) - 1 # -1 as thhe storage list counts the parent storage as well
@classmethod
def from_path(cls, path, root_storage=None):
'''
@@ -237,6 +277,22 @@ class Component(models.Model):
if sum is None:
sum = 0
return sum
@property
def dynamic_description(self):
if not self.component_type or not self.component_type.description_template:
return ''
django_engine = engines["django"]
template = django_engine.from_string(self.component_type.description_template)
parameters = list(ComponentParameter.objects.filter(component=self))
parameters += list(PackageParameter.objects.filter(package=self.package))
context = {f'param_{param.parameter_type.parameter_name}': param for param in parameters}
context.update({'object': self})
return template.render(context)
class AbstractParameter(models.Model):
class Meta:
@@ -271,6 +327,25 @@ class AbstractParameter(models.Model):
elif my_type == 'F':
return self.text_value
def resolved_value_as_short_string(self):
my_type = self.parameter_type.parameter_type
if my_type == 'E' or my_type == 'I':
# Engineering float number
(num, prefix) = NumConv.number_to_engineering(self.value, it_unit=(True if my_type=='I' else False))
result = f'{round(num, 3):g}'
interpostfix = (prefix if prefix else self.parameter_type.interfix or '.')
if '.' in result:
result = result.replace('.', interpostfix)
else:
result = result + interpostfix
return result
elif my_type == 'N':
# Standard float number
return f'{round(self.value, 3):g}{self.parameter_type.unit}'
else:
return self.resolved_value_as_string()
class ComponentParameter(AbstractParameter):
class Meta:
unique_together = ('component', 'parameter_type')
@@ -308,7 +383,7 @@ class Stock(models.Model):
return True
def get_qr_code(self):
qr_data = '[stock]'+str(self.id)
qr_data = '[stck_uuid]'+str(self.id)
return qr_data
def __str__(self):
@@ -395,11 +470,18 @@ def auto_apply_template_structure(sender, instance, created, **kwargs):
If there are nested sub-storages these will be added when the sub-storages
are created automatically.
"""
# Skip recursion if the model is saved 'raw' e.g. when imported
if 'raw' in kwargs:
if kwargs['raw']:
return
if created:
if instance.template:
for sub_storage in instance.template.storage_set.all():
Storage.objects.create(name=sub_storage.name,
parent_storage=instance,
responsible=instance.responsible,
expand_sub_storage_stocks=instance.expand_sub_storage_stocks,
is_template=False,
template=sub_storage)

View File

@@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError, ObjectDoesNotExist
from django.urls import reverse as url_reverse
import re
from .models import Storage, Component
from .models import Storage, Component, Stock
class QrCode:
prefix = ''
@@ -19,6 +19,7 @@ class QrCodeValidator:
qr_patterns = {
'stor_uuid': QrCode('stor_uuid', 'parts-stocks-detail', Storage),
'comp_uuid': QrCode('comp_uuid', 'parts-components-detail', Component),
'stck_uuid': QrCode('stck_uuid', 'parts-stock-detail', Stock),
}
def __init__(self):
@@ -32,16 +33,13 @@ class QrCodeValidator:
qr_type = matches.group('prefix')
qr_uuid = matches.group('uuid')
url_name = self.qr_patterns[qr_type].detail_view
model = None
try:
url_name = self.qr_patterns[qr_type].detail_view
model = self.qr_patterns[qr_type].model
except:
model = None
if model is None:
raise ValidationError('QR Pattern not registered')
return (model,qr_uuid, url_name)
except KeyError as ex:
raise ValidationError('QR Pattern not registered') from ex
return (model, qr_uuid, url_name)
def get_redirect_url(self, data):
model, uuid, url_name = self._get_model_from_qr(data)

View File

@@ -0,0 +1,9 @@
import datetime
from django import template
register = template.Library()
@register.filter(name="get_relative_storage_path")
def get_relative_storage_path(storage, top_level):
return f'.{storage.get_full_path(top_level)}'

View File

@@ -1,4 +1,3 @@
import uuid
from django.shortcuts import redirect
from django.urls import reverse
from django.views.generic import TemplateView, DetailView
@@ -8,6 +7,7 @@ from django.db.models import Q
from django.forms import formset_factory
from django.db import IntegrityError
from django.db.models import ProtectedError
from shimatta_modules.ShimattaSearchLanguage import ShimattaSearchLanguage
from ..models import Stock, Component, ComponentParameter, DistributorNum, PackageParameter
from ..forms import *
from .component_import import import_components_from_csv
@@ -26,15 +26,23 @@ class ComponentView(LoginRequiredMixin, BaseTemplateMixin, TemplateView):
def get_component_query_set(self, search_string):
queryset = Component.objects.all()
error_string = ''
if not search_string:
return queryset
return queryset, error_string
search_parser = ShimattaSearchLanguage()
query, errors = search_parser.search_for_components(search_string)
search_fragments = search_string.strip().split()
for search in search_fragments:
queryset = queryset.filter(Q(name__icontains = search) | Q(manufacturer__name__icontains = search) | Q(package__name__icontains = search))
if query and not errors:
try:
queryset = queryset.filter(query)
except Exception as ex:
error_string = str(ex)
else:
error_string = '<br><br>'.join(errors)
return queryset
return queryset, error_string
def get_component_queryset_from_advanced_search(self, cleaned_data):
queryset = Component.objects.all()
@@ -60,6 +68,8 @@ class ComponentView(LoginRequiredMixin, BaseTemplateMixin, TemplateView):
def get_context_data_int(self, advanced_search, parameter_formset : ParameterSearchFormSet, **kwargs):
context = super().get_context_data(**kwargs)
errors = ''
comp_page_num = self.request.GET.get('comp_page', default=1)
if advanced_search and parameter_formset:
@@ -79,7 +89,7 @@ class ComponentView(LoginRequiredMixin, BaseTemplateMixin, TemplateView):
else:
search = self.request.GET.get('search', default=None)
paginator_queryset = self.get_component_query_set(search)
paginator_queryset, errors = self.get_component_query_set(search)
comp_paginator = Paginator(paginator_queryset, self.default_page_size)
@@ -87,11 +97,11 @@ class ComponentView(LoginRequiredMixin, BaseTemplateMixin, TemplateView):
context['comp_form'] = ComponentForm()
context['import_comp_form'] = ImportComponentForm()
context['search_string'] = search
context['errors'] = errors
if not parameter_formset:
context['advanced_search_param_formset'] = ParameterSearchFormSet()
if not advanced_search:
context['advanced_search_form'] = AdvancedComponentSearchForm(auto_id='adv_search_%s')

View File

@@ -74,7 +74,13 @@ class StockViewDetail(LoginRequiredMixin, BaseTemplateMixin, DetailView):
return crumbs
def search_stock_queryset(self, search):
stocks_in_storage = Stock.objects.filter(storage=self.object).order_by(Lower('component__name'))
if self.object.expand_sub_storage_stocks:
stocks_in_storage = Stock.objects.filter(storage__in=self.object.get_storage_list())
else:
stocks_in_storage = Stock.objects.filter(storage=self.object)
stocks_in_storage = stocks_in_storage.order_by(Lower('component__name'))
if search is None or search == '':
return stocks_in_storage
@@ -115,14 +121,16 @@ class StockViewDetail(LoginRequiredMixin, BaseTemplateMixin, DetailView):
context['storages'] = storage_paginator.get_page(storage_page)
stocks = stock_paginator.get_page(componente_stock_page)
context['stocks'] = stocks
context['stocks_with_forms'] = [{'object': s, 'relocate_form': RelocateStockForm(instance=s, prefix=str(s.id))} for s in stocks]
context['stock_search'] = stock_search_input
add_storage_form = AddSubStorageForm()
add_storage_form.fields['responsible'].initial = self.request.user.id
context['add_storage_form'] = add_storage_form
change_storage_form = ChangeStorageForm()
change_storage_form = ChangeStorageForm(prefix='change_storage')
change_storage_form.fields['storage_name'].initial = self.object.name
change_storage_form.fields['verbose_name'].initial = self.object.verbose_name
change_storage_form.fields['responsible'].initial = self.object.responsible.id
change_storage_form.fields['expand_sub_storage_stocks'].initial = self.object.expand_sub_storage_stocks
change_storage_form.fields['is_template'].initial = self.object.is_template
context['change_storage_form'] = change_storage_form
context['delete_storage_error'] = None
@@ -139,6 +147,7 @@ class StockViewDetail(LoginRequiredMixin, BaseTemplateMixin, DetailView):
verbose_name=f.cleaned_data.get('verbose_name'),
parent_storage=self.object,
responsible=f.cleaned_data['responsible'],
expand_sub_storage_stocks=f.cleaned_data['expand_sub_storage_stocks'],
is_template=f.cleaned_data['is_template'],
template=f.cleaned_data.get('template'))
except ValidationError as v_err:
@@ -148,12 +157,13 @@ class StockViewDetail(LoginRequiredMixin, BaseTemplateMixin, DetailView):
return self.render_to_response(context)
def handle_change_storage_post(self, request, **kwargs):
f = ChangeStorageForm(data=request.POST)
f = ChangeStorageForm(data=request.POST, prefix='change_storage')
if f.is_valid():
try:
self.object.name = f.cleaned_data['storage_name']
self.object.verbose_name = f.cleaned_data.get('verbose_name')
self.object.responsible = f.cleaned_data['responsible']
self.object.expand_sub_storage_stocks = f.cleaned_data['expand_sub_storage_stocks']
self.object.is_template = f.cleaned_data['is_template']
self.object.save()
except ValidationError as v_err:
@@ -205,6 +215,27 @@ class StockViewDetail(LoginRequiredMixin, BaseTemplateMixin, DetailView):
context = self.get_context_data(**kwargs)
return self.render_to_response(context)
def handle_update_lot(self, request, **kwargs):
edit_form = EditLotForm(data=request.POST)
if edit_form.is_valid():
edit_form.save()
else:
pass # Todo: Handle error
context = self.get_context_data(**kwargs)
return self.render_to_response(context)
def handle_relocate_stock(self, request, **kwargs):
instance = Stock.objects.get(id=request.POST['prefix'])
edit_form = RelocateStockForm(instance=instance, data=request.POST, prefix=request.POST['prefix'])
if edit_form.is_valid():
edit_form.save()
else:
pass # Todo: Handle error
context = self.get_context_data(**kwargs)
return self.render_to_response(context)
def handle_amount_change_post(self, request, increase, **kwargs):
edit_form = EditStockAmountForm(data=request.POST)
if edit_form.is_valid():
@@ -244,6 +275,10 @@ class StockViewDetail(LoginRequiredMixin, BaseTemplateMixin, DetailView):
return self.handle_del_stock_post(request, **kwargs)
elif 'submit-edit-watermark' in request.POST:
return self.handle_update_watermark(request, **kwargs)
elif 'submit-edit-lot' in request.POST:
return self.handle_update_lot(request, **kwargs)
elif 'submit-relocate-stock' in request.POST:
return self.handle_relocate_stock(request, **kwargs)
elif 'submit-amount-reduce' in request.POST:
return self.handle_amount_change_post(request, False, **kwargs)
elif 'submit-amount-increase' in request.POST:

View File

@@ -0,0 +1,184 @@
import re
from django.db.models import Q
from sly import Lexer, Parser
from shimatta_modules.EngineeringNumberConverter import EngineeringNumberConverter
class ShimattaSearchConstants():
'''
Just a bunch of conversions and regular expression stored here.
'''
# convert the prefixes from the engineering number converter be used in the lexer
PREFIX_DICT = {k: v for k, v in EngineeringNumberConverter.prefixes if k}
PREFIX_DICT.update({k: v for k, v in EngineeringNumberConverter.it_prefixes if k})
PREFIX_RULE = r'(' + r'|'.join([rf'(?:{p})' for p in PREFIX_DICT.keys()]) + r')?'
TEXTUAL_REGEX = r'(?:([^"><=!&\|\(\)\s]+))|(?:"([^"]*)")'
VALUE_REGEX = rf'(\-?\d+(?:\.\d+)?){PREFIX_RULE}'
TEXTUAL_PATTERN = re.compile(TEXTUAL_REGEX)
VALUE_PATTERN = re.compile(VALUE_REGEX)
class ShimattaSearchLexer(Lexer):
'''
Stupid lexer to tokenize a search string.
'''
tokens = {GT, LT, GTE, LTE, EQ, NEQ, AND, OR, LPAREN, RPAREN, NUMBER, TEXTUAL}
# ignore whitespace only characters as well as newlines
ignore = ' \t\n'
# Regular expression rules for simple tokens
GTE = r'>='
LTE = r'<='
GT = r'>'
LT = r'<'
EQ = r'=='
NEQ = r'!='
AND = r'(?:&{1,2})|(?:and)|(?:AND)'
OR = r'(?:\|{1,2})|(?:or)|(?:OR)'
LPAREN = r'\('
RPAREN = r'\)'
def __init__(self):
self.errors = []
super().__init__()
@_(ShimattaSearchConstants.VALUE_REGEX)
def NUMBER(self, t):
'''
Parse numbers with engineering unit prefixes
'''
match = ShimattaSearchConstants.VALUE_PATTERN.match(t.value)
t.value = float(match.group(1))
prefix = match.group(2)
if prefix:
t.value *= ShimattaSearchConstants.PREFIX_DICT[prefix]
return t
@_(ShimattaSearchConstants.TEXTUAL_REGEX)
def TEXTUAL(self, t):
'''
Find texts with or without param_ prefix (used to filter for parameters)
'''
match = ShimattaSearchConstants.TEXTUAL_PATTERN.match(t.value)
# strip the quotation marks
value = match.group(1)
if match.group(2):
value = match.group(2)
t.value = value
return t
def error(self, t):
self.errors.append(f'Line {self.lineno}: Bad character {t.value}')
self.index += 1
class ShimattaSearchParser(Parser):
# Get the token list from the lexer (required)
tokens = ShimattaSearchLexer.tokens
def __init__(self):
self.errors = []
super().__init__()
@staticmethod
def _get_filter(key, value, compare_suffix='', invert=False):
'''
Assemble a filter to grep data from the relational database structure
'''
# filter for params - stored in two separate tables
if key.startswith('param_'):
key = key[len('param_'):]
key_query = Q(Q(**{f'componentparameter__parameter_type__parameter_name': key})| \
Q(**{f'package__packageparameter__parameter_type__parameter_name': key}))
if isinstance(value, str):
query = Q(Q(**{f'componentparameter__text_value{compare_suffix}': value})| \
Q(**{f'package__packageparameter__text_value{compare_suffix}': value}))&key_query
else:
query = Q(Q(**{f'componentparameter__value{compare_suffix}': value})| \
Q(**{f'package__packageparameter__value{compare_suffix}': value}))&key_query
# filter for direct attributes - or whatever the user throws into the search input
else:
query = Q(**{f'{key}{compare_suffix}': value})
if invert:
query = ~query
return Q(query)
# ruleset
@_('expression : textual GT number')
def expression(self, p):
return self._get_filter(p.textual.strip(), p.number, '__gt', False)
@_('expression : textual LT number')
def expression(self, p):
return self._get_filter(p.textual.strip(), p.number, '__lt', False)
@_('expression : textual GTE number')
def expression(self, p):
return self._get_filter(p.textual.strip(), p.number, '__gte', False)
@_('expression : textual LTE number')
def expression(self, p):
return self._get_filter(p.textual.strip(), p.number, '__lte', False)
@_('expression : textual EQ number')
def expression(self, p):
return self._get_filter(p.textual.strip(), p.number, '', False)
@_('expression : textual NEQ number')
def expression(self, p):
return self._get_filter(p.textual.strip(), p.number, '', True)
@_('expression : textual EQ textual')
def expression(self, p):
return self._get_filter(p.textual0.strip(), p.textual1, '', False)
@_('expression : textual NEQ textual')
def expression(self, p):
return self._get_filter(p.textual0.strip(), p.textual1, '', True)
@_('TEXTUAL')
def textual(self, p):
return p.TEXTUAL
@_('NUMBER')
def number(self, p):
return p.NUMBER
@_('expression : LPAREN expression RPAREN')
def expression(self, p):
return Q(p.expression)
@_('expression : expression AND expression')
def expression(self, p):
return p.expression0&p.expression1
@_('expression : expression OR expression')
def expression(self, p):
return p.expression0|p.expression1
@_('expression')
def expression(self, p):
return p.expression
# Error rule for syntax errors
def error(self, p):
self.errors.append(f'Syntax error in input {p}!')
class ShimattaSearchLanguage():
def __init__(self):
self.lexer = ShimattaSearchLexer()
self.parser = ShimattaSearchParser()
def search_for_components(self, search_string):
query = self.parser.parse(self.lexer.tokenize(search_string))
return query, self.lexer.errors + self.parser.errors

View File

@@ -83,6 +83,13 @@
const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]')
const popoverList = [...popoverTriggerList].map(popoverTriggerEl => new bootstrap.Popover(popoverTriggerEl))
</script>
<!-- Initialize bootstrap tooltips -->
<script type="text/javascript">
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
const tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})
</script>
<!-- Select search field on start of QR scan if no input is currently selevted([) -->
<script type="text/javascript">
window.addEventListener("keydown", (event)=>{

View File

@@ -76,6 +76,11 @@
No description available
</div>
{% endif %}
{% if component.dynamic_description %}
<h2>Dynamic Description</h2>
<pre>{{ component.dynamic_description }}</pre>
{% endif %}
</div>
<div class="col-4">
{% if component.pref_distri %}

View File

@@ -6,16 +6,26 @@
<div class="row">
<div class="col-md">
<h2>Components</h2>
<form action="" method="get">
<div class="input-group mb-3">
<input class="form-control" name="search" type="search" placeholder="Search Component..." {% if search_string %}value="{{search_string}}"{% endif %}>
<button type="submit" class="btn btn-primary">
<i class="bi bi-search"></i>
</button>
<button class="btn btn-secondary" type="button" data-bs-toggle="collapse" href="#advanced-search-collapse">Advanced <i class="bi bi-search"></i></button>
<button class="btn btn-success" type="button" data-bs-toggle="modal" data-bs-target="#comp-edit-modal"><i class="bi bi-plus-circle"></i> Add Component</button>
<button class="btn btn-success" type="button" data-bs-toggle="modal" data-bs-target="#comp-import-modal"><i class="bi bi-plus-circle"></i> Import CSV</button>
<form class="needs-validation" action="" method="get">
{% if errors %}
<div class="card mb-3">
{% endif %}
<div class="input-group mb-3">
<input class="form-control" name="search" type="search" placeholder="Search Component..." {% if search_string %}value="{{search_string}}"{% endif %}>
<button type="submit" class="btn btn-primary">
<i class="bi bi-search"></i>
</button>
<button class="btn btn-secondary" type="button" data-bs-toggle="collapse" href="#advanced-search-collapse">Advanced <i class="bi bi-search"></i></button>
<button class="btn btn-success" type="button" data-bs-toggle="modal" data-bs-target="#comp-edit-modal"><i class="bi bi-plus-circle"></i> Add Component</button>
<button class="btn btn-success" type="button" data-bs-toggle="modal" data-bs-target="#comp-import-modal"><i class="bi bi-plus-circle"></i> Import CSV</button>
</div>
{% if errors %}
<div class="card-body text-danger">
<h5>Error in search query:</h5>
{{ errors }}
</div>
</div>
{% endif %}
</form>
<div class="collapse mb-3{% if advanced_search_shown %} show{% endif %}" id="advanced-search-collapse" aria-expanded="{% if advanced_search_shown %}true{% else %}false{% endif %}">
<form method="POST">
@@ -54,7 +64,12 @@
Manufacturer: {{comp.manufacturer}}
{% endif %}
</div>
<span class="badge bg-primary rounded-pill">{{comp.get_total_amount}}</span>
<div class="flex-grow-1 d-block ms-3" style="text-align: right;">
<pre>{{ comp.dynamic_description }}</pre>
</div>
<div style="width: 10%; text-align: right;">
<span class="badge bg-primary rounded-pill me-2">{{comp.get_total_amount}}</span>
</div>
</li>
</a>
{% endfor %}

View File

@@ -39,7 +39,7 @@ needs following context:
<form method="post">
{% csrf_token %}
<input type="hidden" name="stock_uuid" value="{{stock.id}}">
<div class="input-group mb-3">
<div class="input-group mb-3">
<div class="input-group-text">
<input type="checkbox" class="form-check-input mt-0" name="watermark_active" id="ch-stk-watermark-act-{{stock.id}}" {% if stock.watermark >= 0 %}checked{%endif%}>
</div>
@@ -47,7 +47,22 @@ needs following context:
<input type="submit" class="btn btn-primary" name="submit-edit-watermark" value="Update Watermark">
</div>
</form>
<form method="post">
{% csrf_token %}
<input type="hidden" name="stock_uuid" value="{{stock.id}}">
<div class="input-group mb-3">
<input type="text" name="lot" id="ch-stk-lot-{{stock.id}}" class="form-control" value="{{stock.lot}}" required>
<input type="submit" class="btn btn-primary" name="submit-edit-lot" value="Update Lot">
</div>
</form>
<form method="post">
{% csrf_token %}
<input type="hidden" name="prefix" value="{{relocate_form.prefix}}">
{{ relocate_form|crispy }}
<input type="submit" class="btn btn-warning" name="submit-relocate-stock" value="Relocate Stock">
</form>
</div>
</div>
</div>
</div>
</div>

View File

@@ -2,6 +2,7 @@
{% load qr_code %}
{% load static %}
{% load crispy_forms_tags %}
{% load storage_tags %}
{% block content %}
<div class="container">
@@ -21,13 +22,28 @@
{% qr_from_text object.get_qr_code size="m" image_format="svg" %}
</div>
<div class="row">
<h4>{{storage.name}}{% if storage.verbose_name %}<small> ({{storage.verbose_name}})</small>{% endif %}</h4>
<h4>{{storage.name}}
{% if storage.verbose_name %}
<small>
({{storage.verbose_name}})
</small>
{% endif %}
{% if storage.is_template %}
<small>
(template)
</small>
{% endif %}
</h4>
</div>
<div class="row">
{% if object.parent_storage %}
<h1>Sub-Storages <a class="btn btn-secondary" href="{% url 'parts-stocks-detail' uuid=object.parent_storage.id %}">Parent Storage</a> {% else %}
<h1>Sub-Storages <a class="btn btn-secondary" href="{% url 'parts-stocks-detail' uuid=object.parent_storage.id %}">Parent Storage</a>
{% else %}
<h1>Sub-Storages <a class="btn btn-secondary" href="{% url 'parts-stocks'%}">Stock Overview</a>
{% endif %}
{% if storage.template %}
<a class="btn btn-secondary" href="{% url 'parts-stocks-detail' uuid=storage.template.id %}">Template</a>
{% endif %}
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#delete-storage-modal">Delete</button>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#add-sub-modal"><i class="bi bi-plus-circle"></i></button>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#change-modal"><i class="bi bi-pencil-square"></i></button>
@@ -36,11 +52,13 @@
{% for storage in storages %}
<a href="{% url 'parts-stocks-detail' uuid=storage.id %}" class="text-decoration-none">
<li class="list-group-item list-group-item-action justify-content-between align-items-center d-flex">
<div>
<div>
<h5>{{storage.name}}{% if storage.verbose_name %}<small> ({{storage.verbose_name}})</small>{% endif %}</h5>
Responsible: {{ storage.responsible }}
</div>
<span class="badge bg-primary rounded-pill">{{storage.get_total_stock_amount}}</span>
<span class="badge ms-1 bg-primary rounded-pill" data-bs-toggle="tooltip" data-bs-placement="top" title="Total number of stored parts">{{storage.get_total_stock_amount}}</span>
<span class="badge ms-1 bg-secondary rounded-pill d-none d-lg-block" data-bs-toggle="tooltip" data-bs-placement="top" title="Number of stored lots">{{storage.get_total_stock_count}}</span>
<span class="badge ms-1 bg-info rounded-pill d-none d-lg-block" data-bs-toggle="tooltip" data-bs-placement="top" title="Number of substorages">{{storage.get_total_substorage_amount}}</span>
</li>
</a>
{% endfor %}
@@ -80,11 +98,20 @@
{% if stock.component.manufacturer %}
Manufacturer: {{stock.component.manufacturer}}
{% endif %}
{% if stock.storage != storage %}
<span class="text-secondary"><br>{{ stock.storage|get_relative_storage_path:storage }}</span>
{% endif %}
{% if stock.lot %}
<span class="text-secondary"><br>Lot: {{stock.lot}}</span>
{% endif %}
</div>
<div class="ms-3">
<div class="flex-grow-2 ms-3 d-none d-lg-block" style="text-align: right;">
<pre>{{ stock.component.dynamic_description }}</pre>
</div>
<div class="flex-grow-2 ms-5 d-none d-lg-block">
{% qr_from_text stock.get_qr_code size="6" image_format="svg" %}
</div>
<div class="ms-3" style="width: 20%;">
Amount: {{stock.amount}}
{% if stock.watermark >= 0 %}
<br>Watermark: {{stock.watermark}}
@@ -108,14 +135,14 @@
{% include 'paginator.html' with paginator=stocks get_param='stock_page' aria_label='Stock Page Navigation' %}
</div>
</div>
{% for stock in stocks %}
{% include 'parts/modals/update-stock-modal.html' with stock=stock form=change_stock_form %}
{% for stock in stocks_with_forms %}
{% include 'parts/modals/update-stock-modal.html' with stock=stock.object form=change_stock_form relocate_form=stock.relocate_form %}
{% endfor %}
<!-- Modal for adding a substorage-->
{% with add_storage_form as form %}
{% include 'parts/modals/add-substorage-modal.html' %}
{% endwith %}
<!-- Modal to change current storag-->
<!-- Modal to change current storage-->
{% with change_storage_form as form %}
{% include 'parts/modals/change-storage-modal.html' %}
{% endwith %}

1
sly Submodule

Submodule sly added at 539a85a5d5