15 Commits

Author SHA1 Message Date
3d63357534 made the readme and example for the CSV import accessible from the form directly 2024-11-17 21:44:30 +01:00
20a83c7e91 added CSV upload to enable fast creation of similar components 2024-11-17 19:54:15 +01:00
254bf2bdf0 made the verbose storage name optional in form 2024-11-16 19:11:25 +01:00
6e01b1939d Merge pull request 'feature/sst_storage_verbose_name' (#10) from feature/sst_storage_verbose_name into develop
Reviewed-on: #10
Reviewed-by: Mario Hüttel <mario.huettel@linux.com>
2024-11-16 13:27:19 +01:00
a77f46d697 added verbose name to storages to be printed on labels etc. 2024-11-16 13:22:59 +01:00
50ecaa2cc0 Merge pull request 'sst/storage_templates' (#3) from sst/storage_templates into develop
Reviewed-on: #3
2024-11-15 22:58:18 +01:00
3e72cfe5d8 fixed usage of if instead of elif 2024-11-15 22:55:30 +01:00
5e478ba624 removed bullshit 2024-11-10 21:21:07 +01:00
8280fe7116 added option to change storages 2024-11-10 21:13:56 +01:00
2e295e4691 removed unneeded stuff 2024-11-10 20:47:21 +01:00
0b27e9f064 added templating mechanism for storages 2024-11-10 20:46:45 +01:00
6dd781021c Merge pull request 'sst/tinkeing' (#2) from sst/tinkeing into master
Reviewed-on: #2
2024-04-25 20:37:14 +02:00
7e0fc86b7f running migration without interaction in entrypoint script 2023-12-11 00:34:43 +01:00
7a1bf054f5 disabling django ssl redirection in order to run this in docker (without ssl obviously) 2023-12-11 00:23:36 +01:00
5b6f4e16ef tinkered around to get this app running inside a container using alpine and socket based psql 2023-12-10 23:09:33 +01:00
37 changed files with 602 additions and 623 deletions

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
start_server.sh

6
Dockerfile Normal file
View File

@@ -0,0 +1,6 @@
FROM alpine:latest
RUN apk add --no-cache python3 py3-pip python3-dev py3-setuptools gcc python3-dev jpeg-dev zlib-dev musl-dev py3-gunicorn
COPY . /home/shimatta/kenkyusho
WORKDIR /home/shimatta/kenkyusho
RUN python3 -m venv /home/shimatta/kenkyusho/.venv && . /home/shimatta/kenkyusho/.venv/bin/activate && pip install -r requirements.txt
ENTRYPOINT ["/home/shimatta/kenkyusho/entrypoint.sh"]

6
entrypoint.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/sh
source /home/shimatta/kenkyusho/.venv/bin/activate
cd /home/shimatta/kenkyusho/shimatta_kenkyusho
python manage.py migrate --settings shimatta_kenkyusho.settings_production
python manage.py collectstatic --settings shimatta_kenkyusho.settings_production --noinput
gunicorn -w 4 shimatta_kenkyusho.wsgi:application

View File

@@ -1,5 +1,7 @@
asgiref==3.4.1
astroid==2.6.5
certifi==2024.8.30
charset-normalizer==3.4.0
crispy-bootstrap5==0.6
Django==3.2.5
django-crispy-forms==1.13.0
@@ -8,18 +10,24 @@ django-qr-code==2.2.0
django-rest-framework==0.1.0
django-tex==1.1.9.post1
djangorestframework==3.12.4
gunicorn==21.2.0
idna==3.10
isort==5.9.3
Jinja2==3.0.1
lazy-object-proxy==1.6.0
MarkupSafe==2.0.1
mccabe==0.6.1
packaging==24.2
Pillow==8.3.1
psycopg2==2.9.1
psycopg2-binary==2.9.9
pylint==2.9.6
pytz==2021.1
qrcode==7.2
requests==2.32.3
segno==1.3.3
setuptools==75.3.0
six==1.16.0
sqlparse==0.4.1
toml==0.10.2
urllib3==2.2.3
wrapt==1.12.1

View File

@@ -1,11 +1,12 @@
from django.contrib.auth.models import User, Group
from django.contrib.auth.models import Group
from django.contrib.auth import get_user_model
from rest_framework import serializers
from parts import models as parts_models
class UserSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = User
fields = ['username', 'email', 'first_name', 'last_name', 'groups']
model = get_user_model()
fields = ['id', 'username', 'email', 'first_name', 'last_name', 'groups']
class GroupSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
@@ -28,7 +29,7 @@ class StorageSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = parts_models.Storage
fields = ['url', 'id', 'name', 'parent_storage', 'responsible', 'full_path']
fields = ['url', 'id', 'name', 'verbose_name', 'parent_storage', 'responsible', 'template', 'full_path']
class ComponentSerializer(serializers.HyperlinkedModelSerializer):
package_data = PackageSerializerNoLink(source='package', read_only=True)

View File

@@ -7,6 +7,7 @@ router = routers.DefaultRouter()
router.register(r'users', UserViewSet)
router.register(r'groups', GroupViewSet)
router.register(r'parts/storages', PartsStorageViewSet)
router.register(r'parts/storage_templates', PartsStorageTemplatesViewSet, basename='storage-template')
router.register(r'parts/components', PartsComponentViewSet)
router.register(r'parts/stocks', PartsStockViewSet)
router.register(r'parts/packages', PartsPackageViewSet)

View File

@@ -1,5 +1,6 @@
from django.shortcuts import render
from django.contrib.auth.models import User, Group
from django.contrib.auth.models import Group
from django.contrib.auth import get_user_model
from django.core.exceptions import ObjectDoesNotExist
from rest_framework import viewsets, status
from rest_framework import permissions
@@ -25,7 +26,7 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet):
"""
API endpoint that allows users to be viewed or edited.
"""
queryset = User.objects.all()
queryset = get_user_model().objects.all()
serializer_class = UserSerializer
permission_classes = [permissions.IsAuthenticated]
filter_backends = [filters.SearchFilter]
@@ -44,8 +45,17 @@ class PartsStorageViewSet(viewsets.ModelViewSet):
serializer_class = StorageSerializer
permission_classes = [permissions.DjangoModelPermissions]
filter_backends = [django_filters.rest_framework.DjangoFilterBackend]
search_fields = ['id', 'name', 'parent_storage']
filterset_fields = ['id', 'name', 'parent_storage']
class PartsStorageTemplatesViewSet(viewsets.ReadOnlyModelViewSet):
queryset = parts_models.Storage.objects.filter(is_template=True)
serializer_class = StorageSerializer
permission_classes = [permissions.IsAuthenticated]
filter_backends = [filters.SearchFilter]
search_fields = ['id', 'name']
filterset_fields = ['id', 'name']
class PartsComponentViewSet(viewsets.ModelViewSet):
queryset = parts_models.Component.objects.all()
serializer_class = ComponentSerializer

View File

@@ -9,7 +9,6 @@ admin.site.register(parts_models.Manufacturer)
admin.site.register(parts_models.Storage)
admin.site.register(parts_models.Stock)
admin.site.register(parts_models.ComponentParameter)
admin.site.register(parts_models.PackageParameter)
admin.site.register(parts_models.ComponentParameterType)
admin.site.register(parts_models.ComponentType)
admin.site.register(parts_models.Distributor)

View File

@@ -0,0 +1,110 @@
import io
import csv
import requests
from django.db import transaction
from django.core.files.images import ImageFile
from.models import ComponentParameter, ComponentType, Manufacturer, Component, \
DistributorNum, Stock, Storage, Distributor, Package, ComponentParameterType
def _stock_component(component, storage_uuid, substorage_path, amount, lot, watermark):
if not amount or not any([storage_uuid, substorage_path]):
return None
storage = Storage.from_path(substorage_path, storage_uuid)
stock = Stock.objects.create(component=component,
storage=storage,
amount=amount,
lot=lot,
watermark=watermark)
return stock
def _set_additional_parameters(component, type, value):
if type.startswith('param:'):
type = type[6:]
param_type = ComponentParameterType.objects.get(parameter_name=type)
param = ComponentParameter.objects.create(component=component,
parameter_type=param_type)
if param_type == 'F':
param.text_value = value
else:
param.value = float(value)
param.save()
return param
elif type.startswith('distri:'):
distri_name = type[7:]
distri = Distributor.objects.get(name=distri_name)
distri_num = DistributorNum.objects.create(component=component,
distributor=distri,
distributor_part_number=value)
return distri_num
def import_components_from_csv(csv_file):
"""
Imports components from a csv file containing the component model fields as
well as storage information in the heading.
Parameters can be set by param:<parameter name>, distri numbers by
distri:<distri name>.
"""
with transaction.atomic():
# simulate a text-file for the csv lib
with io.TextIOWrapper(csv_file, encoding='utf8') as csv_text_file:
rows = csv.DictReader(csv_text_file, delimiter=";")
for data in rows:
image = None
image_url = data.pop('image_url')
if image_url:
response = requests.get(image_url)
image_content = response.content
image_file = io.BytesIO(image_content)
image = ImageFile(image_file, 'downloaded_file')
manufacturer = None
manufacturer_name = data.pop('manufacturer')
if manufacturer_name:
manufacturer = Manufacturer.objects.get(name=manufacturer_name)
component_type = None
component_type_name = data.pop('component_type')
if component_type_name:
component_type = ComponentType.objects.get(class_name=component_type_name)
distributor = None
pref_distri = data.pop('pref_distri')
if pref_distri:
distributor = Distributor.objects.get(name=pref_distri)
package = None
package_name = data.pop('package')
if package_name:
package = Package.objects.get(name=package_name)
comp = Component.objects.create(name=data.pop('name'),
manufacturer=manufacturer,
component_type=component_type,
pref_distri=distributor,
description=data.pop('description'),
datasheet_link=data.pop('datasheet_link'),
package=package,
image=image)
_stock_component(comp,
data.pop('storage_uuid'),
data.pop('substorage_path'),
data.pop('amount'),
data.pop('lot'),
data.pop('watermark'))
for key, value in data.items():
_set_additional_parameters(comp, key, value)

View File

@@ -1,23 +1,26 @@
from django import forms
from django.forms import widgets
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.forms import widgets
from parts import models as parts_models
from shimatta_modules.EngineeringNumberConverter import EngineeringNumberConverter
import uuid
from django.urls import reverse
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Fieldset, Row, Column
from crispy_forms.layout import Layout, Row, Column
class AutoCompleteWidget(widgets.Input):
template_name = 'widgets/autocomplete-foreign-key.html'
def __init__(self, api_search_url, image_field_name, foreign_model, name_field_name, *args, **kwargs):
def __init__(self, api_search_url, image_field_name, foreign_model, name_field_name, prepend=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.image_field_name = image_field_name
self.foreign_model = foreign_model
self.api_search_url = api_search_url
self.name_field_name = name_field_name
self.prepend = prepend
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
@@ -47,14 +50,25 @@ class AutoCompleteWidget(widgets.Input):
'current_instance': instance,
'image': image,
'name_field_name': self.name_field_name,
'prepend': self.prepend,
'name': display_name,
}
return context
class AutocompleteForeingKeyField(forms.UUIDField):
def __init__(self, foreign_model=None, api_search_url=None, image_field_name='image', name_field_name='name', **kwargs):
def __init__(self,
foreign_model=None,
api_search_url=None,
image_field_name='image',
name_field_name='name',
prepend=None,
**kwargs):
super().__init__(**kwargs)
self.widget = AutoCompleteWidget(api_search_url, image_field_name, foreign_model, name_field_name)
self.widget = AutoCompleteWidget(api_search_url,
image_field_name,
foreign_model,
name_field_name,
prepend)
self.foreign_model = foreign_model
@@ -74,13 +88,25 @@ class AutocompleteForeingKeyField(forms.UUIDField):
raise ValidationError('Given element does not exist')
return obj
class MyTestForm(forms.Form):
pass
class AddSubStorageForm(forms.Form):
storage_name = forms.CharField(label="storage_name", initial='')
responsible = forms.CharField(label='responsible_user')
class ChangeStorageForm(forms.Form):
storage_name = forms.CharField(label="Name", initial='')
verbose_name = forms.CharField(label="Verbose Name", initial='', required=False)
responsible = AutocompleteForeingKeyField(api_search_url='user-list',
image_field_name=None,
name_field_name='username',
foreign_model=get_user_model(),
prepend='@')
is_template = forms.BooleanField(label='is_template', required=False)
class AddSubStorageForm(ChangeStorageForm):
template = AutocompleteForeingKeyField(api_search_url='storage-template-list',
image_field_name=None,
foreign_model=parts_models.Storage,
required=False)
class DeleteStockForm(forms.Form):
stock_uuid = forms.UUIDField()
@@ -188,6 +214,9 @@ class ComponentForm(forms.ModelForm):
model = parts_models.Component
fields = '__all__'
class ImportComponentForm(forms.Form):
csv_file = forms.FileField(label="CSV File")
class PackageForm(forms.ModelForm):
class Meta:
model = parts_models.Package
@@ -244,7 +273,6 @@ class AdvancedComponentSearchForm(forms.Form):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.disable_csrf = True
self.helper.layout = Layout(
Row(
Column('name'),
@@ -263,53 +291,20 @@ class AdvancedComponentSearchForm(forms.Form):
),
)
PARAMETER_COMPARISON_TYPES = (
('eq', '=='),
('lte', '<='),
('gte', '>='),
)
class ComponentParameterSearchForm(forms.Form):
parameter = AutocompleteForeingKeyField(required=True, foreign_model=parts_models.ComponentParameterType, api_search_url='componentparametertype-list', image_field_name=None, name_field_name='parameter_name')
value = forms.CharField(max_length=100, required=False)
compare_method = forms.ChoiceField(choices=PARAMETER_COMPARISON_TYPES, required=True, initial=1)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.disable_csrf = True
self.helper.layout = Layout(
Row(
Column('parameter'),
Column('compare_method'),
Column('value')
)
)
def clean(self):
cleaned_data = super().clean()
parameter = cleaned_data.get('parameter')
value = cleaned_data.get('value')
if value != '' or value != None:
value = value.strip()
if value == '' or value == None:
cleaned_data['value'] = None
value = None
if parameter and value is not None and value != '':
if parameter.parameter_type != 'F':
try:
cleaned_data['value'] = EngineeringNumberConverter.engineering_to_number(value)
except:
raise ValidationError('Cannot convert value to number')
return cleaned_data
class ComponentParameterCreateForm(forms.Form):
parameter_type = AutocompleteForeingKeyField(required=True, foreign_model=parts_models.ComponentParameterType, api_search_url='componentparametertype-list', image_field_name=None, name_field_name='descriptive_name')
value = forms.CharField(required=True, max_length=256)

View File

@@ -1,44 +0,0 @@
# Generated by Django 3.2.5 on 2022-01-10 18:12
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [
('parts', '0010_auto_20220103_1606'),
]
operations = [
migrations.AddField(
model_name='componenttype',
name='key_parameter1',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='type_param1', to='parts.componentparametertype'),
),
migrations.AddField(
model_name='componenttype',
name='key_parameter2',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='type_param2', to='parts.componentparametertype'),
),
migrations.AddField(
model_name='componenttype',
name='key_parameter3',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='type_param3', to='parts.componentparametertype'),
),
migrations.CreateModel(
name='PackageParameter',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('value', models.FloatField(default=0)),
('text_value', models.TextField(blank=True)),
('package', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='parts.package')),
('parameter_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='parts.componentparametertype')),
],
options={
'ordering': ['id'],
'unique_together': {('package', 'parameter_type')},
},
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 3.2.5 on 2024-11-10 12:42
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('parts', '0010_auto_20220103_1606'),
]
operations = [
migrations.AddField(
model_name='storage',
name='is_template',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='storage',
name='template',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='template_of', to='parts.storage'),
),
]

