Compare commits

...

5 commits

17 changed files with 460 additions and 32 deletions

9
.gitignore vendored
View file

@ -2,9 +2,12 @@
__pycache__
.python_version
*.egg-info
*.mo
media/
./static/
/static/
media.tar.gz
*.pg
*.pg
override.env
.env

21
Dockerfile Normal file
View file

@ -0,0 +1,21 @@
FROM python:3.13-alpine
WORKDIR /usr/src/app
RUN pip install --no-cache-dir poetry
ENV INVENTORY_STATIC_FILES=/static INVENTORY_MEDIA_FILES=/media
RUN mkdir -p "$INVENTORY_MEDIA_FILES" && mkdir -p "$INVENTORY_STATIC_FILES"
COPY pyproject.toml /usr/src/app
COPY poetry.lock /usr/src/app
RUN poetry install --no-root --no-interaction --no-cache
COPY manage.py ./manage.py
COPY inventory ./inventory
COPY inventory_project ./inventory_project
COPY entrypoint.sh /entrypoint.sh
RUN poetry run python manage.py collectstatic
CMD [ "/bin/sh", "/entrypoint.sh" ]

View file

@ -9,17 +9,19 @@ what area of your workshop you have to search for to find the part you
currently need. It has been optimized to store information for electronics
parts and small other hardware like screws, nuts and bolts.
### Prerequisites
### Prerequisites for manual install or docker Standalone
As configured by default you will need the following:
- A postgres database named `inventory` with a postgres user `inventory` that
may connect without password or by default with the password `inventory`
### Installation (manual)
You will need:
- Python > 3.10
- Poetry to install requirements and create a virtualenv
### Installation
This is a standard Django 5.1 application, if you know how to deploy those the
following might sound familiar:
@ -27,16 +29,28 @@ following might sound familiar:
- Github `git clone https://github.com/dunkelstern/inventory.git`
- ForgeJo: `git clone https://git.dunkelstern.de/dunkelstern/inventory.git`
2. Change to checkout: `cd inventory`
3. Install virtualenv and dependencies: `poetry install --no-root`
3. Install virtualenv and dependencies:
```
poetry install --no-root
```
4. If you want to use the system in another language than the default english set it
up in the `inventory_project/settings.py`:
```python
LANGUAGE_CODE = 'en-us' # or something like 'de-de'
```
see the settings file for defined languages.
5. If you changed the language rebuild the translation files: `poetry run python manage.py compilemessages`
6. Migrate the Database: `poetry run python manage.py migrate`
7. Create an admin user: `poetry run python manage.py createsuperuser`
5. If you changed the language rebuild the translation files:
```
poetry run python manage.py compilemessages
```
6. Migrate the Database:
```
poetry run python manage.py migrate
```
7. Optionally create an admin user. If not done manually the application will prompt you on first run.
```
poetry run python manage.py createsuperuser
```
8. Run the server
- Development server (not for deployment!): `poetry run python manage.py runserver`
- Deployment via `gunicorn` on port 8000: `poetry run gunicorn inventory_project.wsgi -b 0.0.0.0:8000`
@ -44,6 +58,73 @@ following might sound familiar:
Then login on `http://localhost:8000/admin/` for the Django admin interface or
go to `http://localhost:8000` to enter the inventory management system directly
### Installation (Standalone Docker)
#### Building yourself
1. Checkout repository:
- Github `git clone https://github.com/dunkelstern/inventory.git`
- ForgeJo: `git clone https://git.dunkelstern.de/dunkelstern/inventory.git`
2. Change to checkout: `cd inventory`
3. Build Docker image: `docker build -t 'dunkelstern/inventory:latest' .`
next steps below
#### Pulling from docker hub
1. Pull Docker image: `docker pull 'dunkelstern/inventory:latest'`
next steps below
#### Next steps
1. Install a PostgreSQL DB somewhere and create a user and DB.
2. Setup environment (put everything in a `.env` file):
```
INVENTORY_DB_HOST=
INVENTORY_DB_NAME=
INVENTORY_DB_USER=
INVENTORY_DB_PASSWORD=
INVENTORY_SECRET_KEY=
INVENTORY_EXTERNAL_URL=http://localhost:8000
INVENTORY_DEBUG=FALSE
INVENTORY_LANGUAGE=en-us
INVENTORY_TIMEZONE=UTC
INVENTORY_PAGE_SIZE=25
```
3. Create a media directory for uploaded files: `mkdir -p media`
4. Run the container:
```
docker run \
--name inventory \
-d \
--restart=always \
--env-file=.env \
-p 8000:8000 \
--volume ./media:/media \
dunkelstern/inventory:latest
```
5. The onboarding process will start on first call of the application and prompt to create an admin user.
### Installation (Docker compose)
1. Checkout repository:
- Github `git clone https://github.com/dunkelstern/inventory.git`
- ForgeJo: `git clone https://git.dunkelstern.de/dunkelstern/inventory.git`
2. Change to checkout: `cd inventory`
3. Copy `default.env` to `override.env` and check settings. Use a long random string for `INVENTORY_SECRET_KEY`!
4. Build the stack: `docker-compose up --build -d`
5. You can reach the application on port 8000
6. The onboarding process will start on first call of the application and prompt to create an admin user.
The compose stack will create two volumes:
- `inventory_dbdata` which contains the PostgreSQL database directory
- `inventory_mediafiles` which will contain any uploaded file
### Additional information
1. The initial DB migration pre-populates the database with some useful defaults

