Compare commits

..

67 Commits

Author SHA1 Message Date
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
f202896c92 Merge pull request 'issue/16-docker-uid-gid' (#28) from issue/16-docker-uid-gid into develop
Reviewed-on: #28
2024-11-23 21:57:20 +01:00
50cfe0a2c6 Fix filename typo in js file 2024-11-23 17:12:37 +01:00
2d718c5e3a Add new management command to create superuser if not present. Use that command in the entrypoint scripts 2024-11-23 17:08:38 +01:00
57b475cbe1 replace docker-compose with docker compose to make script corss compatible to non arch systems 2024-11-23 17:08:01 +01:00
25b592ee39 Let container run as user and set correct restart policy 2024-11-23 17:06:37 +01:00
08a5f97fd4 Add user ID and GID to example env file 2024-11-23 17:05:55 +01:00
511dacf54a Add restart policy to autostart the containers after boot 2024-11-23 15:27:02 +01:00
0c4f1f9dba Exclude run folder from docker. It is used for local debugging of containers 2024-11-23 15:25:43 +01:00
b873b1fd0f Merge pull request 'added CSRF trusted origin config, added tzdata - needed in debug mode' (#22) from sst/some-weird-stuff-with-docker into develop
Reviewed-on: #22
Reviewed-by: Mario Hüttel <mario.huettel@linux.com>
2024-11-21 00:50:08 +01:00
6e51085210 removed the trusted origin foo again - added proper detection of https 2024-11-19 23:31:56 +01:00
5163834de4 added CSRF trusted origin config, added tzdata - needed in debug mode 2024-11-19 23:09:53 +01:00
841be4f1bb Merge pull request 'correct type conversion in csv import' (#19) from bugfix/import-fuckups into develop
Reviewed-on: #19
2024-11-19 20:42:59 +01:00
0d4019832f Merge branch 'develop' into bugfix/import-fuckups 2024-11-19 20:41:15 +01:00
fed63fd45f Merge pull request 'feature/small-useability-improvements' (#20) from feature/small-useability-improvements into develop
Reviewed-on: #20
Reviewed-by: Mario Hüttel <mario.huettel@linux.com>
2024-11-19 20:32:58 +01:00
d10c48f2b9 fixed usage of input fields 2024-11-19 00:39:15 +01:00
a54cd33880 Merge pull request 'mhu/4-port-to-django-5.1 Port to recent Django version' (#18) from mhu/4-port-to-django-5.1 into develop
Reviewed-on: #18
2024-11-19 00:03:22 +01:00
7c1465428f automatically select first finding in autocomplete fields on enter 2024-11-18 23:55:38 +01:00
9afa7d709b automatically select the search input when the start of a scanned uuid is detected 2024-11-18 23:55:12 +01:00
f2ea45cd19 correct type conversion in csv import 2024-11-18 23:08:34 +01:00
7678e6ad88 remove unnecessary uuid import from distributor views 2024-11-18 21:31:25 +01:00
5ce7d99db2 Fix import oder in package views 2024-11-18 21:28:37 +01:00
5109f8d094 Fixup imports in storage/stock views 2024-11-18 21:27:21 +01:00
6d0ec7e448 Fixup imports in package views 2024-11-18 21:19:59 +01:00
8422ff0eeb Fixup imports in manufacturer views 2024-11-18 21:17:31 +01:00
1f3ed7f8ed Fixup imports of distributor views 2024-11-18 21:15:05 +01:00
b3f8041f08 Fixup import of component_views.py 2024-11-18 21:08:55 +01:00
173f0e3c91 Fix trailing whitespace 2024-11-18 20:58:39 +01:00
a2f96ed4f0 Unclutter the views and move them to separate files 2024-11-18 20:56:23 +01:00
741a634546 Update reqirements.txt for Django 5.1.3 2024-11-18 20:19:38 +01:00
74e0be71b9 Upgrade to Dajngo 5.1.3 2024-11-18 20:17:55 +01:00
ce00f018fd Merge branch 'develop' into mhu/4-port-to-django-5.1 2024-11-18 20:08:42 +01:00
6c6ef9e5fc Move vscode folder up by one to include everything in the workspace 2024-11-18 19:53:09 +01:00
b9d788935d Merge pull request 'extended api with read only fields to improve label writing' (#17) from feature/expand-components-in-storage-api into develop
Reviewed-on: #17
Reviewed-by: Mario Hüttel <mario.huettel@linux.com>
2024-11-17 23:42:24 +01:00
aa08de0d10 extended api with read only fields to improve label writing 2024-11-17 23:28:15 +01:00
4ee55c1675 Change pencil icon to pen with square to be consistent with the rest of the app 2024-11-17 23:06:06 +01:00
9adafeb882 Finish broken sentence in comment 2024-11-17 23:03:43 +01:00
f1e366c7af Merge pull request 'added CSV upload to enable fast creation of similar components' (#14) from feature/csv-import into develop
Reviewed-on: #14
Reviewed-by: Mario Hüttel <mario.huettel@linux.com>
2024-11-17 21:55:43 +01:00
390eaac396 Merge branch 'develop' into feature/csv-import 2024-11-17 21:44:53 +01:00
3d63357534 made the readme and example for the CSV import accessible from the form directly 2024-11-17 21:44:30 +01:00
1c08f433d4 Merge pull request 'Implement #11 mhu/11-add-docker-compose-setup: Add compose setup' (#12) from mhu/11-add-docker-compose-setup into develop
Reviewed-on: #12
Reviewed-by: Stefan Strobel <stefan.strobel@shimatta.net>
2024-11-17 20:19:28 +01:00
8a8f2cdea4 Fix typo in comment 2024-11-17 20:08:53 +01:00
20a83c7e91 added CSV upload to enable fast creation of similar components 2024-11-17 19:54:15 +01:00
66f4eea77d Update env file example to contain Volume mount point for DB folder 2024-11-17 19:36:27 +01:00
561b2aed27 Add the /healthcheck path which returns an HTTP200 OK response for checking the helath of the container after startup 2024-11-17 19:10:28 +01:00
a59fad4866 Add self hosted entrypoint file for easier debugging which will use the integrated django server to serve all files 2024-11-17 17:33:45 +01:00
144a65ee05 Fix typo in eample env file 2024-11-17 16:52:18 +01:00
fc5fa9f740 Fix startup issue with postgres on first start. Implement health check of postgres and delay django container until it's healthy. 2024-11-17 16:50:10 +01:00
d2ce635f05 Add compose setup
* Use a dedicated postgresql container as database
* Controlled via .env file
* Ecample script for interactive container startup
2024-11-17 16:37:23 +01:00
64d0a1bfb3 Make gunicorn listen to all incoming IPs on Port 8000. This is necessary to use a spearate net with the postgres container. 2024-11-17 16:36:36 +01:00
b057fedb5f Add optional env variable DJANGO_FORCE_DEV_MODE to force production settings to dev mode for debugging 2024-11-17 16:34:58 +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
56 changed files with 2049 additions and 1857 deletions

2
.dockerignore Normal file
View File

@ -0,0 +1,2 @@
start_server.sh
run/*

38
.env.example Normal file
View File

@ -0,0 +1,38 @@
####################################################################################################
# Example configuration. Must be edited and copied to ".env" next to the compose.yaml
####################################################################################################
# User id to use for the web application. This determines the user id, the media and static files are written to the volumes.
# Make sure the user has rw access to these directories.
DJANGO_USER_ID=1000
# Group id to use for the web application
DJANGO_USER_GID=1000
# Path to to mount as the directory for static data. Must be served by a webserver on the /static path
DJANGO_STATIC_VOL=/path/to/static/root
# Path to the media root. Must be served by a webserver on the media URL
DJANGO_MEDIA_VOL=/path/to/media/root
# folder for DB
PGDATA_VOL=/path/to/pgdata
# Port to serve the App
PORT=8000
# Secret Key. Must be edited before deployment
DJANGO_SECRET_KEY=534hj5jgh4365ghj35jgh245jgh24
# Allowed host to be accessed. Currently it is only possible to specify a single URL
DJANGO_ALLOWED_HOST=lab.example.com
# Media URL for images and other content
DJANGO_MEDIA_URL=media.lab.example.com/
# DO NOT SET DEBUG MODE IN PRODUCTION
# DJANGO_FORCE_DEV_MODE=True
# Set this password if you want to use a custom postgres password. The db should be confined inside the docker network.
# Using the standard PW is therefore not a problem
# DJANGO_POSTGRESQL_PW=myfancynewpassword123donotsharemewithanyone

3
.gitignore vendored
View File

@ -125,3 +125,6 @@ dmypy.json
myenv/* myenv/*
run/*
.env

View File

@ -6,10 +6,9 @@
"configurations": [ "configurations": [
{ {
"name": "Python: Django", "name": "Python: Django",
"type": "python", "type": "debugpy",
"request": "launch", "request": "launch",
"pythonPath": "${workspaceFolder}/../myenv/bin/python", "program": "${workspaceFolder}/shimatta_kenkyusho/manage.py",
"program": "${workspaceFolder}/manage.py",
"args": [ "args": [
"runserver", "runserver",
"0.0.0.0:8000" "0.0.0.0:8000"

View File

@ -1,17 +1,6 @@
FROM ubuntu:22.04 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 curl
RUN apt-get update && apt-get install -y libpq-dev python3 python3-poetry nginx COPY . /home/shimatta/kenkyusho
COPY shimatta_kenkyusho /app/shimatta_kenkyusho WORKDIR /home/shimatta/kenkyusho
COPY poetry.lock /app/ RUN python3 -m venv /home/shimatta/kenkyusho/.venv && . /home/shimatta/kenkyusho/.venv/bin/activate && pip install -r requirements.txt
COPY pyproject.toml /app/ ENTRYPOINT ["/home/shimatta/kenkyusho/entrypoint.sh"]
RUN systemctl enable nginx
ARG UNAME=django
ARG UID=1000
ARG GID=1000
RUN groupadd -g $GID -o $UNAME
RUN useradd -m -u $UID -g $GID -o -s /bin/bash $UNAME
USER $UNAME
WORKDIR "/app"
RUN poetry install

54
compose.yaml Normal file
View File

@ -0,0 +1,54 @@
x-op-restart-policy: &restart_policy
restart: unless-stopped
services:
shimatta-kenkyusho-web:
<<: *restart_policy
build: .
user: "${DJANGO_USER_ID}:${DJANGO_USER_GID}"
volumes:
- "${DJANGO_STATIC_VOL:-./run/static}:/var/static"
- "${DJANGO_MEDIA_VOL:-./run/media}:/var/media"
environment:
DJANGO_POSTGRESQL_PW: "${DJANGO_POSTGRESQL_PW:-p4ssw0rd}"
DJANGO_POSTGRESQL_USER: "postgres"
DJANGO_SECRET_KEY: "${DJANGO_SECRET_KEY}"
DJANGO_ALLOWED_HOST: "${DJANGO_ALLOWED_HOST}"
DJANGO_STATIC_ROOT: "/var/static"
DJANGO_MEDIA_URL: "${DJANGO_MEDIA_URL}"
DJANGO_MEDIA_ROOT: "/var/media"
DJANGO_POSTGRESQL_SOCKET: "shimatta-kenkyusho-db"
DJANGO_POSTGRESQL_PORT: "5432"
DJANGO_FORCE_DEV_MODE: ${DJANGO_FORCE_DEV_MODE:-False}
ports:
- "${PORT}:8000"
networks:
- backendnet
depends_on:
shimatta-kenkyusho-db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -f localhost:8000/healthcheck"]
interval: 5s
timeout: 5s
retries: 5
start_period: 30s
shimatta-kenkyusho-db:
<<: *restart_policy
image: postgres:16.5-alpine
environment:
POSTGRES_PASSWORD: "${DJANGO_POSTGRESQL_PW:-p4ssw0rd}"
POSTGRES_DB: "shimatta_kenkyusho"
volumes:
- "${PGDATA_VOL:-./run/pgdata}:/var/lib/postgresql/data"
networks:
- backendnet
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
networks:
backendnet:

8
entrypoint.sh Executable file
View File

@ -0,0 +1,8 @@
#!/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
python manage.py create_kenkyusho_admin_user --settings shimatta_kenkyusho.settings_production
gunicorn -w 4 --bind 0.0.0.0:8000 shimatta_kenkyusho.wsgi:application

7
entrypoint_self_hosted.sh Executable file
View File

@ -0,0 +1,7 @@
#!/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 create_kenkyusho_admin_user --settings shimatta_kenkyusho.settings_production
python manage.py runserver 0.0.0.0:8000 --settings shimatta_kenkyusho.settings_production

226
poetry.lock generated
View File

@ -1,226 +0,0 @@
[[package]]
name = "annotated-types"
version = "0.5.0"
description = "Reusable constraint types to use with typing.Annotated"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "asgiref"
version = "3.7.2"
description = "ASGI specs, helper code, and adapters"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
typing-extensions = {version = ">=4", markers = "python_version < \"3.11\""}
[package.extras]
tests = ["pytest", "pytest-asyncio", "mypy (>=0.800)"]
[[package]]
name = "crispy-bootstrap5"
version = "0.7"
description = "Bootstrap5 template pack for django-crispy-forms"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
django = ">=3.2"
django-crispy-forms = ">=1.13.0"
[package.extras]
test = ["pytest", "pytest-django"]
[[package]]
name = "django"
version = "4.2.5"
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
category = "main"
optional = false
python-versions = ">=3.8"
[package.dependencies]
asgiref = ">=3.6.0,<4"
sqlparse = ">=0.3.1"
tzdata = {version = "*", markers = "sys_platform == \"win32\""}
[package.extras]
argon2 = ["argon2-cffi (>=19.1.0)"]
bcrypt = ["bcrypt"]
[[package]]
name = "django-crispy-forms"
version = "2.0"
description = "Best way to have Django DRY forms"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
django = ">=3.2"
[[package]]
name = "django-filter"
version = "23.2"
description = "Django-filter is a reusable Django application for allowing users to filter querysets dynamically."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
Django = ">=3.2"
[[package]]
name = "django-qr-code"
version = "3.1.1"
description = "An application that provides tools for displaying QR codes on your Django site."
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
django = ">=3.2"
pydantic = "*"
pytz = "*"
segno = ">=1.5"
[[package]]
name = "djangorestframework"
version = "3.14.0"
description = "Web APIs for Django, made easy."
category = "main"
optional = false
python-versions = ">=3.6"
[package.dependencies]
django = ">=3.0"
pytz = "*"
[[package]]
name = "pillow"
version = "9.5.0"
description = "Python Imaging Library (Fork)"
category = "main"
optional = false
python-versions = ">=3.7"
[package.extras]
docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-removed-in", "sphinxext-opengraph"]
tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
[[package]]
name = "psycopg2"
version = "2.9.7"
description = "psycopg2 - Python-PostgreSQL Database Adapter"
category = "main"
optional = false
python-versions = ">=3.6"
[[package]]
name = "pydantic"
version = "2.3.0"
description = "Data validation using Python type hints"
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
annotated-types = ">=0.4.0"
pydantic-core = "2.6.3"
typing-extensions = ">=4.6.1"
[package.extras]
email = ["email-validator (>=2.0.0)"]
[[package]]
name = "pydantic-core"
version = "2.6.3"
description = ""
category = "main"
optional = false
python-versions = ">=3.7"
[package.dependencies]
typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
[[package]]
name = "pytz"
version = "2023.3"
description = "World timezone definitions, modern and historical"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "segno"
version = "1.5.2"
description = "QR Code and Micro QR Code generator for Python 2 and Python 3"
category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
[[package]]
name = "sqlparse"
version = "0.4.4"
description = "A non-validating SQL parser."
category = "main"
optional = false
python-versions = ">=3.5"
[package.extras]
dev = ["flake8", "build"]
doc = ["sphinx"]
test = ["pytest", "pytest-cov"]
[[package]]
name = "typing-extensions"
version = "4.7.1"
description = "Backported and Experimental Type Hints for Python 3.7+"
category = "main"
optional = false
python-versions = ">=3.7"
[[package]]
name = "tzdata"
version = "2023.3"
description = "Provider of IANA time zone data"
category = "main"
optional = false
python-versions = ">=2"
[[package]]
name = "uwsgi"
version = "2.0.22"
description = "The uWSGI server"
category = "main"
optional = false
python-versions = "*"
[metadata]
lock-version = "1.1"
python-versions = "^3.10"
content-hash = "6e065bbdff5da7659973fa67491d6093c83b8e57d4e8dde6968ee846d382bf65"
[metadata.files]
annotated-types = []
asgiref = []
crispy-bootstrap5 = []
django = []
django-crispy-forms = []
django-filter = []
django-qr-code = []
djangorestframework = []
pillow = []
psycopg2 = []
pydantic = []
pydantic-core = []
pytz = []
segno = []
sqlparse = []
typing-extensions = []
tzdata = []
uwsgi = []

View File

@ -1,23 +0,0 @@
[tool.poetry]
name = "shimatta-kenkyusho"
version = "0.1.0"
description = "Shimatta Lab Inventory System"
authors = ["Mario Hüttel <mario.huettel@gmx.net>"]
license = "GPLv2"
[tool.poetry.dependencies]
python = "^3.10"
Django = "~4.2.5"
django-qr-code = "^3.1.1"
djangorestframework = "^3.14.0"
django-filter = "^23.2"
psycopg2 = "^2.9.7"
Pillow = "^9.3.0"
django-crispy-forms = "^2.0"
crispy-bootstrap5 = "^0.7"
uwsgi = "^2.0.22"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

39
requirements.txt Normal file
View File

@ -0,0 +1,39 @@
annotated-types==0.7.0
asgiref==3.8.1
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==24.3
django-qr-code==4.1.0
django-rest-framework==0.1.0
django-tex==1.1.10
djangorestframework==3.15.2
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
pipdeptree==2.23.4
platformdirs==4.3.6
psycopg2-binary==2.9.9
pydantic==2.9.2
pydantic_core==2.23.4
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
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 rest_framework import serializers
from parts import models as parts_models from parts import models as parts_models
class UserSerializer(serializers.HyperlinkedModelSerializer): class UserSerializer(serializers.HyperlinkedModelSerializer):
class Meta: class Meta:
model = User model = get_user_model()
fields = ['username', 'email', 'first_name', 'last_name', 'groups'] fields = ['id', 'username', 'email', 'first_name', 'last_name', 'groups']
class GroupSerializer(serializers.HyperlinkedModelSerializer): class GroupSerializer(serializers.HyperlinkedModelSerializer):
class Meta: class Meta:
@ -23,22 +24,43 @@ class PackageSerializer(serializers.HyperlinkedModelSerializer):
model = parts_models.Package model = parts_models.Package
fields = '__all__' fields = '__all__'
class StorageSerializer(serializers.HyperlinkedModelSerializer): class ComponentParameterSerializer(serializers.HyperlinkedModelSerializer):
full_path = serializers.ReadOnlyField(source='get_full_path') id = serializers.ReadOnlyField()
ro_parameter_type = serializers.ReadOnlyField(source='parameter_type.parameter_name')
class Meta: class Meta:
model = parts_models.Storage model = parts_models.ComponentParameter
fields = ['url', 'id', 'name', 'parent_storage', 'responsible', 'full_path'] fields = '__all__'
class ComponentDistributorNumSerializer(serializers.HyperlinkedModelSerializer):
id = serializers.ReadOnlyField()
class Meta:
model = parts_models.DistributorNum
fields = '__all__'
class ComponentSerializer(serializers.HyperlinkedModelSerializer): class ComponentSerializer(serializers.HyperlinkedModelSerializer):
package_data = PackageSerializerNoLink(source='package', read_only=True) package_data = PackageSerializerNoLink(source='package', read_only=True)
ro_manufacturer_name = serializers.ReadOnlyField(source='manufacturer.name') ro_manufacturer_name = serializers.ReadOnlyField(source='manufacturer.name')
ro_image = serializers.ReadOnlyField(source='get_resolved_image') ro_image = serializers.ReadOnlyField(source='get_resolved_image')
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)
class Meta: class Meta:
model = parts_models.Component model = parts_models.Component
fields = ['url', 'id', 'name', 'package_data', 'package', 'pref_distri', 'image', 'manufacturer', 'ro_manufacturer_name', 'ro_image'] fields = ['url',
'id',
'name',
'package_data',
'package',
'pref_distri',
'image',
'manufacturer',
'component_type',
'ro_manufacturer_name',
'ro_image',
'ro_component_type',
'ro_parameters',
'ro_distributor_numbers']
class StockSerializer(serializers.HyperlinkedModelSerializer): class StockSerializer(serializers.HyperlinkedModelSerializer):
id = serializers.ReadOnlyField() id = serializers.ReadOnlyField()
@ -50,6 +72,22 @@ class StockSerializer(serializers.HyperlinkedModelSerializer):
model = parts_models.Stock model = parts_models.Stock
fields = '__all__' fields = '__all__'
class StockSerializerExpandComponent(StockSerializer):
ro_component = ComponentSerializer(read_only=True, source='component')
class StorageSerializer(serializers.HyperlinkedModelSerializer):
full_path = serializers.ReadOnlyField(source='get_full_path')
class Meta:
model = parts_models.Storage
fields = ['url', 'id', 'name', 'verbose_name', 'parent_storage', 'responsible', 'template', 'full_path']
class StorageSerializerStocksExpanded(StorageSerializer):
ro_stocks = StockSerializerExpandComponent(many=True, read_only=True, source='stock_set')
class Meta(StorageSerializer.Meta):
fields = StorageSerializer.Meta.fields + ['ro_stocks']
class DistributorSerializer(serializers.HyperlinkedModelSerializer): class DistributorSerializer(serializers.HyperlinkedModelSerializer):
id = serializers.ReadOnlyField() id = serializers.ReadOnlyField()
class Meta: class Meta:

View File

@ -6,7 +6,10 @@ router = routers.DefaultRouter()
router.register(r'users', UserViewSet) router.register(r'users', UserViewSet)
router.register(r'groups', GroupViewSet) router.register(r'groups', GroupViewSet)
router.register(r'parts/storages', PartsStorageViewSet) 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/components', PartsComponentViewSet)
router.register(r'parts/component-parameters', PartsComponentParameterViewSet)
router.register(r'parts/component-distributor-numbers', PartsComponentDistributorNumViewSet)
router.register(r'parts/stocks', PartsStockViewSet) router.register(r'parts/stocks', PartsStockViewSet)
router.register(r'parts/packages', PartsPackageViewSet) router.register(r'parts/packages', PartsPackageViewSet)
router.register(r'parts/distributors', PartsDistributorviewSet) router.register(r'parts/distributors', PartsDistributorviewSet)
@ -17,5 +20,5 @@ router.register(r'parts/component-param-types', PartsComponentParameterTypeViewS
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),
re_path(r'^token-auth/', ObtainExpiringAuthToken.as_view()), re_path(r'^token-auth/', ObtainExpiringAuthToken.as_view()),
re_path(r'^token-logout/', TokenLogout.as_view()), re_path(r'^token-logout/', TokenLogout.as_view()),
] ]

View File

@ -1,5 +1,6 @@
from django.shortcuts import render 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 django.core.exceptions import ObjectDoesNotExist
from rest_framework import viewsets, status from rest_framework import viewsets, status
from rest_framework import permissions from rest_framework import permissions
@ -25,7 +26,7 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet):
""" """
API endpoint that allows users to be viewed or edited. API endpoint that allows users to be viewed or edited.
""" """
queryset = User.objects.all() queryset = get_user_model().objects.all()
serializer_class = UserSerializer serializer_class = UserSerializer
permission_classes = [permissions.IsAuthenticated] permission_classes = [permissions.IsAuthenticated]
filter_backends = [filters.SearchFilter] filter_backends = [filters.SearchFilter]
@ -41,11 +42,24 @@ class GroupViewSet(viewsets.ReadOnlyModelViewSet):
class PartsStorageViewSet(viewsets.ModelViewSet): class PartsStorageViewSet(viewsets.ModelViewSet):
queryset = parts_models.Storage.objects.all() queryset = parts_models.Storage.objects.all()
serializer_class = StorageSerializer
permission_classes = [permissions.DjangoModelPermissions] permission_classes = [permissions.DjangoModelPermissions]
filter_backends = [django_filters.rest_framework.DjangoFilterBackend] filter_backends = [django_filters.rest_framework.DjangoFilterBackend]
search_fields = ['id', 'name', 'parent_storage']
filterset_fields = ['id', 'name', 'parent_storage'] filterset_fields = ['id', 'name', 'parent_storage']
def get_serializer_class(self):
if self.request.GET.get('expand_stocks'):
return StorageSerializerStocksExpanded
return StorageSerializer
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): class PartsComponentViewSet(viewsets.ModelViewSet):
queryset = parts_models.Component.objects.all() queryset = parts_models.Component.objects.all()
serializer_class = ComponentSerializer serializer_class = ComponentSerializer
@ -54,6 +68,22 @@ class PartsComponentViewSet(viewsets.ModelViewSet):
search_fields = ['id', 'name', 'package__name', 'manufacturer__name'] search_fields = ['id', 'name', 'package__name', 'manufacturer__name']
filterset_fields = ['id', 'name'] filterset_fields = ['id', 'name']
class PartsComponentParameterViewSet(viewsets.ModelViewSet):
queryset = parts_models.ComponentParameter.objects.all()
serializer_class = ComponentParameterSerializer
permission_classes = [permissions.DjangoModelPermissions]
filter_backends = [filters.SearchFilter, django_filters.rest_framework.DjangoFilterBackend]
search_fields = ['id', 'parameter_type__parameter_name']
filterset_fields = ['id']
class PartsComponentDistributorNumViewSet(viewsets.ModelViewSet):
queryset = parts_models.DistributorNum.objects.all()
serializer_class = ComponentDistributorNumSerializer
permission_classes = [permissions.DjangoModelPermissions]
filter_backends = [filters.SearchFilter, django_filters.rest_framework.DjangoFilterBackend]
search_fields = ['id', 'distributor', 'distributor_part_number']
filterset_fields = ['id']
class PartsComponentTypeViewSet(viewsets.ModelViewSet): class PartsComponentTypeViewSet(viewsets.ModelViewSet):
queryset = parts_models.ComponentType.objects.all() queryset = parts_models.ComponentType.objects.all()
serializer_class = ComponentTypeSerializer serializer_class = ComponentTypeSerializer

View File

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

View File

@ -1,23 +1,27 @@
from django import forms 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.core.exceptions import ValidationError
from django.forms import widgets
from parts import models as parts_models from parts import models as parts_models
from shimatta_modules.EngineeringNumberConverter import EngineeringNumberConverter from shimatta_modules.EngineeringNumberConverter import EngineeringNumberConverter
import uuid import uuid
from django.urls import reverse from django.urls import reverse
from .qr_parser import QrCodeValidator
from crispy_forms.helper import FormHelper 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): class AutoCompleteWidget(widgets.Input):
template_name = 'widgets/autocomplete-foreign-key.html' 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) super().__init__(*args, **kwargs)
self.image_field_name = image_field_name self.image_field_name = image_field_name
self.foreign_model = foreign_model self.foreign_model = foreign_model
self.api_search_url = api_search_url self.api_search_url = api_search_url
self.name_field_name = name_field_name self.name_field_name = name_field_name
self.prepend = prepend
def get_context(self, name, value, attrs): def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs) context = super().get_context(name, value, attrs)
@ -47,14 +51,25 @@ class AutoCompleteWidget(widgets.Input):
'current_instance': instance, 'current_instance': instance,
'image': image, 'image': image,
'name_field_name': self.name_field_name, 'name_field_name': self.name_field_name,
'prepend': self.prepend,
'name': display_name, 'name': display_name,
} }
return context return context
class AutocompleteForeingKeyField(forms.UUIDField): 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) 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 self.foreign_model = foreign_model
@ -73,14 +88,26 @@ class AutocompleteForeingKeyField(forms.UUIDField):
except self.foreign_model.DoesNotExist: except self.foreign_model.DoesNotExist:
raise ValidationError('Given element does not exist') raise ValidationError('Given element does not exist')
return obj return obj
class MyTestForm(forms.Form): class MyTestForm(forms.Form):
pass pass
class AddSubStorageForm(forms.Form): class ChangeStorageForm(forms.Form):
storage_name = forms.CharField(label="storage_name", initial='') storage_name = forms.CharField(label="Name", initial='')
responsible = forms.CharField(label='responsible_user') 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): class DeleteStockForm(forms.Form):
stock_uuid = forms.UUIDField() stock_uuid = forms.UUIDField()
@ -187,7 +214,10 @@ class ComponentForm(forms.ModelForm):
class Meta: class Meta:
model = parts_models.Component model = parts_models.Component
fields = '__all__' fields = '__all__'
class ImportComponentForm(forms.Form):
csv_file = forms.FileField(label="CSV File")
class PackageForm(forms.ModelForm): class PackageForm(forms.ModelForm):
class Meta: class Meta:
model = parts_models.Package model = parts_models.Package
@ -244,7 +274,6 @@ class AdvancedComponentSearchForm(forms.Form):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_tag = False self.helper.form_tag = False
self.helper.disable_csrf = True
self.helper.layout = Layout( self.helper.layout = Layout(
Row( Row(
Column('name'), Column('name'),
@ -263,53 +292,20 @@ class AdvancedComponentSearchForm(forms.Form):
), ),
) )
PARAMETER_COMPARISON_TYPES = (
('eq', '=='),
('lte', '<='),
('gte', '>='),
)
class ComponentParameterSearchForm(forms.Form): 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') 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) 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): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_tag = False self.helper.form_tag = False
self.helper.disable_csrf = True
self.helper.layout = Layout( self.helper.layout = Layout(
Row( Row(
Column('parameter'), Column('parameter'),
Column('compare_method'),
Column('value') 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): 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') 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) value = forms.CharField(required=True, max_length=256)
@ -343,3 +339,12 @@ class ComponentParameterCreateForm(forms.Form):
text_value = '' text_value = ''
value = self.cleaned_data['number_value'] value = self.cleaned_data['number_value']
parts_models.ComponentParameter.objects.create(parameter_type=param_type, component=component, value=value, text_value=text_value) parts_models.ComponentParameter.objects.create(parameter_type=param_type, component=component, value=value, text_value=text_value)
class QrSearchForm(forms.Form):
my_qr_validator = QrCodeValidator()
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,23 @@
from django.core.management.base import BaseCommand, CommandParser
from django.contrib.auth import get_user_model
class Command(BaseCommand):
help = "Create a default superuser if no superuser is already present. This aids automatic deployment inside a container."
def add_arguments(self, parser: CommandParser):
parser.add_argument('--user',
help='Username to create if no admin account is present',
default='admin')
parser.add_argument('--password',
help='Password to set for newly created user. Ignored, if any admin user is already present',
default='admin')
def handle(self, *args, **options):
User = get_user_model()
# Query if there is any admin user
if not User.objects.filter(is_superuser=True).exists():
self.stdout.write(f'No superuser present. Creating {options['user']} with supplied password')
User.objects.create_superuser(username=options['user'], password=options['password'])
else:
self.stdout.write('At least one superuser already exists. Skipping superuser creation')

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 django.db import models
from shimatta_modules import RandomFileName from shimatta_modules import RandomFileName
from django.db.models import F, Sum 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.exceptions import ValidationError
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from django.dispatch import receiver from django.dispatch import receiver
@ -47,35 +47,11 @@ class ComponentType(models.Model):
class Meta: class Meta:
ordering = ['class_name'] ordering = ['class_name']
class_name = models.CharField(max_length=50, unique=True) 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() passive = models.BooleanField()
possible_parameter = models.ManyToManyField(ComponentParameterType, blank=True) 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): def __str__(self):
return self.get_full_path() return '[' + self.class_name + ']'
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
class Storage(models.Model): class Storage(models.Model):
class Meta: class Meta:
@ -86,8 +62,17 @@ class Storage(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, unique=True) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, unique=True)
name = models.CharField(max_length=100, validators=[storage_name_validator]) 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) 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): def get_path_components(self):
chain = [] chain = []
@ -137,6 +122,29 @@ class Storage(models.Model):
sum = 0 sum = 0
return sum 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): def save(self, *args, **kwargs):
self.validate_unique() self.validate_unique()
super(Storage, self).save(*args, **kwargs) super(Storage, self).save(*args, **kwargs)
@ -168,37 +176,6 @@ class Package(models.Model):
def __str__(self): def __str__(self):
return self.name 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 Manufacturer(models.Model):
class Meta: class Meta:
@ -261,34 +238,7 @@ class Component(models.Model):
sum = 0 sum = 0
return sum 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 ComponentParameter(models.Model):
class Meta: class Meta:
unique_together = ('component', 'parameter_type') unique_together = ('component', 'parameter_type')
@ -313,12 +263,10 @@ class ComponentParameter(models.Model):
if my_type == 'E' or my_type == 'I': if my_type == 'E' or my_type == 'I':
# Engineering float number # Engineering float number
(num, prefix) = NumConv.number_to_engineering(self.value, it_unit=(True if my_type=='I' else False)) (num, prefix) = NumConv.number_to_engineering(self.value, it_unit=(True if my_type=='I' else False))
num = round(num, 3) return f'{num:.3f} {prefix}{self.parameter_type.unit}'
return f'{num} {prefix}{self.parameter_type.unit}'
elif my_type == 'N': elif my_type == 'N':
# Standard float number # Standard float number
num = round(self.value, 3) return f'{self.value:.3f} {self.parameter_type.unit}'
return f'{num} {self.parameter_type.unit}'
elif my_type == 'F': elif my_type == 'F':
return self.text_value return self.text_value
@ -372,7 +320,7 @@ class DistributorNum(models.Model):
class QrPrintJob(models.Model): class QrPrintJob(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False, unique=True) 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) qrdata = models.CharField(max_length=256, blank=True, null=False)
text = models.TextField(max_length=512, 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)]) print_count = models.PositiveIntegerField(default=1, validators=[MinValueValidator(0), MaxValueValidator(32)])
@ -419,4 +367,26 @@ def auto_delete_file_on_change(sender, instance, **kwargs):
if not old_file: if not old_file:
return True return True
if os.path.isfile(old_file.path): if os.path.isfile(old_file.path):
os.remove(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.
"""
# 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,
is_template=False,
template=sub_storage)

View File

@ -4,7 +4,6 @@ from . import views as parts_views
urlpatterns = [ urlpatterns = [
path('', parts_views.MainView.as_view(), name='parts-main'), path('', parts_views.MainView.as_view(), name='parts-main'),
path('components/', parts_views.ComponentView.as_view(), name='parts-components'), 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('packages/', parts_views.PackageView.as_view(), name='parts-packages'),
path('distributors/', parts_views.DistributorView.as_view(), name='parts-distributors'), path('distributors/', parts_views.DistributorView.as_view(), name='parts-distributors'),
path('stocks/', parts_views.StockView.as_view(), name='parts-stocks'), path('stocks/', parts_views.StockView.as_view(), name='parts-stocks'),
@ -15,7 +14,7 @@ urlpatterns = [
path('components/<slug:uuid>/', parts_views.ComponentDetailView.as_view(), name='parts-components-detail'), path('components/<slug:uuid>/', parts_views.ComponentDetailView.as_view(), name='parts-components-detail'),
path('packages/<slug:uuid>/', parts_views.PackageDetailView.as_view(), name='parts-packages-detail'), path('packages/<slug:uuid>/', parts_views.PackageDetailView.as_view(), name='parts-packages-detail'),
path('distributors/<slug:uuid>/', parts_views.DistributorDetailView.as_view(), name='parts-distributors-detail'), 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/', parts_views.ManufacturersView.as_view(), name='parts-manufacturers'),
path("manufacturers/<slug:uuid>/", parts_views.ManufacturerDetailViewSet.as_view(), name='parts-manufacturers-detail'), path("manufacturers/<slug:uuid>/", parts_views.ManufacturerDetailView.as_view(), name='parts-manufacturers-detail'),
path("componenttypes/<slug:uuid>/", parts_views.ComponentTypeDetailView.as_view(), name='parts-componenttypes-detail'), path("healthcheck/", parts_views.health_check_view, name='parts-health-check'),
] ]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,6 @@
from .component_views import *
from .distributor_views import *
from .generic_views import *
from .manufacturer_views import *
from .package_views import *
from .storage_views import *

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.parameter_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

@ -0,0 +1,272 @@
import uuid
from django.shortcuts import redirect
from django.urls import reverse
from django.views.generic import TemplateView, DetailView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.paginator import Paginator
from django.db.models import Q
from django.forms import formset_factory
from django.db import IntegrityError
from django.db.models import ProtectedError
from ..models import Stock, Component, ComponentParameter, DistributorNum
from ..forms import *
from .component_import import import_components_from_csv
from .generic_views import BaseTemplateMixin
ParameterSearchFormSet = formset_factory(ComponentParameterSearchForm, extra=1)
# Create your views here.
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.all()
if not search_string:
return queryset
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))
return queryset
def get_component_queryset_from_advanced_search(self, cleaned_data):
queryset = Component.objects.all()
if cleaned_data['name']:
queryset = queryset.filter(Q(name__icontains=cleaned_data['name']))
if cleaned_data['package']:
queryset = queryset.filter(package=cleaned_data['package'])
if cleaned_data['package_pin_count']:
queryset = queryset.filter(package__pin_count=cleaned_data['package_pin_count'])
if cleaned_data['component_type']:
queryset = queryset.filter(component_type=cleaned_data['component_type'])
if cleaned_data['distributor_num']:
if cleaned_data['distributor']:
distri = cleaned_data['distributor']
queryset = queryset.filter(Q(distributornum__distributor_part_number__icontains=cleaned_data['distributor_num']) & Q(distributornum__distributor=distri))
else:
queryset = queryset.filter(Q(distributornum__distributor_part_number__icontains=cleaned_data['distributor_num']))
if cleaned_data['manufacturer']:
queryset = queryset.filter(manufacturer=cleaned_data['manufacturer'])
return queryset
def get_context_data_int(self, advanced_search, parameter_formset : ParameterSearchFormSet, **kwargs):
context = super().get_context_data(**kwargs)
comp_page_num = self.request.GET.get('comp_page', default=1)
if advanced_search and parameter_formset:
search = None
context['advanced_search_shown'] = True
context['advanced_search_form'] = advanced_search
context['advanced_search_param_formset'] = parameter_formset
if advanced_search.is_valid():
paginator_queryset = self.get_component_queryset_from_advanced_search(
advanced_search.cleaned_data)
else:
paginator_queryset = Component.objects.all()
if parameter_formset.is_valid():
# Process parameters
pass
else:
search = self.request.GET.get('search', default=None)
paginator_queryset = self.get_component_query_set(search)
comp_paginator = Paginator(paginator_queryset, self.default_page_size)
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):
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)
new_component = None
if cform.is_valid():
new_component = cform.save()
context = self.get_context_data(**kwargs)
if not cform.is_valid():
context['comp_form'] = cform
if open and new_component:
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 ComponentDetailView(LoginRequiredMixin, BaseTemplateMixin, DetailView):
template_name = 'parts/components-detail.html'
model = Component
pk_url_kwarg = 'uuid'
base_title = ''
navbar_selected = 'Components'
def get_context_data(self, **kwargs):
self.base_title = 'Component / '+self.object.name
context = super().get_context_data(**kwargs)
context['component'] = self.object
context['stocks'] = Stock.objects.filter(component=self.object)
context['comp_form'] = ComponentForm(instance=self.object)
context['new_distri_num_form'] = DistributorNumberCreateForm()
context['new_param_form'] = ComponentParameterCreateForm()
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
def handle_submit_edit_component_post(self, request, **kwargs):
cform = ComponentForm(instance=self.object, data=request.POST, files=request.FILES)
if cform.is_valid():
cform.save()
context = self.get_context_data(**kwargs)
if not cform.is_valid():
context['comp_form'] = cform
return self.render_to_response(context)
def handle_submit_delete_post(self, request, **kwargs):
delete_error = None
protected_stuff = None
try:
self.object.delete()
except ProtectedError as pe:
delete_error = 'Component is protected'
protected_stuff = pe.protected_objects
except:
delete_error = 'Cannot delete component. Unknown error'
if delete_error is None:
return redirect('parts-components')
else:
context = self.get_context_data(**kwargs)
context['delete_error'] = delete_error
context['protected_stuff'] = protected_stuff
return self.render_to_response(context)
def handle_submit_new_distri_num_post(self, request, **kwargs):
form = DistributorNumberCreateForm(data=request.POST)
if form.is_valid():
new_number = form.save(commit=False)
new_number.component = self.object
try:
new_number.save()
except IntegrityError as ie:
form.add_error('__all__', 'Number for given distributor already exists')
context = self.get_context_data(**kwargs)
if not form.is_valid():
context['new_distri_num_form'] = form
return self.render_to_response(context)
def handle_submit_delete_distri_num_post(self, request, **kwargs):
form = DistributorNumberDeleteForm(data=request.POST)
if form.is_valid():
form.cleaned_data['distributor_num'].delete()
context = self.get_context_data(**kwargs)
return self.render_to_response(context)
def handle_submit_delete_param_post(self, request, **kwargs):
form = ComponentParameterDeleteForm(data=request.POST)
if form.is_valid():
form.cleaned_data['param_num'].delete()
context = self.get_context_data(**kwargs)
return self.render_to_response(context)
def handle_submit_new_param_post(self, request, **kwargs):
form = ComponentParameterCreateForm(data=request.POST)
if form.is_valid():
try:
form.save(self.object)
except IntegrityError:
form.add_error('__all__', 'This parameter is already set')
context = self.get_context_data(**kwargs)
if not form.is_valid():
context['new_param_form'] = form
return self.render_to_response(context)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
if 'submit-edit-component' in request.POST:
return self.handle_submit_edit_component_post(request, **kwargs)
elif 'submit-component-delete' in request.POST:
return self.handle_submit_delete_post(request, **kwargs)
elif 'submit-create-new-distri-num' in request.POST:
return self.handle_submit_new_distri_num_post(request, **kwargs)
elif 'submit-delete-distributor-num' in request.POST:
return self.handle_submit_delete_distri_num_post(request, **kwargs)
elif 'submit-delete-param' in request.POST:
return self.handle_submit_delete_param_post(request, **kwargs)
elif 'submit-create-new-param' in request.POST:
return self.handle_submit_new_param_post(request, **kwargs)
else:
return super().post(request, *args, **kwargs)

View File

@ -0,0 +1,120 @@
from django.shortcuts import redirect
from django.views.generic import TemplateView, DetailView
from django.contrib.auth.mixins import LoginRequiredMixin
from ..models import Distributor
from django.core.paginator import Paginator
from django.db.models import ProtectedError
from ..forms import *
from django.db.models import Q
from .generic_views import BaseTemplateMixin
class DistributorView(LoginRequiredMixin, BaseTemplateMixin, TemplateView):
template_name = 'parts/distributors.html'
base_title = 'Distributors'
navbar_selected = 'Distributors'
default_page_size = 25
def search_distributors(self, search):
qs = Distributor.objects.all()
if not search:
return qs
search_fragments = search.strip().split()
for search in search_fragments:
qs = qs.filter(Q(name__icontains = search) | Q(website__icontains = search))
return qs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
page_num = self.request.GET.get('page', default=1)
search_string = self.request.GET.get('search', default=None)
queryset = self.search_distributors(search_string)
paginator = Paginator(queryset, self.default_page_size)
context['search_string'] = search_string
context['distributors'] = paginator.get_page(page_num)
context['new_distri_form'] = DistributorForm()
return context
def handle_add_new_distributor(self, request):
form = DistributorForm(data=request.POST, files=request.FILES)
if form.is_valid():
form.save()
context = self.get_context_data()
if not form.is_valid():
context['new_distri_form'] = form
return self.render_to_response(context)
def post(self, request, *args, **kwargs):
if 'submit-distri-add-new' in request.POST:
return self.handle_add_new_distributor(request)
return super().post(request, *args, **kwargs)
class DistributorDetailView(LoginRequiredMixin, BaseTemplateMixin, DetailView):
template_name = 'parts/distributors-detail.html'
model = Distributor
pk_url_kwarg = 'uuid'
base_title = ''
navbar_selected = 'Distributors'
def get_context_data(self, **kwargs):
self.base_title = 'Distributor / '+self.object.name
context = super().get_context_data(**kwargs)
context['distributor'] = self.object
context['edit_form'] = DistributorForm(instance=self.object)
return context
def handle_delete_distributor(self, request):
delete_error = None
protected_objects = None
# Try to delete this instance
try:
self.object.delete()
except ProtectedError as pe:
delete_error = 'Cannot delete this distributor. It is referenced by a component.'
protected_objects = pe.protected_objects
except:
delete_error = 'Cannot delete this distributor. Unknown error'
if delete_error:
context = self.get_context_data()
context['delete_error'] = delete_error
context['protected_components'] = protected_objects
return self.render_to_response(context)
else:
return redirect('parts-distributors')
def edit_distributor(self, request):
edit_form = DistributorForm(data=request.POST, files=request.FILES, instance=self.object)
if edit_form.is_valid():
edit_form.save()
context = self.get_context_data()
if not edit_form.is_valid():
context['edit_form'] = edit_form
return self.render_to_response(context)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
if 'submit-distri-delete' in request.POST:
return self.handle_delete_distributor(request)
elif 'submit-distri-edit' in request.POST:
return self.edit_distributor(request)
return super().post(request, *args, **kwargs)

View File

@ -0,0 +1,121 @@
from django.shortcuts import render, redirect
from django.contrib.auth import logout, login
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.generic import TemplateView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponse
from ..navbar import NavBar
from ..forms import QrSearchForm
class BaseTemplateMixin():
navbar_selected = ''
base_title = ''
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
base_context = {
'navbar': NavBar.get_navbar(self.navbar_selected, self.request.user),
'title': NavBar.get_brand()+' / '+ self.base_title,
'login_active': False,
}
context['base'] = base_context
return context
def post(self, request, *args, **kwargs):
data = request.POST
if 'qr_search' not in data:
super().post(request, *args, **kwargs)
print('QR',data['qr_search'])
f = QrSearchForm(data)
if f.is_valid():
return redirect(f.my_qr_validator.get_redirect_url(f.cleaned_data['qr_search']))
return self.get(request)
class ChangePasswordView(LoginRequiredMixin, BaseTemplateMixin, TemplateView):
template_name = 'parts/change-pw.html'
navbar_selected = 'Main'
base_title = 'Change Password'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['form'] = PasswordChangeForm(self.request.user)
return context
def post(self, request, *args, **kwargs):
if 'submit-change-pw' not in request.POST:
return super().post(request, *args, **kwargs)
form = PasswordChangeForm(request.user, data=request.POST)
if form.is_valid():
user = form.save()
update_session_auth_hash(request, user)
return redirect('parts-main')
context = self.get_context_data(**kwargs)
if form.errors:
context['form'] = form
return self.render_to_response(context)
class MainView(BaseTemplateMixin, TemplateView):
template_name = 'parts/main.html'
navbar_selected = 'Main'
base_title = 'Main'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['user'] = self.request.user
return context
def logout_view(request):
logout(request)
return redirect('parts-main')
def login_view(request):
base_context = {
'navbar': NavBar.get_navbar('Login', request.user),
'title': NavBar.get_brand()+' / '+'Login',
'login_active': True,
}
if request.user.is_authenticated:
next_param = request.GET.get('next')
if next_param is not None:
return redirect(next_param)
return redirect('parts-main')
if request.method == 'POST':
form = AuthForm(data=request.POST)
if form.is_valid():
valid_user = form.get_user()
login(request, valid_user)
next_param = request.GET.get('next')
if next_param is not None:
return redirect(next_param)
return redirect('parts-main')
else:
form = AuthForm()
context = {
'base': base_context,
'form': form,
}
return render(request, 'parts/login.html', context)
def health_check_view(_request) -> HttpResponse:
"""
Health checking view. Returns empty http response with HTTP status OK.
This will be used to check if the system is actually running correctly
"""
return HttpResponse(status=200)

View File

@ -0,0 +1,121 @@
from django.shortcuts import redirect
from django.views.generic import TemplateView, DetailView
from django.contrib.auth.mixins import LoginRequiredMixin
from ..models import Manufacturer
from django.core.paginator import Paginator
from django.db.models import ProtectedError
from ..forms import *
from django.db.models import Q
from .generic_views import BaseTemplateMixin
class ManufacturersView(LoginRequiredMixin, BaseTemplateMixin, TemplateView):
template_name = 'parts/manufacturers.html'
base_title = 'Manufacturers'
navbar_selected = 'Manufacturers'
default_page_size = 25
def search_manufacturers(self, search):
qs = Manufacturer.objects.all()
if not search:
return qs
search_fragements = search.strip().split()
for search in search_fragements:
qs = qs.filter(Q(name__icontains = search) | Q(website__icontains = search))
return qs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
page_num = self.request.GET.get('page', default=1)
search_string = self.request.GET.get('search', default=None)
queryset = self.search_manufacturers(search_string)
paginator = Paginator(queryset, self.default_page_size)
context['search_string'] = search_string
context['manufacturers'] = paginator.get_page(page_num)
context['new_manufacturer_form'] = ManufacturerForm()
return context
def handle_add_new_manufacturer(self, request):
form = ManufacturerForm(data=request.POST, files=request.FILES)
if form.is_valid():
form.save()
context = self.get_context_data()
if not form.is_valid():
context['new_manufacturer_form'] = form
return self.render_to_response(context)
def post(self, request, *args, **kwargs):
if 'submit-manufacturer-add-new' in request.POST:
return self.handle_add_new_manufacturer(request)
return super().post(request, *args, **kwargs)
class ManufacturerDetailView(LoginRequiredMixin, BaseTemplateMixin, DetailView):
template_name = 'parts/manufacturers-detail.html'
model = Manufacturer
pk_url_kwarg = 'uuid'
base_title = ''
navbar_selected = 'Manufacturers'
def get_context_data(self, **kwargs):
self.base_title = 'Manufacturer / '+self.object.name
context = super().get_context_data(**kwargs)
context['manufacturer'] = self.object
context['edit_form'] = ManufacturerForm(instance=self.object)
return context
def handle_delete_manufacturer(self, request):
delete_error = None
protected_objects = None
# Try to delete this instance
try:
self.object.delete()
except ProtectedError as pe:
delete_error = 'Cannot delete this distributor. It is referenced by a component.'
protected_objects = pe.protected_objects
except:
delete_error = 'Cannot delete this distributor. Unknown error'
if delete_error:
context = self.get_context_data()
context['delete_error'] = delete_error
context['protected_components'] = protected_objects
return self.render_to_response(context)
else:
return redirect('parts-manufacturers')
def edit_manufacturer(self, request):
edit_form = ManufacturerForm(data=request.POST, files=request.FILES, instance=self.object)
if edit_form.is_valid():
edit_form.save()
context = self.get_context_data()
if not edit_form.is_valid():
context['edit_form'] = edit_form
return self.render_to_response(context)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
if 'submit-manufacturer-delete' in request.POST:
return self.handle_delete_manufacturer(request)
elif 'submit-manufacturer-edit' in request.POST:
return self.edit_manufacturer(request)
return super().post(request, *args, **kwargs)

View File

@ -0,0 +1,128 @@
from django.shortcuts import redirect
from django.views.generic import TemplateView, DetailView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.paginator import Paginator
from django.db.models import ProtectedError
from django.db.models import Q
from ..forms import *
from ..models import Package
from .generic_views import BaseTemplateMixin
class PackageView(LoginRequiredMixin, BaseTemplateMixin, TemplateView):
template_name = 'parts/packages.html'
base_title = 'Packages'
navbar_selected = 'Packages'
default_page_size = 25
def search_packages(self, search):
qs = Package.objects.all()
if not search:
return qs
search_fragments = search.strip().split()
for search in search_fragments:
if search.lower() == 'smd':
s_filter = Q(name__icontains = search) | Q(smd = True)
else:
try:
pin_count = int(search)
s_filter = Q(name__icontains = search) | Q(pin_count=pin_count)
except:
s_filter = Q(name__icontains = search)
qs = qs.filter(s_filter)
return qs
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
page_num = self.request.GET.get('page', default=1)
search_string = self.request.GET.get('search', default=None)
package_queryset = self.search_packages(search_string)
paginator = Paginator(package_queryset, self.default_page_size)
context['search_string'] = search_string
context['packages'] = paginator.get_page(page_num)
context['new_pkg_form'] = PackageForm()
return context
def handle_add_new_package(self, request):
form = PackageForm(data=request.POST, files=request.FILES)
if form.is_valid():
form.save()
context = self.get_context_data()
if not form.is_valid():
context['new_pkg_form'] = form
return self.render_to_response(context)
def post(self, request, *args, **kwargs):
if 'submit-pkg-add-new' in request.POST:
return self.handle_add_new_package(request)
return super().post(request, *args, **kwargs)
class PackageDetailView(LoginRequiredMixin, BaseTemplateMixin, DetailView):
template_name = 'parts/packages-detail.html'
model = Package
pk_url_kwarg = 'uuid'
base_title = ''
navbar_selected = 'Packages'
def get_context_data(self, **kwargs):
self.base_title = 'Package / '+self.object.name
context = super().get_context_data(**kwargs)
context['package'] = self.object
context['edit_form'] = PackageForm(instance=self.object)
return context
def handle_delete_package(self, request):
delete_error = None
protected_objects = None
# Try to delete this instance
try:
self.object.delete()
except ProtectedError as pe:
delete_error = 'Cannot delete this package. It is referenced by a component.'
protected_objects = pe.protected_objects
except:
delete_error = 'Cannot delete this package. Unknown error'
if delete_error:
context = self.get_context_data()
context['delete_error'] = delete_error
context['protected_components'] = protected_objects
return self.render_to_response(context)
else:
return redirect('parts-packages')
def edit_package(self, request):
edit_form = PackageForm(data=request.POST, files=request.FILES, instance=self.object)
if edit_form.is_valid():
edit_form.save()
context = self.get_context_data()
if not edit_form.is_valid():
context['edit_form'] = edit_form
return self.render_to_response(context)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
if 'submit-pkg-delete' in request.POST:
return self.handle_delete_package(request)
elif 'submit-pkg-edit' in request.POST:
return self.edit_package(request)
return super().post(request, *args, **kwargs)

View File

@ -0,0 +1,254 @@
import uuid
from django.shortcuts import redirect
from django.urls import reverse
from django.views.generic import TemplateView, DetailView
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.paginator import Paginator
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.db.models.functions import Lower
from ..models import Storage, Stock
from ..forms import *
from .generic_views import BaseTemplateMixin
class StockView(LoginRequiredMixin, BaseTemplateMixin, TemplateView):
template_name = 'parts/stocks.html'
base_title = 'Stocks'
navbar_selected = 'Stocks'
default_pagination_size = 25
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
storage_page = self.request.GET.get('storage_page')
if storage_page is None:
storage_page = 1
low_stock_page = self.request.GET.get('low_stock_page')
if low_stock_page is None:
low_stock_page = 1
storage_paginator = Paginator(Storage.objects.filter(parent_storage=None), self.default_pagination_size)
low_stock_paginator = Paginator(Stock.get_under_watermark(),
self.default_pagination_size)
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.id
context['add_storage_form'] = add_stor_form
return context
def handle_add_storage(self, request, **kwargs):
f = AddSubStorageForm(data=request.POST)
if f.is_valid():
sub_name = f.cleaned_data['storage_name']
try:
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)
context['add_storage_form'] = f
return self.render_to_response(context)
def post(self, request, **kwargs):
if 'submit-add-storage' in request.POST:
return self.handle_add_storage(request, **kwargs)
return super().post(request, **kwargs)
class StockViewDetail(LoginRequiredMixin, BaseTemplateMixin, DetailView):
template_name = 'parts/stocks-detail.html'
model = Storage
pk_url_kwarg = 'uuid'
base_title = ''
navbar_selected = 'Stocks'
default_pagination_size = 8
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 search_stock_queryset(self, search):
stocks_in_storage = Stock.objects.filter(storage=self.object).order_by(Lower('component__name'))
if search is None or search == '':
return stocks_in_storage
if search.startswith('[comp_uuid]'):
search = search.replace('[comp_uuid]', '')
# Check if the searhc equals a UUID
test_uuid = None
try:
test_uuid = uuid.UUID(search)
except:
pass
if test_uuid is not None:
stocks_in_storage = stocks_in_storage.filter(Q(component__id = test_uuid) | Q(id= test_uuid))
else:
stocks_in_storage = stocks_in_storage.filter(Q(component__name__icontains = search) |
Q(component__package__name__icontains = search) |
Q(component__manufacturer__name__icontains = search))
return stocks_in_storage
def get_context_data(self, **kwargs):
self.base_title = 'Stocks / ' + self.object.name
context = super().get_context_data(**kwargs)
context['breadcrumbs'] = self.get_breadcrumbs()
storage_page = self.request.GET.get('storage_page', default=1)
storage_paginator = Paginator(Storage.objects.filter(parent_storage=self.object), self.default_pagination_size)
stock_search_input = self.request.GET.get('search')
componente_stock_page = self.request.GET.get('stock_page', default=1)
stock_paginator = Paginator(self.search_stock_queryset(stock_search_input), self.default_pagination_size)
context['storages'] = storage_paginator.get_page(storage_page)
stocks = stock_paginator.get_page(componente_stock_page)
context['stocks'] = 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.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):
f = AddSubStorageForm(data=request.POST)
if f.is_valid():
sub_name = f.cleaned_data['storage_name']
try:
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))
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():
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:
self.object.delete()
except:
context = self.get_context_data(**kwargs)
context['delete_storage_errors'] = ['Error deleting Storage '+str(self.object)]
return self.render_to_response(context)
if parent is None:
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 # TODO: Check error handling. This is clearly not working as intended :P
if 'stock_uuid' in request.POST:
f = DeleteStockForm(data=request.POST)
if f.is_valid():
try:
s = Stock.objects.get(id=f.cleaned_data['stock_uuid'])
print(s.storage)
print(self.object)
if s.storage == self.object:
s.delete()
else:
del_error = 'Cannot delete stock from another storage.'
except:
del_error = 'Could not find requested stock in this storage.'
context = self.get_context_data(**kwargs)
return self.render_to_response(context)
def handle_update_watermark(self, request, **kwargs):
edit_form = EditWatermarkForm(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_amount_change_post(self, request, increase, **kwargs):
edit_form = EditStockAmountForm(data=request.POST)
if edit_form.is_valid():
edit_form.save(increase)
context = self.get_context_data(**kwargs)
return self.render_to_response(context)
def handle_add_stock_post(self, request, **kwargs):
f = AddStockForm(data=request.POST)
error_occured = False
if f.is_valid():
try:
f.save(self.object)
except Exception as ex:
f.add_error('', str(ex))
error_occured = True
else:
error_occured = True
context = self.get_context_data(**kwargs)
if error_occured:
context['add_stock_form'] = f
return self.render_to_response(context)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
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:
return self.handle_del_stock_post(request, **kwargs)
elif 'submit-edit-watermark' in request.POST:
return self.handle_update_watermark(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:
return self.handle_amount_change_post(request, True, **kwargs)
elif 'submit-add-stock' in request.POST:
return self.handle_add_stock_post(request, **kwargs)
return super().post(request, *args, **kwargs)

View File

@ -8,23 +8,6 @@ https://docs.djangoproject.com/en/3.2/topics/settings/
For the full list of settings and their values, see For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.2/ref/settings/ https://docs.djangoproject.com/en/3.2/ref/settings/
Development mode is selected by setting env variable
- DJANGO_DEV_MODE
The following environment variables have to be set for Production Mode but might be optional for dev mode:
- DJANGO_SECRET_KEY
- DJANGO_ALLOWED_HOST
- DJANGO_STATIC_ROOT
- DJANGO_MEDIA_URL
- DJANGO_MEDIA_ROOT
- DJANGO_POSTGRESQL_SOCKET
The following can be set
- DJANGO_SECURE_HSTS_SECONDS (defaults to 120)
""" """
from pathlib import Path from pathlib import Path
@ -33,34 +16,17 @@ import os
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
def get_env_value(env_variable, default=None):
try:
return os.environ[env_variable]
except KeyError:
if default is not None:
return default
error_msg = 'Set the {} environment variable'.format(env_variable)
raise Exception(error_msg)
RUNS_IN_DEV_MODE = True if get_env_value('DJANGO_DEV_MODE', default=False) != False else False
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY='' SECRET_KEY = 'django-insecure-vq_@ue3ul@&4bz7wkcpf3pjrwf8o$7g!z-rw$ftr-$)7l3*m=^'
if RUNS_IN_DEV_MODE:
SECRET_KEY = get_env_value('DJANGO_SECRET_KEY',
default='django-insecure-vq_@ue3ul@&4bz7wkcpf3pjrwf8o$7g!z-rw$ftr-$)7l3*m=^')
else:
SECRET_KEY = get_env_value('DJANGO_SECRET_KEY')
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True if RUNS_IN_DEV_MODE else False DEBUG = True
ALLOWED_HOSTS = ['localhost'] ALLOWED_HOSTS = ['localhost']
if not RUNS_IN_DEV_MODE:
ALLOWED_HOSTS = ['localhost', get_env_value('DJANGO_ALLOWED_HOST')]
# Application definition # Application definition
@ -130,34 +96,10 @@ WSGI_APPLICATION = 'shimatta_kenkyusho.wsgi.application'
# Database # Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases # https://docs.djangoproject.com/en/3.2/ref/settings/#databases
DATABASES = {} DATABASES = {
if RUNS_IN_DEV_MODE:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
else:
b_pw = ''
try:
db_pw = get_env_value('DJANGO_POSTGRESQL_PW')
except:
pass
db_user = ''
try:
db_user = get_env_value('DJANGO_POSTGRESQL_USER')
except:
pass
DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.postgresql', 'ENGINE': 'django.db.backends.sqlite3',
'NAME': 'shimatta_kenkyusho', 'NAME': BASE_DIR / 'db.sqlite3',
'USER': db_user,
'PASSWORD': db_pw,
'HOST': get_env_value('DJANGO_POSTGRESQL_SOCKET'),
} }
} }
@ -225,17 +167,12 @@ STATICFILES_DIRS = [
os.path.join(BASE_DIR, "static"), os.path.join(BASE_DIR, "static"),
] ]
STATIC_ROOT = None
if not RUNS_IN_DEV_MODE:
STATIC_ROOT = get_env_value('DJANGO_STATIC_ROOT')
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
MEDIA_URL = '/media/'
MEDIA_URL = get_env_value('DJANGO_MEDIA_URL', default='/media/') MEDIA_ROOT = os.path.join(BASE_DIR, "media")
MEDIA_ROOT = get_env_value('DJANGO_MEDIA_ROOT', default=os.path.join(BASE_DIR, "media"))
LOGIN_URL = '/login' LOGIN_URL = '/login'
LOGIN_REDIRECT_URL = '/' LOGIN_REDIRECT_URL = '/'
@ -245,17 +182,4 @@ SHIMATTA_KENKYUSHO_TITLE = 'しまった・研究所'
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5" CRISPY_TEMPLATE_PACK = "bootstrap5"
# Production only settings
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True
SECURE_HSTS_SECONDS = get_env_value('DJANGO_SECURE_HSTS_SECONDS', default=120)
if RUNS_IN_DEV_MODE:
SESSION_COOKIE_SECURE = False
CSRF_COOKIE_SECURE = False
SECURE_SSL_REDIRECT = False

View File

@ -0,0 +1,244 @@
"""
Django settings for shimatta_kenkyusho project.
Generated by 'django-admin startproject' using Django 3.2.5.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.2/ref/settings/
The following environment variables have to be set:
- DJANGO_SECRET_KEY
- DJANGO_ALLOWED_HOST
- DJANGO_STATIC_ROOT
- DJANGO_MEDIA_URL
- DJANGO_MEDIA_ROOT
- DJANGO_POSTGRESQL_SOCKET
- DJANGO_POSTGRESQL_PORT
The following can be set
- DJANGO_POSTGRESQL_PW (assumed empty if missing)
- DJANGO_POSTGRESQL_USER (assmumed empty if mssing)
- DJANGO_SECURE_HSTS_SECONDS (defaults to 120)
- DJANGO_FORCE_DEV_MODE
"""
from pathlib import Path
import os
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
def get_env_value(env_variable, default=None):
try:
return os.environ[env_variable]
except KeyError:
if default is not None:
return default
error_msg = 'Set the {} environment variable'.format(env_variable)
raise Exception(error_msg)
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = get_env_value('DJANGO_SECRET_KEY')
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False
if get_env_value('DJANGO_FORCE_DEV_MODE', default=False) == 'True':
DEBUG = True
ALLOWED_HOSTS = ['127.0.0.1', 'localhost', get_env_value('DJANGO_ALLOWED_HOST')]
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'parts.apps.PartsConfig',
'api.apps.ApiConfig',
'rest_framework.authtoken',
'django_filters',
'qr_code',
'rest_framework',
'crispy_forms',
'crispy_bootstrap5',
'django.forms',
]
FORM_RENDERER = 'django.forms.renderers.TemplatesSetting'
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'shimatta_kenkyusho.urls'
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
},
'qr-code': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'LOCATION': 'qr-code-cache',
'TIMEOUT': 3600
}
}
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates'),],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'shimatta_kenkyusho.wsgi.application'
# Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
db_pw = ''
try:
db_pw = get_env_value('DJANGO_POSTGRESQL_PW')
except:
pass
db_user = ''
try:
db_user = get_env_value('DJANGO_POSTGRESQL_USER')
except:
pass
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'shimatta_kenkyusho',
'USER': db_user,
'PASSWORD': db_pw,
'HOST': get_env_value('DJANGO_POSTGRESQL_SOCKET'),
'PORT': get_env_value('DJANGO_POSTGRESQL_PORT'),
}
}
# Password validation
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication',
'api.ExpiringAuthToken.ExpiringTokenAuthentication',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'PAGE_SIZE': 10,
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle'
],
'DEFAULT_THROTTLE_RATES': {
'anon': '100/hour',
'user': '2000/hour'
}
}
REST_FRAMEWORK_TOKEN_EXPIRE_HOURS = 4
# Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.2/howto/static-files/
STATIC_URL = '/static/'
STATICFILES_DIRS = [
os.path.join(BASE_DIR, "static"),
]
STATIC_ROOT = get_env_value('DJANGO_STATIC_ROOT')
# Default primary key field type
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
MEDIA_URL = get_env_value('DJANGO_MEDIA_URL')
MEDIA_ROOT = get_env_value('DJANGO_MEDIA_ROOT')
LOGIN_URL = '/login'
LOGIN_REDIRECT_URL = '/'
SHIMATTA_KENKYUSHO_TITLE = 'しまった・研究所'
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5"
# Production only settings
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = False
# allow detection of https behind "old" nginx
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_HSTS_SECONDS = get_env_value('DJANGO_SECURE_HSTS_SECONDS', default=120)

View File

@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shimatta_kenkyusho.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'shimatta_kenkyusho.settings_production')
application = get_wsgi_application() application = get_wsgi_application()

View File

@ -1,67 +1,67 @@
class EngineeringNumberConverter(): class EngineeringNumberConverter():
prefixes = [ prefixes = [
('y', 1e-24), ('y', 1e-24),
('z', 1e-21), ('z', 1e-21),
('a', 1e-18), ('a', 1e-18),
('f', 1e-15), ('f', 1e-15),
('p', 1e-12), ('p', 1e-12),
('n', 1e-9), ('n', 1e-9),
('u', 1e-6), ('u', 1e-6),
('m', 1e-3), ('m', 1e-3),
# We skip centi and dezi because no one really uses these besides for length measurements # We skip centi and dezi because no one really uses these besides for length measurements
('', 1), ('', 1),
# We also skip h for hekto # We also skip h for hekto
('k', 1e3), ('k', 1e3),
('M', 1e6), ('M', 1e6),
('G', 1e9), ('G', 1e9),
('T', 1e12), ('T', 1e12),
('P', 1e15), ('P', 1e15),
('E', 1e18), ('E', 1e18),
('Z', 1e21), ('Z', 1e21),
('Y', 1e24), ('Y', 1e24),
] ]
it_prefixes = [ it_prefixes = [
('', 1), ('', 1),
('Ki', 1024), ('Ki', 1024),
('Mi', 1024*1024), ('Mi', 1024*1024),
('Gi', 1024*1024*1024), ('Gi', 1024*1024*1024),
('Ti', 1024*1024*1024*1024) ('Ti', 1024*1024*1024*1024)
] ]
@classmethod
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)
"""
if it_unit:
used_prefixes = c.it_prefixes
else:
used_prefixes = c.prefixes
@classmethod if (len(used_prefixes) < 2):
def number_to_engineering(c, number, it_unit=False): return (number / used_prefixes[0][1], used_prefixes[0])
"""
Convert a number to engineering SI syntax with prefix. for i, (prefix, scale) in enumerate(used_prefixes[1:], 1):
This function will return a tuple of (new_number, prefix) if number < scale:
""" return (number / used_prefixes[i-1][1], used_prefixes[i-1][0])
if it_unit:
used_prefixes = c.it_prefixes return (number / used_prefixes[-1][1], used_prefixes[-1][0])
else:
used_prefixes = c.prefixes
if (len(used_prefixes) < 2): @classmethod
return (number / used_prefixes[0][1], used_prefixes[0]) def engineering_to_number(c, input):
cleaned_input = input.strip().replace(' ', '')
for i, (prefix, scale) in enumerate(used_prefixes[1:], 1): selected_scaling = 1
if number < scale:
return (number / used_prefixes[i-1][1], used_prefixes[i-1][0])
return (number / used_prefixes[-1][1], used_prefixes[-1][0]) for (prefix, scale) in c.prefixes+c.it_prefixes:
if prefix == '':
@classmethod continue
def engineering_to_number(c, input): if cleaned_input.endswith(prefix):
cleaned_input = input.strip().replace(' ', '') cleaned_input = cleaned_input.replace(prefix, '')
selected_scaling = scale
selected_scaling = 1 break
for (prefix, scale) in c.prefixes+c.it_prefixes: return float(cleaned_input) * selected_scaling
if prefix == '':
continue
if cleaned_input.endswith(prefix):
cleaned_input = cleaned_input.replace(prefix, '')
selected_scaling = scale
break
return float(cleaned_input) * selected_scaling

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 name_field_name = search_element.getAttribute('data-ac-name-field');
var search_url = search_element.getAttribute('data-ac-url'); var search_url = search_element.getAttribute('data-ac-url');
var base_id = search_element.getAttribute('id'); var base_id = search_element.getAttribute('id');
var uuid_field = search_element.parentElement.querySelector('#'+base_id+'-uuid-field'); var uuid_field = search_element.parentElement.parentElement.querySelector('#'+base_id+'-uuid-field');
var dflex_container = search_element.parentElement.querySelector('#'+base_id+'-dflex-container'); var dflex_container = search_element.parentElement.parentElement.querySelector('#'+base_id+'-dflex-container');
var initial_delete_button = search_element.parentElement.querySelector('[data-ac-delete]'); var initial_delete_button = search_element.parentElement.parentElement.querySelector('[data-ac-delete]');
console.log(initial_delete_button); console.log(initial_delete_button);
console.log(image_field_name); console.log(image_field_name);
@ -27,6 +27,17 @@ function initialize_autocompletion_foreign_key_field(search_element) {
}); });
} }
// select first match if any on enter
search_element.addEventListener('keydown', (e) => {
if (e.key === "Enter") {
e.preventDefault();
first_search_result = search_element.parentElement.querySelector('li')
if (first_search_result) {
first_search_result.click()
}
}
});
new AutocompleteCustomUi( new AutocompleteCustomUi(
base_id, base_id+'-ac-ul', function(search_query, autocomplete_obj) { base_id, base_id+'-ac-ul', function(search_query, autocomplete_obj) {
api_ajax_request_without_send('GET', search_url+`?search=${encodeURIComponent(search_query)}`, function(method, url, json) { api_ajax_request_without_send('GET', search_url+`?search=${encodeURIComponent(search_query)}`, function(method, url, json) {
@ -47,7 +58,7 @@ function initialize_autocompletion_foreign_key_field(search_element) {
} }
} }
autocomplete_obj.show_results(nodes, function(data) { autocomplete_obj.show_results(nodes, function(data) {
var name = data['result'][name_field_name]; var name = data['result'][name_field_name];
if (image_field_name != '' && image_field_name != null) { if (image_field_name != '' && image_field_name != null) {
var image = data['result'][image_field_name]; var image = data['result'][image_field_name];
@ -72,7 +83,7 @@ function initialize_autocompletion_foreign_key_field(search_element) {
}); });
dflex_container.appendChild(span); dflex_container.appendChild(span);
}) })
}, function (){}); }, function (){});
} }
) )

View File

@ -38,7 +38,9 @@ class AutocompleteCustomUi {
this.query_callback = query_function.bind(this); this.query_callback = query_function.bind(this);
document.getElementById(text_id).addEventListener("keyup", this.ac_delay(function(event) { document.getElementById(text_id).addEventListener("keyup", this.ac_delay(function(event) {
this.query_callback(document.getElementById(this.text_id).value, this); if (event.key != 'Enter') {
this.query_callback(document.getElementById(this.text_id).value, this);
}
}, autocomplete_query_delay_ms).bind(this)); }, autocomplete_query_delay_ms).bind(this));
this.dropdown_data = {}; this.dropdown_data = {};

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); 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) { 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); 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' %}', 'user-list': '{% url 'user-list' %}',
'groups-list': '{% url 'user-list' %}', 'groups-list': '{% url 'user-list' %}',
'storage-list': '{% url 'storage-list' %}', 'storage-list': '{% url 'storage-list' %}',
'storage-template-list': '{% url 'storage-template-list' %}',
'component-list': '{% url 'component-list' %}', 'component-list': '{% url 'component-list' %}',
'package-list': '{% url 'package-list' %}', 'package-list': '{% url 'package-list' %}',
'stock-list': '{% url 'stock-list' %}', 'stock-list': '{% url 'stock-list' %}',
@ -74,11 +75,24 @@
'component-parameter-type-list': '{% url 'componentparametertype-list' %}', 'component-parameter-type-list': '{% url 'componentparametertype-list' %}',
}; };
</script> </script>
<script type="text/javascript" src="{% static 'js/kenyusho-api-v1.js' %}"></script> <script type="text/javascript" src="{% static 'js/kenkyusho-api-v1.js' %}"></script>
<script type="text/javascript" src="{% static 'js/autocomplete.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> <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>
<!-- Select search field on start of QR scan if no input is currently selevted([) -->
<script type="text/javascript">
window.addEventListener("keydown", (event)=>{
if (document.activeElement.nodeName != 'INPUT' && event.key == '[') {
document.getElementById("qr_search_field").focus()
}
})
</script>
{% block custom_scripts %} {% block custom_scripts %}
{% endblock custom_scripts %} {% endblock custom_scripts %}
</body> </body>
</html> </html>

View File

@ -2,21 +2,21 @@
<nav aria-label="{{aria_label}}"> <nav aria-label="{{aria_label}}">
<ul class="pagination"> <ul class="pagination">
{% if paginator.has_previous %} {% 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 %} {% else %}
<li class="page-item disabled"><span class="page-link">&laquo;</span></li> <li class="page-item disabled"><span class="page-link">&laquo;</span></li>
{% endif %} {% endif %}
{% for i in paginator.paginator.page_range %} {% for i in paginator.paginator.page_range %}
{% if i <= paginator.number|add:5 and i >= paginator.number|add:-5 %} {% if i <= paginator.number|add:5 and i >= paginator.number|add:-5 %}
{% if i == paginator.number %} {% 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 %} {% 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 %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if paginator.has_next %} {% 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 %} {% else %}
<li class="page-item disabled"><span class="page-link">&raquo;</span></li> <li class="page-item disabled"><span class="page-link">&raquo;</span></li>
{% endif %} {% 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> <tbody>
<tr> <tr>
<td class="align-middle" scope="row"> <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>
<td class="align-middle" > <td class="align-middle" >
{% if component.package %} {% if component.package %}
@ -119,17 +119,6 @@
<th scope="col"></th> <th scope="col"></th>
</thead> </thead>
<tbody> <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 %} {% for param in parameters %}
<tr> <tr>
<td> <td>
@ -159,13 +148,6 @@
</div> </div>
{% endif %} {% endif %}
{% endfor %} {% 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> </div>
<div class="col"> <div class="col">

View File

@ -14,10 +14,11 @@
</button> </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-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-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> </div>
</form> </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 %}"> <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="row">
<div class="col-sm"> <div class="col-sm">
{% crispy advanced_search_form %} {% crispy advanced_search_form %}
@ -31,9 +32,6 @@
<input type="submit" name="submit-advanced-search" value="Search" class="btn btn-success"> <input type="submit" name="submit-advanced-search" value="Search" class="btn btn-success">
</div> </div>
</form> </form>
<template id="advanced-search-parameter-template">
{% crispy advanced_search_param_formset.empty_form %}
</template>
</div> </div>
<div class="list-group mb-3"> <div class="list-group mb-3">
{% for comp in components %} {% for comp in components %}
@ -48,12 +46,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="flex-grow-1 ms-3"> <div class="flex-grow-1 ms-3">
<h6 class="mt-0 text-primary"> <h6 class="mt-0 text-primary">{{ comp.name }}</h6>
{{ comp.name }}
{% for key_param in comp.get_key_parameters_as_text %}
{{key_param}}
{% endfor %}
</h6>
{% if comp.package %} {% if comp.package %}
Package: {{comp.package}}<br> Package: {{comp.package}}<br>
{% endif %} {% endif %}
@ -72,6 +65,7 @@
</div> </div>
{% include 'parts/modals/edit-component-modal.html' with form=comp_form heading='New Component' open_component_button=True %} {% 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 %} {% endblock content %}
{% block custom_scripts %} {% block custom_scripts %}
@ -80,5 +74,8 @@
{% if comp_form.errors %} {% if comp_form.errors %}
bootstrap.Modal.getOrCreateInstance(document.getElementById('comp-edit-modal')).show() bootstrap.Modal.getOrCreateInstance(document.getElementById('comp-edit-modal')).show()
{% endif %} {% endif %}
{% if import_comp_form.errors %}
bootstrap.Modal.getOrCreateInstance(document.getElementById('comp-import-modal')).show()
{% endif %}
</script> </script>
{% endblock custom_scripts %} {% 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"> <div class="row">
{% qr_from_text object.get_qr_code size="m" image_format="svg" %} {% qr_from_text object.get_qr_code size="m" image_format="svg" %}
</div> </div>
<div class="row">
<h4>{{storage.name}}{% if storage.verbose_name %}<small> ({{storage.verbose_name}})</small>{% endif %}</h4>
</div>
<div class="row"> <div class="row">
{% if object.parent_storage %} {% 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 %}
@ -27,13 +30,14 @@
{% endif %} {% 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-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="#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>
</h1> </h1>
<div class="list-group"> <div class="list-group">
{% for storage in storages %} {% for storage in storages %}
<a href="{% url 'parts-stocks-detail' uuid=storage.id %}" class="text-decoration-none"> <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"> <li class="list-group-item list-group-item-action justify-content-between align-items-center d-flex">
<div> <div>
<h5>{{storage.name}}</h5> <h5>{{storage.name}}{% if storage.verbose_name %}<small> ({{storage.verbose_name}})</small>{% endif %}</h5>
Responsible: {{ storage.responsible }} Responsible: {{ storage.responsible }}
</div> </div>
<span class="badge bg-primary rounded-pill">{{storage.get_total_stock_amount}}</span> <span class="badge bg-primary rounded-pill">{{storage.get_total_stock_amount}}</span>
@ -69,11 +73,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="flex-grow-1 ms-3"> <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> <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>
{% for key_param in stock.component.get_key_parameters_as_text %}
{{key_param}}&nbsp;
{% endfor %}
</h6>
{% if stock.component.package %} {% if stock.component.package %}
Package: {{stock.component.package}}<br> Package: {{stock.component.package}}<br>
{% endif %} {% endif %}
@ -113,7 +113,11 @@
{% endfor %} {% endfor %}
<!-- Modal for adding a substorage--> <!-- Modal for adding a substorage-->
{% with add_storage_form as form %} {% 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 %} {% endwith %}
<!-- Modal for deleting this storage --> <!-- Modal for deleting this storage -->
{% with delete_storage_errors as err_msgs %} {% with delete_storage_errors as err_msgs %}
@ -162,18 +166,6 @@ api_get_component_from_id(uuid, function(component){
} }
{% endif %} {% 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> </script>
{% endblock custom_scripts %} {% endblock custom_scripts %}

View File

@ -40,7 +40,7 @@
<a href="{% url 'parts-stocks-detail' uuid=storage.id %}" class="text-decoration-none"> <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"> <li class="list-group-item list-group-item-action justify-content-between align-items-center d-flex">
<div> <div>
<h5>{{storage.name}}</h5> <h5>{{storage.name}}{% if storage.verbose_name %}<small> ({{storage.verbose_name}})</small>{% endif %}</h5>
Responsible: {{ storage.responsible }} Responsible: {{ storage.responsible }}
</div> </div>
<span class="badge bg-primary rounded-pill">{{storage.get_total_stock_amount}}</span> <span class="badge bg-primary rounded-pill">{{storage.get_total_stock_amount}}</span>
@ -54,7 +54,7 @@
<!-- Add storage modal form --> <!-- Add storage modal form -->
{% with add_storage_form as form %} {% with add_storage_form as form %}
{% include 'parts/modals/new-substorage-modal.html' %} {% include 'parts/modals/add-substorage-modal.html' %}
{% endwith %} {% endwith %}
</div> </div>
@ -67,18 +67,6 @@
var modal = bootstrap.Modal.getOrCreateInstance(addSubStorageModal); var modal = bootstrap.Modal.getOrCreateInstance(addSubStorageModal);
modal.show(); modal.show();
{% endif %} {% 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> </script>
{% endblock custom_scripts %} {% endblock custom_scripts %}

View File

@ -1,7 +1,12 @@
<div class="dropdown"> <div class="dropdown">
<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}}"> <div class="input-group">
<ul id="{{widget.attrs.id}}-ac-ul" class="dropdown-menu"> {% if custom.prepend %}
</ul> <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"> <div class="d-flex align-items-center mt-3 mb-3" id="{{widget.attrs.id}}-dflex-container">
{% if custom.current_instance %} {% if custom.current_instance %}
{% if custom.image_field_name %} {% if custom.image_field_name %}

View File

@ -0,0 +1,7 @@
#!/bin/bash
# Startup the db container
docker compose start shimatta-kenkyusho-db
# Override entrypoint to get interactive shell
docker compose run --entrypoint="/bin/sh" -p 8000:8000 shimatta-kenkyusho-web

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