View File

@@ -1,19 +0,0 @@
# Generated by Django 3.2.5 on 2022-11-11 20:18
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('parts', '0011_auto_20220110_1812'),
]
operations = [
migrations.AddField(
model_name='componenttype',
name='parent_class',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='child_classes', to='parts.componenttype'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.5 on 2024-11-16 11:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('parts', '0011_auto_20241110_1242'),
]
operations = [
migrations.AddField(
model_name='storage',
name='verbose_name',
field=models.CharField(blank=True, max_length=100, null=True),
),
]

View File

@@ -1,7 +1,7 @@
from django.db import models
from shimatta_modules import RandomFileName
from django.db.models import F, Sum
from django.contrib.auth.models import User as AuthUser
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.dispatch import receiver
@@ -47,35 +47,11 @@ class ComponentType(models.Model):
class Meta:
ordering = ['class_name']
class_name = models.CharField(max_length=50, unique=True)
parent_class = models.ForeignKey('self', on_delete=models.PROTECT, related_name='child_classes', null=True, blank=True)
passive = models.BooleanField()
possible_parameter = models.ManyToManyField(ComponentParameterType, blank=True)
key_parameter1 = models.ForeignKey(ComponentParameterType, on_delete=models.CASCADE, blank=True, null=True, related_name="type_param1")
key_parameter2 = models.ForeignKey(ComponentParameterType, on_delete=models.CASCADE, blank=True, null=True, related_name="type_param2")
key_parameter3 = models.ForeignKey(ComponentParameterType, on_delete=models.CASCADE, blank=True, null=True, related_name="type_param3")
def __str__(self):
return self.get_full_path()
def get_path_components(self):
chain = []
iterator = self
chain.append(self)
while iterator.parent_class is not None:
chain.append(iterator.parent_class)
iterator = iterator.parent_class
return chain
def get_full_path(self):
output = ''
chain = self.get_path_components()
for i in range(len(chain) - 1, -1, -1):
output = output + ' / ' + chain[i].class_name
return output
return '[' + self.class_name + ']'
class Storage(models.Model):
class Meta:
@@ -86,8 +62,17 @@ class Storage(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, unique=True)
name = models.CharField(max_length=100, validators=[storage_name_validator])
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(AuthUser, on_delete=models.SET_NULL, blank=True, null=True)
responsible = models.ForeignKey(get_user_model(), on_delete=models.SET_NULL, blank=True, null=True)
# allow storages to be templates which can be selected when adding new storages
is_template = models.BooleanField(default=False)
template = models.ForeignKey('self',
on_delete=models.SET_NULL,
blank=True,
null=True,
related_name='template_of')
def get_path_components(self):
chain = []
@@ -137,6 +122,29 @@ class Storage(models.Model):
sum = 0
return sum
@classmethod
def from_path(cls, path, root_storage=None):
'''
Get the storage object described by its complete path or the sub-path
from the passed root_storage uuid
'''
parts = path.split('/')
# assemble filter query
filter_dict = {}
layer = 0
for part in parts[::-1]:
filter_dict[f'{"parent_storage__" * layer}name'] = part
layer += 1
if root_storage:
filter_dict[f'{"parent_storage__" * layer}id'] = root_storage
else:
filter_dict[f'{"parent_storage__" * layer}isnull'] = True
obj = cls.objects.get(**filter_dict)
return obj
def save(self, *args, **kwargs):
self.validate_unique()
super(Storage, self).save(*args, **kwargs)
@@ -168,37 +176,6 @@ class Package(models.Model):
def __str__(self):
return self.name
class PackageParameter(models.Model):
class Meta:
unique_together = ('package', 'parameter_type')
ordering = ['id']
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, unique=True)
package = models.ForeignKey(Package, on_delete=models.CASCADE) # A target package is required!
parameter_type = models.ForeignKey(ComponentParameterType, on_delete=models.CASCADE)
value = models.FloatField(default=0)
text_value = models.TextField(null=False, blank=True)
def __str__(self):
if self.parameter_type.parameter_type == 'F':
value = self.text_value
else:
value = str(self.value)
return str(self.package)+ ': '+ str(self.parameter_type) + ': ' + value
def resolved_value_as_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))
return f'{num:.3f} {prefix}{self.parameter_type.unit}'
elif my_type == 'N':
# Standard float number
return f'{self.value:.3f} {self.parameter_type.unit}'
elif my_type == 'F':
return self.text_value
class Manufacturer(models.Model):
class Meta:
@@ -261,33 +238,6 @@ class Component(models.Model):
sum = 0
return sum
def get_key_parameters(self):
"""
Get the key parameters of a component defined by its component type.
Returns a tuple of 3 elements. All three might be None
"""
p1 = None
p2 = None
p3 = None
if self.component_type:
t = (self.component_type.key_parameter1, self.component_type.key_parameter2, self.component_type.key_parameter3)
if t[0]:
p1 = ComponentParameter.objects.filter(component=self, parameter_type=t[0]).first()
if t[1]:
p2 = ComponentParameter.objects.filter(component=self, parameter_type=t[1]).first()
if t[2]:
p3 = ComponentParameter.objects.filter(component=self, parameter_type=t[2]).first()
return (p1, p2, p3)
def get_key_parameters_as_text(self):
params = self.get_key_parameters()
ret_strings = []
for p in params:
if p:
ret_strings.append(p.resolved_value_as_string())
return ret_strings
class ComponentParameter(models.Model):
class Meta:
@@ -313,12 +263,10 @@ class ComponentParameter(models.Model):
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))
num = round(num, 3)
return f'{num} {prefix}{self.parameter_type.unit}'
return f'{num:.3f} {prefix}{self.parameter_type.unit}'
elif my_type == 'N':
# Standard float number
num = round(self.value, 3)
return f'{num} {self.parameter_type.unit}'
return f'{self.value:.3f} {self.parameter_type.unit}'
elif my_type == 'F':
return self.text_value
@@ -372,7 +320,7 @@ class DistributorNum(models.Model):
class QrPrintJob(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, unique=True)
user = models.ForeignKey(AuthUser, on_delete=models.CASCADE, null=False, blank=False)
user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE, null=False, blank=False)
qrdata = models.CharField(max_length=256, blank=True, null=False)
text = models.TextField(max_length=512, blank=True, null=False)
print_count = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0), MaxValueValidator(32)])
@@ -420,3 +368,19 @@ def auto_delete_file_on_change(sender, instance, **kwargs):
return True
if os.path.isfile(old_file.path):
os.remove(old_file.path)
@receiver(models.signals.post_save, sender=Storage)
def auto_apply_template_structure(sender, instance, created, **kwargs):
"""
Add the sub-storages from the template.
If there are nested sub-storages these will be added when the sub-storages
are created automatically.
"""
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,
is_template=False,
template=sub_storage)