17
default.env Normal file
View file

@ -0,0 +1,17 @@
# override this with a long random string (used for CSRF protection)
INVENTORY_SECRET_KEY=""
# override with URL the service will be available under
INVENTORY_EXTERNAL_URL="https://inventory.example.com"
# keep this to FALSE for deployments
INVENTORY_DEBUG="FALSE"
# if you want to run the service in another language, override this
INVENTORY_LANGUAGE="en-us"
# if you want the service to use local time then override this
INVENTORY_TIMEZONE="UTC"
# This is the default pagination size
INVENTORY_PAGE_SIZE="25"

46
docker-compose.yaml Normal file
View file

@ -0,0 +1,46 @@
name: inventory
services:
db:
image: postgres:17-alpine
restart: always
shm_size: 128mb
environment:
POSTGRES_PASSWORD: inventory
POSTGRES_USER: inventory
POSTGRES_DB: inventory
volumes:
- dbdata:/var/lib/postgresql/data
inventory:
image: dunkelstern/inventory
build: .
restart: always
depends_on:
- db
env_file:
- path: ./default.env
required: true
- path: ./override.env
required: false
environment:
INVENTORY_DB_HOST: db
INVENTORY_DB_NAME: inventory
INVENTORY_DB_USER: inventory
INVENTORY_DB_PASSWORD: inventory
ports:
- name: web
target: 8000
host_ip: 127.0.0.1
published: "8000"
protocol: tcp
app_protocol: http
mode: host
volumes:
- mediafiles:/media
links:
- db
volumes:
dbdata:
mediafiles:

8
entrypoint.sh Normal file
View file

@ -0,0 +1,8 @@
#!/bin/sh
cd /usr/src/app
# poetry run python manage.py collectstatic --noinput
poetry run python manage.py migrate --noinput
exec poetry run gunicorn inventory_project.wsgi -b 0.0.0.0:8000

Binary file not shown.

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: 1.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-01-07 23:07+0100\n"
"POT-Creation-Date: 2025-01-11 03:20+0100\n"
"PO-Revision-Date: 2025-01-07 23:00+0100\n"
"Last-Translator: Johannes Schriewer <hallo@dunkelstern.de>\n"
"Language: German\n"
@ -467,6 +467,62 @@ msgstr "Inventarverwaltung"
msgid "Create new manufacturer..."
msgstr "Neuen Hersteller anlegen..."
#: .\inventory\templates\inventory\onboarding.html:5
#: .\inventory\templates\inventory\onboarding.html:8
#: .\inventory\templates\inventory\onboarding_success.html:5
#: .\inventory\templates\inventory\onboarding_success.html:8
msgid "Inventory Setup"
msgstr "Inventarverwaltung Setup"
#: .\inventory\templates\inventory\onboarding.html:12
msgid "Welcome to the Inventory Management setup"
msgstr "Willkommen zur Einrichtung der Inventarverwaltung"
#: .\inventory\templates\inventory\onboarding.html:15
msgid ""
"\n"
" Currently no admin user is defined in the database.\n"
" To use the inventory management system you need at least one admin "
"user...\n"
" "
msgstr ""
"\n"
" Aktuell ist kein Admin Benutzer in der Datenbank angelegt.\n"
" Um die Inventarverwaltung nutzen zu können muss mindestens\n"
" ein Administrator angelegt werden...\n"
" "
#: .\inventory\templates\inventory\onboarding.html:22
msgid ""
"\n"
" Please verify that the following settings are correct and then fill "
"out the\n"
" form at the end and click\n"
" "
msgstr ""
" Bitte überprüfe die folgenden Einstellungen und klicke dann auf\n"
" "
#: .\inventory\templates\inventory\onboarding.html:26
#: .\inventory\templates\inventory\onboarding.html:38
#: .\inventory\templates\inventory\onboarding.html:51
msgid "Create user"
msgstr "Benutzer anlegen"
#: .\inventory\templates\inventory\onboarding.html:29
msgid "Current settings"
msgstr "Aktuelle Einstellungen"
#: .\inventory\templates\inventory\onboarding.html:42
msgid ""
"\n"
" Please correct the errors below.\n"
" "
msgstr ""
"\n"
" Bitte die unten angezeigten Fehler korrigieren.\n"
" "
#: .\inventory\templates\inventory\pagination.html:6
#: .\inventory\templates\inventory\search_result.html:33
msgid "First page"
@ -552,10 +608,22 @@ msgstr ""
msgid "Lost password?"
msgstr "Passwort vergessen?"
#: .\inventory_project\settings.py:121
#: .\inventory\views\onboarding.py:14
msgid "Username"
msgstr "Benutzername"
#: .\inventory\views\onboarding.py:15
msgid "Email"
msgstr "E-Mail"
#: .\inventory\views\onboarding.py:16
msgid "Password"
msgstr "Passwort"
#: .\inventory_project\settings.py:130
msgid "German"
msgstr "Deutsch"
#: .\inventory_project\settings.py:122
#: .\inventory_project\settings.py:131
msgid "English"
msgstr "Englisch"

View file

@ -335,4 +335,14 @@ div.warning-icon {
background-color: #c00000;
-webkit-mask: url(/static/inventory/img/warn.svg) no-repeat center;
mask: url(/static/inventory/img/warn.svg) no-repeat center;
}
form label {
display: inline-block;
width: 100px;
}
dt {
font-weight: bold;
font-family: 'Courier New', Courier, monospace;
}

View file

@ -0,0 +1,53 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% block title %}{% translate 'Inventory Setup' %}{% endblock %}
{% block header_bar %}
{% translate 'Inventory Setup' %}
{% endblock %}
{% block content %}
<h2>{% translate 'Welcome to the Inventory Management setup' %}</h2>
<p>
{% blocktranslate %}
Currently no admin user is defined in the database.
To use the inventory management system you need at least one admin user...
{% endblocktranslate %}
</p>
<p>
{% blocktranslate %}
Please verify that the following settings are correct and then fill out the
form at the end and click
{% endblocktranslate %}
<em>{% translate 'Create user' %}</em>
</p>
<h2>{% translate 'Current settings' %}</h2>
<dl>
{% for key, value in settings.items %}
<dt>{{ key }}</dt>
<dd>{{ value }}</dd>
{% endfor %}
</dl>
<h2>{% translate 'Create user' %}</h2>
{% if form.errors %}
<p>
{% blocktranslate %}
Please correct the errors below.
{% endblocktranslate %}
</p>
{% endif %}
<form action="{% url 'onboarding' %}" method="post">
{% csrf_token %}
{{ form }}
<input type="submit" value="{% translate 'Create user' %}">
</form>
{% endblock %}