View File

@@ -4,7 +4,6 @@ from . import views as parts_views
urlpatterns = [
path('', parts_views.MainView.as_view(), name='parts-main'),
path('components/', parts_views.ComponentView.as_view(), name='parts-components'),
path('componenttypes/', parts_views.ComponentTypeView.as_view(), name='parts-componenttypes'),
path('packages/', parts_views.PackageView.as_view(), name='parts-packages'),
path('distributors/', parts_views.DistributorView.as_view(), name='parts-distributors'),
path('stocks/', parts_views.StockView.as_view(), name='parts-stocks'),
@@ -17,5 +16,4 @@ urlpatterns = [
path('distributors/<slug:uuid>/', parts_views.DistributorDetailView.as_view(), name='parts-distributors-detail'),
path('manufacturers/', parts_views.ManufacturersViewSet.as_view(), name='parts-manufacturers'),
path("manufacturers/<slug:uuid>/", parts_views.ManufacturerDetailViewSet.as_view(), name='parts-manufacturers-detail'),
path("componenttypes/<slug:uuid>/", parts_views.ComponentTypeDetailView.as_view(), name='parts-componenttypes-detail'),
]

View File

@@ -1,33 +1,27 @@
from django.shortcuts import render, redirect
from django.urls import resolve, reverse
from django.urls import reverse
from django.contrib.auth import logout, login
from django.contrib.auth.models import User
from django.utils.http import urlencode
from django.http import HttpResponse
from .navbar import NavBar
from django.contrib.auth.forms import AuthenticationForm as AuthForm
from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth import update_session_auth_hash
from django.views import View
import django.forms as forms
from django.views.generic import TemplateView, DetailView
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from .models import Storage, Stock, Component, Distributor, Manufacturer, Package
from .models import ComponentParameter, ComponentParameterType, DistributorNum, PackageParameter
from .models import ComponentType
from django.contrib.auth.mixins import LoginRequiredMixin
from .models import Storage, Stock, Component, Distributor, Manufacturer, Package, ComponentParameter, DistributorNum
from .qr_parser import QrCodeValidator
from django.core.paginator import Paginator
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from django.db.models import ProtectedError
from .forms import *
from .component_import import import_components_from_csv
from django.db.models import Q
from django.db.models import Prefetch
from django.db.models.functions import Lower
from django.forms import formset_factory
import uuid
ParameterSearchFormSet = formset_factory(ComponentParameterSearchForm, extra=0)
ParameterSearchFormSet = formset_factory(ComponentParameterSearchForm, extra=1)
class QrSearchForm(forms.Form):
my_qr_validator = QrCodeValidator()
@@ -38,15 +32,6 @@ class QrSearchForm(forms.Form):
qr_search = forms.CharField(label='qr_search', validators=[my_qr_validator])
class KeepSearchParamMixin(object):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
search = self.request.GET.get('search', default=None)
if search:
context['additional_params'] = urlencode({'search': search})
return context
class BaseTemplateMixin(object):
navbar_selected = ''
base_title = ''
@@ -155,69 +140,16 @@ def login_view(request):
# Create your views here.
class ComponentTypeView(LoginRequiredMixin, BaseTemplateMixin, TemplateView):
template_name = 'parts/component-types.html'
base_title = 'Component Types'
default_page_size = 25
def filter_queryset(self, queryset, search_string):
if search_string is None or search_string == '':
return queryset
search_fragments = search_string.strip().split()
for search in search_fragments:
queryset = queryset.filter(Q(class_name__icontains = search))
return queryset
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
search = self.request.GET.get('search', default=None)
page_num = self.request.GET.get('page', default=1)
context['search_string'] = search
queryset = ComponentType.objects.all()
types = self.filter_queryset(queryset, search)
comptypes = Paginator(types, self.default_page_size)
context['comptypes'] = comptypes.get_page(page_num)
return context
def post(self, request, *args, **kwargs):
return super().post(request, *args, **kwargs)
class ComponentTypeDetailView(LoginRequiredMixin, BaseTemplateMixin, DetailView):
model = ComponentType
base_title = ''
pk_url_kwarg = 'uuid'
template_name = 'parts/component-types-detail.html'
def get_breadcrumbs(self):
crumbs = self.object.get_path_components()
# Reverse list and drop the last element of the reversed list
crumbs = crumbs[::-1][:-1]
return crumbs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['breadcrumbs'] = self.get_breadcrumbs()
return context
class ComponentView(LoginRequiredMixin, KeepSearchParamMixin, BaseTemplateMixin, TemplateView):
class ComponentView(LoginRequiredMixin, BaseTemplateMixin, TemplateView):
template_name = 'parts/components.html'
base_title = 'Components'
navbar_selected = 'Components'
default_page_size = 25
def get_component_query_set(self, search_string):
queryset = Component.objects.select_related('package', 'manufacturer', 'component_type').prefetch_related('componentparameter_set').all()
queryset = Component.objects.all()
if search_string is None or search_string == '':
if not search_string:
return queryset
search_fragments = search_string.strip().split()
@@ -227,7 +159,7 @@ class ComponentView(LoginRequiredMixin, KeepSearchParamMixin, BaseTemplateMixin,
return queryset
def get_component_queryset_from_advanced_search(self, cleaned_data):
queryset = Component.objects.select_related('manufacturer', 'package', 'component_type').prefetch_related('componentparameter_set').all()
queryset = Component.objects.all()
if cleaned_data['name']:
queryset = queryset.filter(Q(name__icontains=cleaned_data['name']))
@@ -247,21 +179,6 @@ class ComponentView(LoginRequiredMixin, KeepSearchParamMixin, BaseTemplateMixin,
queryset = queryset.filter(manufacturer=cleaned_data['manufacturer'])
return queryset
def filter_queryset_with_parameters(self, queryset, parameter, value, compare_method):
if parameter and (value is None or value == ''):
return queryset.filter(Q(componentparameter__parameter_type=parameter))
elif parameter and value is not None:
if parameter.parameter_type == 'F':
return queryset.filter(Q(componentparameter__text_value__icontains=value) & Q(componentparameter__parameter_type=parameter))
else:
if compare_method == 'lte': # <=
return queryset.filter(Q(componentparameter__value__lte=value) & Q(componentparameter__parameter_type=parameter))
elif compare_method == 'gte': # >=
return queryset.filter(Q(componentparameter__value__gte=value) & Q(componentparameter__parameter_type=parameter))
else:
return queryset.filter(Q(componentparameter__value=value) & Q(componentparameter__parameter_type=parameter))
return queryset
def get_context_data_int(self, advanced_search, parameter_formset : ParameterSearchFormSet, **kwargs):
context = super().get_context_data(**kwargs)
@@ -275,14 +192,11 @@ class ComponentView(LoginRequiredMixin, KeepSearchParamMixin, BaseTemplateMixin,
if advanced_search.is_valid():
paginator_queryset = self.get_component_queryset_from_advanced_search(advanced_search.cleaned_data)
else:
paginator_queryset = self.get_component_query_set(None)
paginator_queryset = Component.objects.all()
if parameter_formset.is_valid():
# Process parameters
for f in parameter_formset:
# If the form is valid and has changed compared to its initial empty state
if f.is_valid() and f.has_changed():
paginator_queryset = self.filter_queryset_with_parameters(paginator_queryset, f.cleaned_data['parameter'], f.cleaned_data['value'], f.cleaned_data['compare_method'])
pass
else:
search = self.request.GET.get('search', default=None)
@@ -292,29 +206,20 @@ class ComponentView(LoginRequiredMixin, KeepSearchParamMixin, BaseTemplateMixin,
context['components'] = comp_paginator.get_page(comp_page_num)
context['comp_form'] = ComponentForm()
context['import_comp_form'] = ImportComponentForm()
context['search_string'] = search
if not parameter_formset:
context['advanced_search_param_formset'] = ParameterSearchFormSet()
if not advanced_search:
context['advanced_search_form'] = AdvancedComponentSearchForm(auto_id='adv_search_%s')
return context
def get_context_data(self, **kwargs):
adv_search_form = None
adv_param_search_formset = None
if 'submit-advanced-search' in self.request.GET:
adv_search_form = AdvancedComponentSearchForm(auto_id='adv_search_%s', data=self.request.GET)
adv_param_search_formset = ParameterSearchFormSet(data=self.request.GET)
if adv_search_form.is_valid():
pass
if adv_param_search_formset.is_valid():
pass
return self.get_context_data_int(advanced_search = adv_search_form, parameter_formset=adv_param_search_formset, **kwargs)
return self.get_context_data_int(advanced_search = None, parameter_formset=None, **kwargs)
def handle_new_component_post(self, request, open=False, **kwargs):
cform = ComponentForm(data=request.POST, files=request.FILES)
@@ -330,15 +235,48 @@ class ComponentView(LoginRequiredMixin, KeepSearchParamMixin, BaseTemplateMixin,
return redirect(reverse('parts-components-detail', kwargs={'uuid':new_component.id}))
return self.render_to_response(context)
def handle_import_components_post(self, request, open=False, **kwargs):
cform = ImportComponentForm(data=request.POST, files=request.FILES)
context = self.get_context_data(**kwargs)
if cform.is_valid():
try:
import_components_from_csv(cform.files['csv_file'].file)
except Exception as ex:
cform.add_error('csv_file', str(ex))
context['import_comp_form'] = cform
else:
context['import_comp_form'] = cform
return self.render_to_response(context)
def handle_advanced_search_post(self, request, **kwargs):
form = AdvancedComponentSearchForm(auto_id='adv_search_%s', data=request.POST)
param_formset = ParameterSearchFormSet(data=request.POST)
if form.is_valid():
print('Valid')
if param_formset.is_valid():
print('Formset is valid!')
context = self.get_context_data_int(form, param_formset, **kwargs)
return self.render_to_response(context)
def post(self, request, *args, **kwargs):
if 'submit-edit-component' in request.POST:
return self.handle_new_component_post(request, open=False, **kwargs)
elif 'submit-edit-component-open' in request.POST:
return self.handle_new_component_post(request, open=True, **kwargs)
elif 'submit-import-components' in request.POST:
return self.handle_import_components_post(request, open=True, **kwargs)
elif 'submit-advanced-search' in request.POST:
return self.handle_advanced_search_post(request, **kwargs)
else:
return super().post(request, *args, **kwargs)
class PackageView(LoginRequiredMixin, KeepSearchParamMixin, BaseTemplateMixin, TemplateView):
class PackageView(LoginRequiredMixin, BaseTemplateMixin, TemplateView):
template_name = 'parts/packages.html'
base_title = 'Packages'
navbar_selected = 'Packages'
@@ -400,7 +338,7 @@ class PackageView(LoginRequiredMixin, KeepSearchParamMixin, BaseTemplateMixin, T
return super().post(request, *args, **kwargs)
class DistributorView(LoginRequiredMixin, KeepSearchParamMixin, BaseTemplateMixin, TemplateView):
class DistributorView(LoginRequiredMixin, BaseTemplateMixin, TemplateView):
template_name = 'parts/distributors.html'
base_title = 'Distributors'
navbar_selected = 'Distributors'
@@ -476,36 +414,23 @@ class StockView(LoginRequiredMixin, BaseTemplateMixin, TemplateView):
context['low_stocks'] = low_stock_paginator.get_page(low_stock_page)
context['storages'] = storage_paginator.get_page(storage_page)
add_stor_form = AddSubStorageForm()
add_stor_form.fields['responsible'].initial = self.request.user.username
add_stor_form.fields['responsible'].initial = self.request.user.id
context['add_storage_form'] = add_stor_form
return context
def handle_add_storage(self, request, **kwargs):
return_invalid_form = False
f = AddSubStorageForm(data=request.POST)
if f.is_valid():
new_storage_name = f.cleaned_data['storage_name']
sub_name = f.cleaned_data['storage_name']
try:
resp_user = User.objects.get(username=f.cleaned_data['responsible'])
except Exception as _:
resp_user = None
f.add_error('responsible', 'Invalid Responsible User')
return_invalid_form = True
if resp_user is not None:
try:
Storage.objects.create(name=new_storage_name, responsible=resp_user, parent_storage=None)
except ValidationError as verr:
return_invalid_form = True
f.add_error('storage_name', ' .'.join(verr.messages))
else:
return_invalid_form = True
Storage.objects.create(name=sub_name,
responsible=f.cleaned_data['responsible'],
is_template=f.cleaned_data['is_template'],
template=f.cleaned_data.get('template'))
except ValidationError as v_err:
f.add_error('storage_name', '. '.join(v_err.messages))
context = self.get_context_data(**kwargs)
if return_invalid_form:
context['add_storage_form'] = f
return self.render_to_response(context)
def post(self, request, **kwargs):
@@ -514,7 +439,7 @@ class StockView(LoginRequiredMixin, BaseTemplateMixin, TemplateView):
return super().post(request, **kwargs)
class StockViewDetail(LoginRequiredMixin, KeepSearchParamMixin, BaseTemplateMixin, DetailView):
class StockViewDetail(LoginRequiredMixin, BaseTemplateMixin, DetailView):
template_name = 'parts/stocks-detail.html'
model = Storage
pk_url_kwarg = 'uuid'
@@ -572,10 +497,17 @@ class StockViewDetail(LoginRequiredMixin, KeepSearchParamMixin, BaseTemplateMixi
context['stocks'] = stocks
context['stock_search'] = stock_search_input
add_storage_form = AddSubStorageForm()
add_storage_form.fields['responsible'].initial = self.request.user.username
add_storage_form.fields['responsible'].initial = self.request.user.id
context['add_storage_form'] = add_storage_form
change_storage_form = ChangeStorageForm()
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['is_template'].initial = self.object.is_template
context['change_storage_form'] = change_storage_form
context['delete_storage_error'] = None
context['add_stock_form'] = AddStockForm()
return context
def handle_add_storage_post(self, request, **kwargs):
@@ -583,17 +515,34 @@ class StockViewDetail(LoginRequiredMixin, KeepSearchParamMixin, BaseTemplateMixi
if f.is_valid():
sub_name = f.cleaned_data['storage_name']
try:
user = User.objects.get(username=f.cleaned_data['responsible'])
try:
Storage.objects.create(name=sub_name, parent_storage=self.object, responsible=user)
Storage.objects.create(name=sub_name,
verbose_name=f.cleaned_data.get('verbose_name'),
parent_storage=self.object,
responsible=f.cleaned_data['responsible'],
is_template=f.cleaned_data['is_template'],
template=f.cleaned_data.get('template'))
except ValidationError as v_err:
f.add_error('storage_name', '. '.join(v_err.messages))
except:
f.add_error('responsible', 'Invalid user')
context = self.get_context_data(**kwargs)
context['add_storage_form'] = f
return self.render_to_response(context)
def handle_change_storage_post(self, request, **kwargs):
f = ChangeStorageForm(data=request.POST)
if f.is_valid():
sub_name = f.cleaned_data['storage_name']
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.is_template = f.cleaned_data['is_template']
self.object.save()
except ValidationError as v_err:
f.add_error('storage_name', '. '.join(v_err.messages))
context = self.get_context_data(**kwargs)
context['change_storage_form'] = f
return self.render_to_response(context)
def handle_del_storage_post(self, request, **kwargs):
parent = self.object.parent_storage
try:
@@ -607,6 +556,7 @@ class StockViewDetail(LoginRequiredMixin, KeepSearchParamMixin, BaseTemplateMixi
return redirect('parts-stocks')
else:
return redirect(reverse('parts-stocks-detail', kwargs={'uuid':parent.id}))
def handle_del_stock_post(self, request, **kwargs):
del_error = None
if 'stock_uuid' in request.POST:
@@ -632,10 +582,9 @@ class StockViewDetail(LoginRequiredMixin, KeepSearchParamMixin, BaseTemplateMixi
if edit_form.is_valid():
edit_form.save()
else:
pass
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):
@@ -669,6 +618,8 @@ class StockViewDetail(LoginRequiredMixin, KeepSearchParamMixin, BaseTemplateMixi
if 'submit-add-storage' in request.POST:
return self.handle_add_storage_post(request, **kwargs)
elif 'submit-change-storage' in request.POST:
return self.handle_change_storage_post(request, **kwargs)
elif 'submit-delete-storage' in request.POST:
return self.handle_del_storage_post(request, **kwargs)
elif 'submit-delete-stock' in request.POST:
@@ -686,7 +637,7 @@ class StockViewDetail(LoginRequiredMixin, KeepSearchParamMixin, BaseTemplateMixi
class ComponentDetailView(LoginRequiredMixin, BaseTemplateMixin, DetailView):
template_name = 'parts/components-detail.html'
queryset = Component.objects.select_related('component_type', 'package', 'manufacturer').prefetch_related('componentparameter_set', 'distributornum_set')
model = Component
pk_url_kwarg = 'uuid'
base_title = ''
navbar_selected = 'Components'
@@ -699,13 +650,8 @@ class ComponentDetailView(LoginRequiredMixin, BaseTemplateMixin, DetailView):
context['comp_form'] = ComponentForm(instance=self.object)
context['new_distri_num_form'] = DistributorNumberCreateForm()
context['new_param_form'] = ComponentParameterCreateForm()
context['distri_nums'] = self.object.distributornum_set.select_related('distributor').order_by('distributor__name')
context['parameters'] = self.object.componentparameter_set.order_by('parameter_type__parameter_name')
if self.object.package:
context['package_parameters'] = PackageParameter.objects.filter(package=self.object.package).order_by('parameter_type__parameter_name')
parameter_texts = self.object.get_key_parameters_as_text()
context['key_parameter_string'] = ', '.join(parameter_texts)
context['distri_nums'] = DistributorNum.objects.filter(component=self.object).order_by('distributor__name')
context['parameters'] = ComponentParameter.objects.filter(component=self.object).order_by('parameter_type__parameter_name')
return context
@@ -915,7 +861,7 @@ class DistributorDetailView(LoginRequiredMixin, BaseTemplateMixin, DetailView):
return super().post(request, *args, **kwargs)
class ManufacturersViewSet(LoginRequiredMixin, KeepSearchParamMixin, BaseTemplateMixin, TemplateView):
class ManufacturersViewSet(LoginRequiredMixin, BaseTemplateMixin, TemplateView):
template_name = 'parts/manufacturers.html'
base_title = 'Manufacturers'
navbar_selected = 'Manufacturers'

View File

@@ -138,6 +138,7 @@ DATABASES = {
'USER': db_user,
'PASSWORD': db_pw,
'HOST': get_env_value('DJANGO_POSTGRESQL_SOCKET'),
'PORT': get_env_value('DJANGO_POSTGRESQL_PORT'),
}
}
@@ -229,6 +230,6 @@ CRISPY_TEMPLATE_PACK = "bootstrap5"
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True
SECURE_SSL_REDIRECT = False
SECURE_HSTS_SECONDS = get_env_value('DJANGO_SECURE_HSTS_SECONDS', default=120)

View File

@@ -31,7 +31,7 @@ class EngineeringNumberConverter():
]
@classmethod
def number_to_engineering(c, number, it_unit=False):
def number_to_engineering(c, number, it_unit = False):
"""
Convert a number to engineering SI syntax with prefix.
This function will return a tuple of (new_number, prefix)

View File

@@ -0,0 +1,2 @@
name;manufacturer;component_type;pref_distri;description;datasheet_link;package;image_url;storage_uuid;substorage_path;amount;lot;watermark;param:Resistance;param:Temperature coefficient;param:Tolerance;distri:Mouser
1K41;Phycomp;Resistor;;KVG - expired;;R0603;https://t3.ftcdn.net/jpg/02/95/44/22/360_F_295442295_OXsXOmLmqBUfZreTnGo9PREuAPSLQhff.jpg;a4909bb4-4d69-4e58-8fbd-fba4fa62d9fb;bew;1111;4000;0;1330;100;1;gggg
1 name manufacturer component_type pref_distri description datasheet_link package image_url storage_uuid substorage_path amount lot watermark param:Resistance param:Temperature coefficient param:Tolerance distri:Mouser
2 1K41 Phycomp Resistor KVG - expired R0603 https://t3.ftcdn.net/jpg/02/95/44/22/360_F_295442295_OXsXOmLmqBUfZreTnGo9PREuAPSLQhff.jpg a4909bb4-4d69-4e58-8fbd-fba4fa62d9fb bew 1111 4000 0 1330 100 1 gggg

View File

@@ -0,0 +1,14 @@
# The shimatta kenkyusho can import components from a CSV file uploaded to the website
All parameters are passed as names (e.g. package, manufacturer etc.).
Component parameters can be set using a prefix ``param:`` followed by the
parameter name. The value is then parsed bysed on the parameter type.
Distributor part numbers can be set similarly by prepending ``distri:``.
It is also possible to create initial stocks to one storage by passing the
storage uuid and/or the path to the storage (as printed in the breadcrumbs) or
a combination of a storage uuid and the path from this storage.
See example for details.

View File

@@ -12,9 +12,9 @@ function initialize_autocompletion_foreign_key_field(search_element) {
var name_field_name = search_element.getAttribute('data-ac-name-field');
var search_url = search_element.getAttribute('data-ac-url');
var base_id = search_element.getAttribute('id');
var uuid_field = search_element.parentElement.querySelector('#'+base_id+'-uuid-field');
var dflex_container = search_element.parentElement.querySelector('#'+base_id+'-dflex-container');
var initial_delete_button = search_element.parentElement.querySelector('[data-ac-delete]');
var uuid_field = search_element.parentElement.parentElement.querySelector('#'+base_id+'-uuid-field');
var dflex_container = search_element.parentElement.parentElement.querySelector('#'+base_id+'-dflex-container');
var initial_delete_button = search_element.parentElement.parentElement.querySelector('[data-ac-delete]');
console.log(initial_delete_button);
console.log(image_field_name);

View File

@@ -29,6 +29,10 @@ function api_search_user(search, onSuccess, onFail) {
return api_ajax_request_without_send('GET', api_urls_v1['user-list']+`?search=${encodeURIComponent(search)}`, function(method, url, json) {onSuccess(json);}, onFail);
}
function api_search_storage_template(search, onSuccess, onFail) {
return api_ajax_request_without_send('GET', api_urls_v1['storage-template-list']+`?search=${encodeURIComponent(search)}`, function(method, url, json) {onSuccess(json);}, onFail);
}
function api_search_component(search, onSuccess, onFail) {
return api_ajax_request_without_send('GET', api_urls_v1['component-list']+`?search=${encodeURIComponent(search)}`, function(method, url, json) {onSuccess(json);}, onFail);
}

View File

@@ -65,6 +65,7 @@
'user-list': '{% url 'user-list' %}',
'groups-list': '{% url 'user-list' %}',
'storage-list': '{% url 'storage-list' %}',
'storage-template-list': '{% url 'storage-template-list' %}',
'component-list': '{% url 'component-list' %}',
'package-list': '{% url 'package-list' %}',
'stock-list': '{% url 'stock-list' %}',
@@ -77,6 +78,11 @@
<script type="text/javascript" src="{% static 'js/kenyusho-api-v1.js' %}"></script>
<script type="text/javascript" src="{% static 'js/autocomplete.js' %}"></script>
<script type="text/javascript" src="{% static 'js/autocomplete-foreign-key-field.js' %}"></script>
<!-- Initialize bootstrap popovers -->
<script type="text/javascript">
const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]')
const popoverList = [...popoverTriggerList].map(popoverTriggerEl => new bootstrap.Popover(popoverTriggerEl))
</script>
{% block custom_scripts %}
{% endblock custom_scripts %}

View File

@@ -2,21 +2,21 @@
<nav aria-label="{{aria_label}}">
<ul class="pagination">
{% if paginator.has_previous %}
<li class="page-item"><a class="page-link" href="?{{get_param}}={{paginator.previous_page_number}}{% if additional_params %}&{{additional_params}}{% endif %}">&laquo;</a></li>
<li class="page-item"><a class="page-link" href="?{{get_param}}={{paginator.previous_page_number}}">&laquo;</a></li>
{% else %}
<li class="page-item disabled"><span class="page-link">&laquo;</span></li>
{% endif %}
{% for i in paginator.paginator.page_range %}
{% if i <= paginator.number|add:5 and i >= paginator.number|add:-5 %}
{% if i == paginator.number %}
<li class="page-item active"><a class="page-link" href="?{{get_param}}={{i}}{% if additional_params %}&{{additional_params}}{% endif %}">{{i}}</a></li>
<li class="page-item active"><a class="page-link" href="?{{get_param}}={{i}}">{{i}}</a></li>
{% else %}
<li class="page-item"><a class="page-link" href="?{{get_param}}={{i}}{% if additional_params %}&{{additional_params}}{% endif %}">{{i}}</a></li>
<li class="page-item"><a class="page-link" href="?{{get_param}}={{i}}">{{i}}</a></li>
{% endif %}
{% endif %}
{% endfor %}
{% if paginator.has_next %}
<li class="page-item"><a class="page-link" href="?{{get_param}}={{paginator.next_page_number}}{% if additional_params %}&{{additional_params}}{% endif %}">&raquo;</a></li>
<li class="page-item"><a class="page-link" href="?{{get_param}}={{paginator.next_page_number}}">&raquo;</a></li>
{% else %}
<li class="page-item disabled"><span class="page-link">&raquo;</span></li>
{% endif %}

View File

@@ -1,27 +0,0 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container">
<nav aria-label="breadcrumb" class="fs-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"></li>
{% for crumb in breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'parts-componenttypes-detail' uuid=crumb.id %}">{{crumb.class_name}}</a></li>
{% endfor %}
<li class="breadcrumb-item active" aria-current="page">{{object.class_name}}</li>
</ol>
</nav>
<div class="row">
<div class="col-md">
<h2>Component Type: {{object.class_name}}</h2>
</div>
</div>
</div>
{% endblock content %}
{% block custom_scripts %}
<script type="text/javascript">
</script>
{% endblock custom_scripts %}

View File

@@ -1,46 +0,0 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block content %}
<div class="container">
<div class="row">
<div class="col-md">
<h2>Component Types</h2>
<form action="" method="get">
<div class="input-group mb-3">
<input class="form-control" name="search" type="search" placeholder="Search Component Type..." {% if search_string %}value="{{search_string}}"{% endif %}>
<button type="submit" class="btn btn-primary">
<i class="bi bi-search"></i>
</button>
</div>
</form>
{% include 'paginator.html' with paginator=comptypes get_param='page' aria_label='Component Type Page Navigation' %}
<div class="list-group mb-3">
{% for t in comptypes %}
<a href="{% url 'parts-componenttypes-detail' uuid=t.id %}" class="text-decoration-none">
<li class="list-group-item list-group-item-action d-flex flex-row align-items-center justify-content-between">
<div class="p-2">
{{t}}
</div>
{% if t.passive %}
<div class="p-2">
<span class="text-muted">&nbsp;passive</span>
</div>
{% endif %}
</li>
</a>
{% endfor %}
</div>
{% include 'paginator.html' with paginator=comptypes get_param='page' aria_label='Component Type Page Navigation' %}
</div>
</div>
</div>
{% endblock content %}
{% block custom_scripts %}
<script type="text/javascript">
</script>
{% endblock custom_scripts %}

View File

@@ -39,7 +39,7 @@
<tbody>
<tr>
<td class="align-middle" scope="row">
{{component.name}}{% if key_parameter_string %}<br><span class="text-secondary">{{key_parameter_string}}</span>{% endif %}
{{component.name}}
</td>
<td class="align-middle" >
{% if component.package %}
@@ -119,17 +119,6 @@
<th scope="col"></th>
</thead>
<tbody>
{% for param in package_parameters %}
<td>
<h6 {% if param.parameter_type.parameter_description %} class="accordion-header" data-bs-toggle="collapse" data-bs-target="#collapse-pkg-parameter-desc-{{forloop.counter}}"{% endif %}>
{{param.parameter_type.parameter_name}}
</h6>
</td>
<td>
{{param.resolved_value_as_string}}
</td>
<td><span class="text-secondary">from Package</span></td>
{% endfor %}
{% for param in parameters %}
<tr>
<td>
@@ -159,13 +148,6 @@
</div>
{% endif %}
{% endfor %}
{% for param in package_parameters %}
{% if param.parameter_type.parameter_description %}
<div class="collapse accordion-collapse" id="collapse-pkg-parameter-desc-{{forloop.counter}}" data-bs-parent="#accordion-param-desc">
{{param.parameter_type.parameter_description}}
</div>
{% endif %}
{% endfor %}
</div>
</div>
<div class="col">

View File

@@ -14,10 +14,11 @@
</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>
</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="GET">
<form method="POST">
<div class="row">
<div class="col-sm">
{% crispy advanced_search_form %}
@@ -31,9 +32,6 @@
<input type="submit" name="submit-advanced-search" value="Search" class="btn btn-success">
</div>
</form>
<template id="advanced-search-parameter-template">
{% crispy advanced_search_param_formset.empty_form %}
</template>
</div>
<div class="list-group mb-3">
{% for comp in components %}
@@ -48,12 +46,7 @@
{% endif %}
</div>
<div class="flex-grow-1 ms-3">
<h6 class="mt-0 text-primary">
{{ comp.name }}
{% for key_param in comp.get_key_parameters_as_text %}
{{key_param}}
{% endfor %}
</h6>
<h6 class="mt-0 text-primary">{{ comp.name }}</h6>
{% if comp.package %}
Package: {{comp.package}}<br>
{% endif %}
@@ -72,6 +65,7 @@
</div>
{% include 'parts/modals/edit-component-modal.html' with form=comp_form heading='New Component' open_component_button=True %}
{% include 'parts/modals/import-component-modal.html' with form=import_comp_form %}
{% endblock content %}
{% block custom_scripts %}
@@ -80,5 +74,8 @@
{% if comp_form.errors %}
bootstrap.Modal.getOrCreateInstance(document.getElementById('comp-edit-modal')).show()
{% endif %}
{% if import_comp_form.errors %}
bootstrap.Modal.getOrCreateInstance(document.getElementById('comp-import-modal')).show()
{% endif %}
</script>
{% endblock custom_scripts %}

View File

@@ -0,0 +1,22 @@
{% load static %}
{% load crispy_forms_tags %}
<div class="modal fade" id="add-sub-modal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Storage</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="modal-body">
{{form|crispy}}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<input type="submit" class="btn btn-primary" value="Add Storage" name="submit-add-storage">
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,22 @@
{% load static %}
{% load crispy_forms_tags %}
<div class="modal fade" id="change-modal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Change Storage</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="modal-body">
{{form|crispy}}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<input type="submit" class="btn btn-primary" value="Change Storage" name="submit-change-storage">
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,38 @@
{% load static %}
{% load crispy_forms_tags %}
<div class="modal fade" id="comp-import-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h2>Import Component CSV</h2>
<button type="button"
class="btn"
data-bs-toggle="popover"
data-bs-title="Import Component CSV"
data-bs-html="true"
data-bs-content="The shimatta kenkyusho can import components from a CSV file uploaded to the website.<br>
All parameters are passed as names (e.g. package, manufacturer etc.).<br><br>
Component parameters can be set using a prefix <code>param:</code> followed by the
parameter name. The value is then parsed bysed on the parameter type.<br><br>
Distributor part numbers can be set similarly by prepending <code>distri:</code>.<br><br>
It is also possible to create initial stocks to one storage by passing the
storage uuid and/or the path to the storage (as printed in the breadcrumbs) or
a combination of a storage uuid and the path from this storage.<br><br>
See <a href='{% static 'example/import_csv/import_csv.csv' %}'>example</a> for details.">
<i class="bi bi-info-circle"></i>
</button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="modal-body">
{{form|crispy}}
</div>
<div class="modal-footer">
<input type="submit" name="submit-import-components" class="btn btn-primary" value="Save">
</div>
</form>
</div>
</div>
</div>

View File

@@ -1,46 +0,0 @@
{% load static %}
<div class="modal fade" id="add-sub-modal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add Storage</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="" method="post">
{% csrf_token %}
<div class="modal-body">
<label for="add-storage-name" class="form-label">Storage Name</label>
<div class="input-group has-validation">
<input value="{{form.storage_name.value}}" class="form-control{% if form.storage_name.errors or form.non_field_errors %} is-invalid{% endif %}" id="add-storage-name" name="{{form.storage_name.name}}" type="text" aria-describedby="validationStorageName" required>
<div id="validationStorageName" class="invalid-feedback">
{% for msg in form.storage_name.errors %}
{{msg}}
{% endfor %}
</div>
</div>
<label for="{{form.responsible.id_for_label}}">Responsible</label>
<div class="input-group has-validation dropdown">
<span class="input-group-text" id="add_storage_username_prepend">@</span><input autocomplete="off" data-bs-toggle="dropdown" type="text" value="{{form.responsible.value}}" class="form-control{% if form.responsible.errors or form.non_field_errors %} is-invalid{% endif %}" id="{{form.responsible.id_for_label}}" name="{{form.responsible.name}}" aria-describedby="add_storage_username_prepend validationServerUsernameFeedback" required>
<ul class="dropdown-menu" aria-labelledby="{{form.responsible.id_for_label}}" id="{{form.responsible.id_for_label}}-ac-dropdown">
</ul>
<div id="validationServerUsernameFeedback" class="invalid-feedback">
{% for msg in form.responsible.errors %}
{{msg}}
{% endfor %}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<input type="submit" class="btn btn-primary" value="Add Storage" name="submit-add-storage">
{% if form.non_field_errors %}
{% for error in form.non_field_errors %}
<p class="text-danger text-center">{{ error }}</p>
{% endfor %}
{% endif %}
</div>
</form>
</div>
</div>
</div>

View File

@@ -20,6 +20,9 @@
<div class="row">
{% 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>
</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 %}
@@ -27,13 +30,14 @@
{% 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-pen-fill"></i></button>
</h1>
<div class="list-group">
{% 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>
<h5>{{storage.name}}</h5>
<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>
@@ -69,11 +73,7 @@
{% endif %}
</div>
<div class="flex-grow-1 ms-3">
<h6 class="mt-0 text-primary"><a href="{% url 'parts-components-detail' uuid=stock.component.id %}" class="text-decoration-none">{{ stock.component.name }}</a>
{% for key_param in stock.component.get_key_parameters_as_text %}
{{key_param}}&nbsp;
{% endfor %}
</h6>
<h6 class="mt-0 text-primary"><a href="{% url 'parts-components-detail' uuid=stock.component.id %}" class="text-decoration-none">{{ stock.component.name }}</a></h6>
{% if stock.component.package %}
Package: {{stock.component.package}}<br>
{% endif %}
@@ -113,7 +113,11 @@
{% endfor %}
<!-- Modal for adding a substorage-->
{% with add_storage_form as form %}
{% include 'parts/modals/new-substorage-modal.html' %}
{% include 'parts/modals/add-substorage-modal.html' %}
{% endwith %}
<!-- Modal to change current storag-->
{% with change_storage_form as form %}
{% include 'parts/modals/change-storage-modal.html' %}
{% endwith %}
<!-- Modal for deleting this storage -->
{% with delete_storage_errors as err_msgs %}
@@ -162,18 +166,6 @@ api_get_component_from_id(uuid, function(component){
}
{% endif %}
new AutocompleteText('{{add_storage_form.responsible.id_for_label}}', '{{add_storage_form.responsible.id_for_label}}-ac-dropdown',
function(search, autocomplete_obj) {
api_search_user(search, function(results) {
var usernames = new Array();
console.log(results);
for (var i = 0; i < results.results.length; i++) {
usernames.push(results.results[i].username);
}
console.log(usernames);
autocomplete_obj.show_results(usernames);
}, function(){});
});
</script>
{% endblock custom_scripts %}

View File

@@ -40,7 +40,7 @@
<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>
<h5>{{storage.name}}</h5>
<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>
@@ -54,7 +54,7 @@
<!-- Add storage modal form -->
{% with add_storage_form as form %}
{% include 'parts/modals/new-substorage-modal.html' %}
{% include 'parts/modals/add-substorage-modal.html' %}
{% endwith %}
</div>
@@ -67,18 +67,6 @@
var modal = bootstrap.Modal.getOrCreateInstance(addSubStorageModal);
modal.show();
{% endif %}
new AutocompleteText('{{add_storage_form.responsible.id_for_label}}', '{{add_storage_form.responsible.id_for_label}}-ac-dropdown',
function(search, autocomplete_obj) {
api_search_user(search, function(results) {
var usernames = new Array();
console.log(results);
for (var i = 0; i < results.results.length; i++) {
usernames.push(results.results[i].username);
}
console.log(usernames);
autocomplete_obj.show_results(usernames);
}, function(){});
});
</script>
{% endblock custom_scripts %}

View File

@@ -1,7 +1,12 @@
<div class="dropdown">
<div class="input-group">
{% if custom.prepend %}
<span class="input-group-text" id="{{widget.attrs.id}}-prepend">{{custom.prepend}}</span>
{% endif %}
<input autocomplete="off" id="{{widget.attrs.id}}" data-ac-url="{{custom.search_url}}" data-ac-name-field="{{custom.name_field_name}}" {% if custom.image_field_name %}data-ac-image-field="{{custom.image_field_name}}"{% endif %} data-bs-toggle="dropdown" type="text" placeholder="Search..." class="{{widget.attrs.class}}">
<ul id="{{widget.attrs.id}}-ac-ul" class="dropdown-menu">
</ul>
</div>
<div class="d-flex align-items-center mt-3 mb-3" id="{{widget.attrs.id}}-dflex-container">
{% if custom.current_instance %}
{% if custom.image_field_name %}

1
start_server.sh Executable file
View File

@@ -0,0 +1 @@
podman run -it -e DJANGO_SECRET_KEY=<secret_key> -e DJANGO_ALLOWED_HOST=parts.shimatta.net -e DJANGO_STATIC_ROOT=/var/static -e DJANGO_MEDIA_URL=media -e DJANGO_MEDIA_ROOT=/var/media -e DJANGO_POSTGRESQL_SOCKET=host.docker.internal -e DJANGO_POSTGRESQL_PORT=2345 -e DJANGO_POSTGRESQL_USER=<db_user> -e DJANGO_POSTGRESQL_PW=<db_pass> -v /var/parts/static:/var/static -v /var/parts/media:/var/media -p 8000:8000 --entrypoint /bin/sh localhost/kenkyusho:0.1