View file

@ -0,0 +1,35 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% block title %}{% translate 'Inventory Setup' %}{% endblock %}
{% block header_bar %}
{% translate 'Inventory Setup' %}
{% endblock %}
{% block content %}
<h2>Onboarding completed!</h2>
<p>Congratulations, you have successfully setup the Inventory management system</p>
<p>You may now log in with the password you just set up.</p>
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<table>
<tr>
<td>{{ form.username.label_tag }}</td>
<td>{{ form.username }}</td>
</tr>
<tr>
<td>{{ form.password.label_tag }}</td>
<td>{{ form.password }}</td>
</tr>
</table>
<input type="submit" value="login">
<input type="hidden" name="next" value="{{ next }}">
</form>
{% endblock %}

View file

@ -5,10 +5,13 @@ from django.utils.formats import number_format
from inventory.models import Settings
register = template.Library()
s = Settings.objects.first()
s = None
@register.filter(name='currency')
def currency(value, format):
global s
if s is None:
s = Settings.objects.first()
value = float(value)
if format == 'detail':

View file

@ -34,7 +34,8 @@ from .views import (
ManufacturerView,
IndexView,
TagView,
SearchView
SearchView,
OnboardingView
)
urlpatterns = [
@ -54,5 +55,6 @@ urlpatterns = [
path('tags', TagListView.as_view(), name='tag-list'),
path('tag/<int:pk>', TagView.as_view(), name='tag-detail'),
path('search', SearchView.as_view(), name='search'),
path('onboarding', OnboardingView.as_view(), name='onboarding'),
path('', IndexView.as_view(), name='index')
]

View file

@ -7,6 +7,7 @@ from .workshop import WorkshopView, WorkshopListView
from .index import IndexView
from .tag import TagListView, TagView
from .search import SearchView
from .onboarding import OnboardingView
__all__ = [
AreaView, AreaListView,
@ -17,5 +18,6 @@ __all__ = [
WorkshopView, WorkshopListView,
IndexView,
TagView, TagListView,
SearchView
SearchView,
OnboardingView
]

View file

@ -3,14 +3,24 @@ from django.shortcuts import redirect
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator
from django.views.generic import View
from django.contrib.auth import get_user_model
from django.contrib.auth.views import redirect_to_login
from inventory.models import Settings
@method_decorator(login_required, name='dispatch')
@method_decorator(login_required, name='post')
class IndexView(View):
def get(self, request):
User = get_user_model()
if User.objects.all().count() == 0:
# redirect to onboarding
return redirect(reverse('onboarding'))
if not request.user.is_authenticated:
path = request.get_full_path()
return redirect_to_login(path, reverse('login'))
# check settings for correct starred index page
settings = Settings.objects.first()
if settings.default_container is not None:
return redirect(settings.default_container.url)

View file

@ -0,0 +1,60 @@
from django.utils.translation import gettext_lazy as _
from django.urls import reverse
from django.template.response import TemplateResponse
from django.shortcuts import redirect
from django.views.generic import View
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import AuthenticationForm
from django.conf import settings
class OnboardingForm(forms.Form):
username = forms.CharField(label=_("Username"), max_length=150, required=True)
email = forms.EmailField(label=_("Email"), max_length=254, required=True)
password = forms.CharField(label=_("Password"), max_length=1024, min_length=8, widget=forms.PasswordInput(), required=True)
class OnboardingView(View):
def get(self, request):
User = get_user_model()
if User.objects.all().count() != 0:
# redirect to index
return redirect(reverse('index'))
return TemplateResponse(request, "inventory/onboarding.html", {
"settings": {
"SERVER_URL": settings.SERVER_URL,
"DEBUG": settings.DEBUG,
"ALLOWED_HOSTS": settings.ALLOWED_HOSTS,
"DATABASE_HOST": settings.DATABASES['default']['HOST'],
"DATABASE_NAME": settings.DATABASES['default']['NAME'],
"DATABASE_USER": settings.DATABASES['default']['USER'],
"DATABASE_PASSWORD": settings.DATABASES['default']['PASSWORD'],
"LANGUAGE_CODE": settings.LANGUAGE_CODE,
"TIME_ZONE": settings.TIME_ZONE,
"STATIC_ROOT": settings.STATIC_ROOT,
"MEDIA_ROOT": settings.MEDIA_ROOT,
"PAGE_SIZE": settings.PAGE_SIZE,
},
"form": OnboardingForm()
})
def post(self, request):
# validate we have everything
form = OnboardingForm(request.POST)
if form.is_valid():
# create superuser
User = get_user_model()
User.objects.create_superuser(
form.cleaned_data['username'],
form.cleaned_data['email'],
form.cleaned_data['password']
)
# show success screen
login_form = AuthenticationForm(data={"username": form.cleaned_data['username'] })
return TemplateResponse(request, "inventory/onboarding_success.html", {"form": login_form, "next": reverse('index')})
return TemplateResponse(request, "inventory/onboarding.html", {"settings": settings, "form": form})

View file

@ -10,10 +10,12 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.0/ref/settings/
"""
from typing import List
import os
import sys
import asyncio
import socket
from urllib.parse import urlparse
from uuid import uuid4
from django.utils.translation import gettext_lazy as _
if sys.platform == 'win32':
@ -23,19 +25,25 @@ if sys.platform == 'win32':
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Externally visible URL of the server
SERVER_URL = "http://127.0.0.1:8000"
SERVER_URL = os.environ.get('INVENTORY_EXTERNAL_URL', "http://127.0.0.1:8000")
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'nqo*a(^g$8#0%&+*_7#b_7ybn-znk4#=45_(qy-lq-^v675pqk'
SECRET_KEY = os.environ.get('INVENTORY_SECRET_KEY', uuid4().hex)
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS: List[str] = []
DEBUG = (os.environ.get('INVENTORY_DEBUG', "FALSE") == "TRUE")
parsed_url = urlparse(SERVER_URL)
ALLOWED_HOSTS: list[str] = [
'.localhost',
'127.0.0.1',
'[::1]',
parsed_url.hostname,
socket.gethostbyname('localhost')
]
# Application definition
@ -89,9 +97,10 @@ ASGI_APPLICATION = 'inventory_project.asgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'inventory',
'USER': 'inventory',
'PASSWORD': 'inventory'
'HOST': os.environ.get('INVENTORY_DB_HOST', 'localhost'),
'NAME': os.environ.get('INVENTORY_DB_NAME', 'inventory'),
'USER': os.environ.get('INVENTORY_DB_USER', 'inventory'),
'PASSWORD': os.environ.get('INVENTORY_DB_PASSWORD', 'inventory')
}
}
@ -122,9 +131,9 @@ LANGUAGES = [
("en", _("English")),
]
LANGUAGE_CODE = 'en-us'
LANGUAGE_CODE = os.environ.get('INVENTORY_LANGUAGE', 'en-us')
TIME_ZONE = 'UTC'
TIME_ZONE = os.environ.get('INVENTORY_TIMEZONE', 'UTC')
USE_I18N = True
@ -137,13 +146,13 @@ USE_TZ = True
# https://docs.djangoproject.com/en/3.0/howto/static-files/
STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
STATIC_ROOT = os.environ.get('INVENTORY_STATIC_FILES', os.path.join(BASE_DIR, 'static'))
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
SERVE_MEDIA_FILES = DEBUG
MEDIA_ROOT = os.environ.get('INVENTORY_MEDIA_FILES', os.path.join(BASE_DIR, 'media'))
SERVE_MEDIA_FILES = True
# Default page size for paginated content
PAGE_SIZE = 25
PAGE_SIZE = int(os.environ.get('INVENTORY_PAGE_SIZE', "25"))