forked from hswaw/spejstore
Compare commits
137 commits
storage-un
...
master
Author | SHA1 | Date | |
---|---|---|---|
|
1bbd28933f | ||
650fc34115 | |||
ab631aeb90 | |||
81417f58be | |||
6c81441f00 | |||
d36a84e25b | |||
67ac858323 | |||
fe496e85f8 | |||
ea5e223fcc | |||
2323263ccb | |||
f5740e1543 | |||
c9be16b76f | |||
35a48c3eee | |||
6af75328f4 | |||
c3c80d650c | |||
142e38ad95 | |||
f94e7b3207 | |||
ee2c93908a | |||
d1b9beca6d | |||
1fcbbd9dd3 | |||
2892923389 | |||
d71885d264 | |||
f1143dc4f1 | |||
38c7245a3f | |||
156df0a8a5 | |||
f8b3dd6bf7 | |||
3c3ba16811 | |||
f1335f0565 | |||
daea8dda22 | |||
f92635f5f3 | |||
8ce869393e | |||
0fa9762bea | |||
15bf813b04 | |||
401fcc088d | |||
820f04cc01 | |||
c5a9fba034 | |||
7f19fe7c7a | |||
cc3fddfd22 | |||
20303a14a7 | |||
cc6f00da08 | |||
23244bdf24 | |||
ee9e9becf5 | |||
6cd7ec529b | |||
d615da3a0f | |||
4fc47030db | |||
ce3f07de5f | |||
e3229ffd7e | |||
d4a305c362 | |||
255e0f0d08 | |||
af9cb2db32 | |||
9200bdbb3b | |||
d942c99cb9 | |||
e1a22100c4 | |||
4fc3629dcd | |||
|
875e385f68 | ||
|
8048fccede | ||
3a286a5bc6 | |||
2a70d2cb31 | |||
ae219a2533 | |||
d026e41ac5 | |||
a6705a956f | |||
5012a10298 | |||
027bcfcde5 | |||
154e1079da | |||
30c3c3eb7a | |||
c15f1bb840 | |||
f7688262e4 | |||
d2e25c0801 | |||
e2e82b1a2e | |||
8210381027 | |||
b09016ea3b | |||
af85d191ad | |||
a20e14a8d3 | |||
b6ce1516d2 | |||
a8f7530263 | |||
b74c1b3c8f | |||
bfe9d27d71 | |||
ad73094b67 | |||
a0c6d87adb | |||
|
5ed4128151 | ||
|
d473901f8c | ||
|
878f246b08 | ||
|
af1be4aca7 | ||
|
72e668622d | ||
|
150c405468 | ||
|
efcd932481 | ||
|
837734a655 | ||
|
45ad9bf88c | ||
|
659f04ce9c | ||
|
3fdf788168 | ||
3b5439ef74 | |||
3de626d5c5 | |||
1f5a053a19 | |||
5160052dfd | |||
2e0c031fab | |||
a82668ca01 | |||
0c883bed2f | |||
7ac99dd44b | |||
d313274615 | |||
1587f26181 | |||
4c4d889aac | |||
83e2afcf06 | |||
5e598d80b4 | |||
af9ed46861 | |||
4acccb7d94 | |||
786f2a3675 | |||
7f12474e76 | |||
60601b270e | |||
5a70e382b8 | |||
16ff203aaa | |||
93ebd810aa | |||
35da79f0b2 | |||
323da8e0df | |||
a427c34dff | |||
388f67eb80 | |||
0aee39af76 | |||
d39cfd9b42 | |||
43c1c94844 | |||
fedf3dca80 | |||
3f58a080f4 | |||
6b91ed4d25 | |||
|
638ea2a2aa | ||
|
1f8dadcf18 | ||
|
95694890ea | ||
|
0df044b267 | ||
|
5fa30b7e3a | ||
|
9577c2d27d | ||
|
45c4772f78 | ||
|
2690136711 | ||
|
2ff57e3e0a | ||
|
65871f578d | ||
|
1723535394 | ||
|
1039211bba | ||
|
87db1c4747 | ||
|
85a85ed4c6 | ||
|
812c883964 | ||
|
8d26bac6f9 |
69 changed files with 2006 additions and 7949 deletions
41
.devcontainer/devcontainer.json
Normal file
41
.devcontainer/devcontainer.json
Normal file
|
@ -0,0 +1,41 @@
|
|||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose
|
||||
{
|
||||
"name": "Existing Docker Compose (Extend)",
|
||||
|
||||
// Update the 'dockerComposeFile' list if you have more compose files or use different names.
|
||||
// The .devcontainer/docker-compose.yml file contains any overrides you need/want to make.
|
||||
"dockerComposeFile": ["../docker-compose.yml", "docker-compose.yml"],
|
||||
|
||||
// The 'service' property is the name of the service for the container that VS Code should
|
||||
// use. Update this value and .devcontainer/docker-compose.yml to the real service name.
|
||||
"service": "web",
|
||||
|
||||
// The optional 'workspaceFolder' property is the path VS Code should open by default when
|
||||
// connected. This is typically a file mount in .devcontainer/docker-compose.yml
|
||||
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
|
||||
"features": {
|
||||
"ghcr.io/wxw-matt/devcontainer-features/script_runner:0": {}
|
||||
}
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
// Uncomment the next line if you want start specific services in your Docker Compose config.
|
||||
// "runServices": [],
|
||||
|
||||
// Uncomment the next line if you want to keep your containers running after VS Code shuts down.
|
||||
// "shutdownAction": "none",
|
||||
|
||||
// Uncomment the next line to run commands after the container is created.
|
||||
// "postCreateCommand": "cat /etc/os-release",
|
||||
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
|
||||
// Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "devcontainer"
|
||||
}
|
40
.devcontainer/docker-compose.yml
Normal file
40
.devcontainer/docker-compose.yml
Normal file
|
@ -0,0 +1,40 @@
|
|||
version: '3'
|
||||
services:
|
||||
# Update this to the name of the service you want to work with in your docker-compose.yml file
|
||||
web:
|
||||
# Uncomment if you want to override the service's Dockerfile to one in the .devcontainer
|
||||
# folder. Note that the path of the Dockerfile and context is relative to the *primary*
|
||||
# docker-compose.yml file (the first in the devcontainer.json "dockerComposeFile"
|
||||
# array). The sample below assumes your primary file is in the root of your project.
|
||||
#
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: .devcontainer/Dockerfile
|
||||
|
||||
volumes:
|
||||
# Update this to wherever you want VS Code to mount the folder of your project
|
||||
- ..:/workspaces:cached
|
||||
|
||||
# Uncomment the next four lines if you will use a ptrace-based debugger like C++, Go, and Rust.
|
||||
# cap_add:
|
||||
# - SYS_PTRACE
|
||||
# security_opt:
|
||||
# - seccomp:unconfined
|
||||
|
||||
# Overrides default command so things don't shut down after the process ends.
|
||||
command: /bin/sh -c "while sleep 1000; do :; done"
|
||||
environment:
|
||||
- SPEJSTORE_ENV=dev
|
||||
- SPEJSTORE_DB_NAME=postgres
|
||||
- SPEJSTORE_DB_USER=postgres
|
||||
- SPEJSTORE_DB_PASSWORD=postgres
|
||||
- SPEJSTORE_DB_HOST=db
|
||||
# - SPEJSTORE_DB_PORT=
|
||||
- SPEJSTORE_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
# - SPEJSTORE_CLIENT_ID=
|
||||
# - SPEJSTORE_SECRET=
|
||||
# - SPEJSTORE_MEDIA_ROOT=
|
||||
# - SPEJSTORE_REQUIRE_AUTH=true
|
||||
- SPEJSTORE_OAUTH_REDIRECT_IS_HTTPS=false
|
||||
- SPEJSTORE_PROXY_TRUSTED_IPS=172.21.37.1
|
||||
|
22
.dockerignore
Normal file
22
.dockerignore
Normal file
|
@ -0,0 +1,22 @@
|
|||
*.py[co]
|
||||
db.sqlite3
|
||||
*.swp
|
||||
spejstore.env
|
||||
env/
|
||||
backups
|
||||
media/
|
||||
django-tree/
|
||||
postgres-hstore/
|
||||
.ropeproject/
|
||||
docker-compose.yml
|
||||
docker-compose.override.yml
|
||||
docker-compose.prod-override.yml
|
||||
docker-compose.dev-override.yml
|
||||
build_static
|
||||
.venv
|
||||
.vscode
|
||||
.history
|
||||
log
|
||||
.Dockerfile
|
||||
.env
|
||||
.devcontainer
|
9
.env.example
Normal file
9
.env.example
Normal file
|
@ -0,0 +1,9 @@
|
|||
SPEJSTORE_CLIENT_ID=OAUTH_ID
|
||||
SPEJSTORE_SECRET=OAUTH_SECRET
|
||||
SPEJSTORE_ENV=prod
|
||||
SPEJSTORE_DB_NAME=postgres
|
||||
SPEJSTORE_DB_PASSWORD=postgres
|
||||
SPEJSTORE_DB_USER=postgres
|
||||
SPEJSTORE_DB_HOST=db
|
||||
SPEJSTORE_HOST="https://inventory.hackerspace.pl"
|
||||
SPEJSTORE_LABEL_API=https://label.waw.hackerspace.pl
|
14
.gitignore
vendored
14
.gitignore
vendored
|
@ -1,3 +1,17 @@
|
|||
*.py[co]
|
||||
db.sqlite3
|
||||
*.swp
|
||||
spejstore.env
|
||||
.env
|
||||
env/
|
||||
backups
|
||||
media/
|
||||
django-tree/
|
||||
postgres-hstore/
|
||||
.ropeproject/
|
||||
docker-compose.override.yml
|
||||
build_static
|
||||
.venv
|
||||
.vscode
|
||||
|
||||
__pycache__
|
||||
|
|
15
Dockerfile
15
Dockerfile
|
@ -1,10 +1,17 @@
|
|||
FROM python:3.5
|
||||
FROM python:3.11.4-slim-bookworm
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
RUN mkdir /code
|
||||
WORKDIR /code
|
||||
RUN apt-get -y update
|
||||
RUN apt-get -y install libsasl2-dev libldap2-dev libssl-dev
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq-dev \
|
||||
gcc \
|
||||
&& \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
ADD requirements.txt /code/
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN wget https://github.com/vishnubob/wait-for-it/raw/8ed92e8cab83cfed76ff012ed4a36cef74b28096/wait-for-it.sh -O /usr/local/bin/wait-for-it && chmod +x /usr/local/bin/wait-for-it
|
||||
ADD . /code/
|
||||
|
||||
RUN python -m pip install gunicorn
|
||||
|
||||
CMD bash -c "python manage.py collectstatic --no-input --clear && python manage.py migrate && gunicorn --workers 1 --threads 4 -b 0.0.0.0:8000 --capture-output --error-logfile - --access-logfile - spejstore.wsgi:application"
|
||||
|
|
28
README.md
28
README.md
|
@ -5,18 +5,34 @@ Please use Python3, for the love of `$deity`...
|
|||
|
||||
## Usage
|
||||
|
||||
### Quick start
|
||||
|
||||
1. Run:
|
||||
```sh
|
||||
ln -s docker-compose.dev-override.yml docker-compose.override.yml
|
||||
docker-compose up --build
|
||||
```
|
||||
2. Run `docker-compose run --rm web python manage.py createsuperuser` -- now you can dev authenticate w/o SSO
|
||||
|
||||
### Build & run
|
||||
|
||||
```sh
|
||||
docker-compose up
|
||||
```
|
||||
docker-compose up --build
|
||||
|
||||
### Rebuild
|
||||
|
||||
```sh
|
||||
docker-compose build
|
||||
# if you need to reset built static files and/or postgres database:
|
||||
docker-compose up --build --renew-anon-volumes
|
||||
```
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- https://askubuntu.com/q/615394/413683
|
||||
|
||||
## New docs (WIP):
|
||||
|
||||
Spejstore is a simple inventory system made for Warsaw Hackerspace purposes. Includes some features very specific to hswaw requirements, which are:
|
||||
|
||||
- Label printing and label-system support (via `django-rest-api` api views and `SPEJSTORE_LABEL_API` env variable), using the [spejstore-labelmaker](https://code.hackerspace.pl/informatic/spejstore-labelmaker/) software
|
||||
- Publically viewing all items and requiring users to sign in view oauth to manage inventory via `django-admin`
|
||||
- Authorizing label printing via local network only, see `SPEJSTORE_LAN_ALLOWED_ADDRESS_SPACE` env variable
|
||||
|
||||
Currently inventory is deployed under `inventory.waw.hackerspace.pl`, with a [Beyondspace NGINX configuration](https://cs.hackerspace.pl/hscloud/-/blob/hswaw/machines/customs.hackerspace.pl/beyondspace.nix), which allows the inventory to be accessible from outside of the Warsaw Hackerspace network with a necessary oauth authorization, but does not allow printing of labels without physically being in the local network of HSWAW.
|
||||
|
|
0
auth/__init__.py
Normal file
0
auth/__init__.py
Normal file
42
auth/backend.py
Normal file
42
auth/backend.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
from urllib.parse import urlencode
|
||||
from social_core.backends.oauth import BaseOAuth2
|
||||
|
||||
|
||||
class HSWawOAuth2(BaseOAuth2):
|
||||
"""Hackerspace OAuth authentication backend"""
|
||||
|
||||
name = "hswaw"
|
||||
ID_KEY = "username"
|
||||
AUTHORIZATION_URL = "https://sso.hackerspace.pl/oauth/authorize"
|
||||
ACCESS_TOKEN_URL = "https://sso.hackerspace.pl/oauth/token"
|
||||
DEFAULT_SCOPE = ["profile:read"]
|
||||
REDIRECT_STATE = False
|
||||
SCOPE_SEPARATOR = ","
|
||||
EXTRA_DATA = [("expires", "expires_in")]
|
||||
|
||||
def get_user_details(self, response):
|
||||
"""Return user details from Hackerspace account"""
|
||||
personal_email = None
|
||||
if response.get("personal_email"):
|
||||
personal_email = response.get("personal_email")[0]
|
||||
|
||||
return {
|
||||
"username": response.get("username"),
|
||||
"email": response.get("email"),
|
||||
"personal_email": personal_email,
|
||||
}
|
||||
|
||||
def user_data(self, access_token, *args, **kwargs):
|
||||
"""Loads user data from service"""
|
||||
url = "https://sso.hackerspace.pl/api/1/profile"
|
||||
headers = {"Authorization": "Bearer {}".format(access_token)}
|
||||
return self.get_json(url, headers=headers)
|
||||
|
||||
def auth_url(self):
|
||||
"""Return redirect url"""
|
||||
state = self.get_or_create_state()
|
||||
params = self.auth_params(state)
|
||||
params.update(self.get_scope_argument())
|
||||
params.update(self.auth_extra_arguments())
|
||||
params = urlencode(params)
|
||||
return "{0}?{1}".format(self.authorization_url(), params)
|
23
auth/pipeline.py
Normal file
23
auth/pipeline.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
from social_core.pipeline.social_auth import associate_by_email
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
|
||||
def staff_me_up(backend, details, response, uid, user, *args, **kwargs):
|
||||
user.is_staff = True
|
||||
try:
|
||||
user.groups.set([Group.objects.get(name="member")])
|
||||
except Group.DoesNotExist:
|
||||
pass
|
||||
user.save()
|
||||
|
||||
|
||||
def associate_by_personal_email(backend, details, user=None, *args, **kwargs):
|
||||
return associate_by_email(
|
||||
backend,
|
||||
{
|
||||
"email": details.get("personal_email"),
|
||||
},
|
||||
user,
|
||||
*args,
|
||||
**kwargs
|
||||
)
|
5
auth/views.py
Normal file
5
auth/views.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from django.shortcuts import redirect
|
||||
|
||||
|
||||
def auth_redirect(request):
|
||||
return redirect("social:begin", "hswaw")
|
17
docker-compose.dev-override.yml
Normal file
17
docker-compose.dev-override.yml
Normal file
|
@ -0,0 +1,17 @@
|
|||
version: "3"
|
||||
services:
|
||||
web:
|
||||
environment:
|
||||
- SPEJSTORE_ENV=dev
|
||||
- SPEJSTORE_DB_NAME=postgres
|
||||
- SPEJSTORE_DB_USER=postgres
|
||||
- SPEJSTORE_DB_PASSWORD=postgres
|
||||
- SPEJSTORE_DB_HOST=db
|
||||
# - SPEJSTORE_DB_PORT=
|
||||
- SPEJSTORE_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
# - SPEJSTORE_CLIENT_ID=
|
||||
# - SPEJSTORE_SECRET=
|
||||
# - SPEJSTORE_MEDIA_ROOT=
|
||||
# - SPEJSTORE_REQUIRE_AUTH=true
|
||||
- SPEJSTORE_OAUTH_REDIRECT_IS_HTTPS=false
|
||||
- SPEJSTORE_PROXY_TRUSTED_IPS=172.21.37.1
|
8
docker-compose.prod-override.yml
Normal file
8
docker-compose.prod-override.yml
Normal file
|
@ -0,0 +1,8 @@
|
|||
version: "3"
|
||||
|
||||
services:
|
||||
db:
|
||||
volumes:
|
||||
- /var/spejstore-data-new:/var/lib/postgresql/data
|
||||
web:
|
||||
build: .
|
|
@ -1,18 +1,28 @@
|
|||
version: "2"
|
||||
version: "3"
|
||||
services:
|
||||
db:
|
||||
build: postgres-hstore
|
||||
image: postgres:15.4
|
||||
restart: always
|
||||
volumes:
|
||||
- /var/spejstore-data:/var/lib/postgresql/data
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=postgres
|
||||
healthcheck:
|
||||
#CHANGE 1: this command checks if the database is ready, right on the source db server
|
||||
test: ["CMD-SHELL", "pg_isready -d postgres -U postgres"]
|
||||
interval: 1s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
|
||||
web:
|
||||
build: .
|
||||
restart: always
|
||||
command: bash -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000"
|
||||
command: bash -c "python manage.py collectstatic --no-input --clear && python manage.py migrate && gunicorn --workers 1 --threads 4 -b 0.0.0.0:8000 --capture-output --error-logfile - --access-logfile - spejstore.wsgi:application"
|
||||
volumes:
|
||||
- .:/code
|
||||
- /code/build_static
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
- db
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
FROM postgres:latest
|
||||
MAINTAINER Piotr Dobrowolski
|
||||
ADD create_extension.sh /docker-entrypoint-initdb.d/create_extension.sh
|
|
@ -1,16 +0,0 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Because both template1 and the user postgres database have already been created,
|
||||
# we need to create the hstore extension in template1 and then recreate the postgres database.
|
||||
#
|
||||
# Running CREATE EXTENSION in both template1 and postgres can lead to
|
||||
# the extensions having different eid's.
|
||||
psql --dbname template1 -U postgres <<EOSQL
|
||||
CREATE EXTENSION hstore;
|
||||
CREATE EXTENSION ltree;
|
||||
CREATE EXTENSION pg_trgm;
|
||||
DROP DATABASE $POSTGRES_USER;
|
||||
CREATE DATABASE $POSTGRES_USER TEMPLATE template1;
|
||||
EOSQL
|
||||
|
|
@ -1,16 +1,37 @@
|
|||
certifi==2017.4.17
|
||||
chardet==3.0.3
|
||||
Django==1.10.1
|
||||
git+https://github.com/djangonauts/django-hstore@61427e474cb2f4be8fdfce225d78a5330bc77eb0#egg=django-hstore
|
||||
git+https://github.com/informatic/django-tree@993cec827ed989d3c162698a739da95b9227604b#egg=django-tree
|
||||
django-appconf==1.0.2
|
||||
django-auth-ldap==1.2.12
|
||||
Django-Select2==5.8.10
|
||||
djangorestframework==3.5.4
|
||||
Pillow==3.3.1
|
||||
psycopg2==2.6.2
|
||||
djangorestframework-hstore==1.3
|
||||
pyldap==2.4.28
|
||||
requests==2.16.5
|
||||
urllib3==1.21.1
|
||||
django_markdown2==0.3.0
|
||||
asgiref==3.7.2
|
||||
certifi==2023.5.7
|
||||
cffi==1.15.1
|
||||
chardet==5.1.0
|
||||
charset-normalizer==3.2.0
|
||||
colorclass==2.2.2
|
||||
cryptography==41.0.1
|
||||
defusedxml==0.7.1
|
||||
Django==5.0.1
|
||||
django-admin-hstore-widget==1.2.1
|
||||
django-appconf==1.0.5
|
||||
django-hstore==1.4.2
|
||||
django-markdown2==0.3.1
|
||||
django-select2==8.1.2
|
||||
django-storages[s3]==1.14.2
|
||||
# Django-tree
|
||||
https://github.com/Palid/django-tree/archive/master.zip
|
||||
djangorestframework==3.14.0
|
||||
docopt==0.6.2
|
||||
idna==3.4
|
||||
markdown2==2.4.9
|
||||
oauthlib==3.2.2
|
||||
packaging==23.1
|
||||
Pillow==10.0.0
|
||||
psycopg2==2.9.6
|
||||
pycparser==2.21
|
||||
PyJWT==2.7.0
|
||||
python3-openid==3.2.0
|
||||
pytz==2023.3
|
||||
requests==2.31.0
|
||||
requests-oauthlib==1.3.1
|
||||
social-auth-app-django==5.2.0
|
||||
social-auth-core==4.4.2
|
||||
sqlparse==0.4.4
|
||||
terminaltables==3.1.10
|
||||
urllib3==2.0.3
|
||||
whitenoise==6.5.0
|
||||
|
|
7
spejstore-dev.env
Normal file
7
spejstore-dev.env
Normal file
|
@ -0,0 +1,7 @@
|
|||
SPEJSTORE_CLIENT_ID=inventory
|
||||
SPEJSTORE_SECRET=secret
|
||||
SPEJSTORE_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
SPEJSTORE_DB_NAME=postgres
|
||||
SPEJSTORE_DB_PASSWORD=postgres
|
||||
SPEJSTORE_DB_USER=postgres
|
||||
SPEJSTORE_DB_HOST=db
|
|
@ -12,83 +12,121 @@ https://docs.djangoproject.com/en/1.10/ref/settings/
|
|||
|
||||
import os
|
||||
|
||||
|
||||
def env(name, default=None):
|
||||
return os.getenv("SPEJSTORE_" + name, default)
|
||||
|
||||
|
||||
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "build_static")
|
||||
PROD = os.getenv("SPEJSTORE_ENV") == "prod"
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = '#hjthi7_udsyt*9eeyb&nwgw5x=%pk_lnz3+u2tg9@=w3p1m*k'
|
||||
SECRET_KEY = env("SECRET_KEY", "#hjthi7_udsyt*9eeyb&nwgw5x=%pk_lnz3+u2tg9@=w3p1m*k")
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
DEBUG = not PROD
|
||||
|
||||
ALLOWED_HOSTS = []
|
||||
ALLOWED_HOSTS = env(
|
||||
"ALLOWED_HOSTS",
|
||||
"devinventory,inventory.waw.hackerspace.pl,inventory.hackerspace.pl,i,inventory"
|
||||
+ (",127.0.0.1,locahost,*" if not PROD else ""),
|
||||
).split(",")
|
||||
LOGIN_REDIRECT_URL = "/admin/"
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = env("HOST", "https://inventory.hackerspace.pl").split(",")
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.postgres',
|
||||
|
||||
'django_hstore',
|
||||
'tree',
|
||||
'django_select2',
|
||||
'rest_framework',
|
||||
'django_markdown2',
|
||||
|
||||
'storage',
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django.contrib.postgres",
|
||||
"storages", # django-storages s3boto support
|
||||
"social_django",
|
||||
"tree",
|
||||
"django_select2",
|
||||
"rest_framework",
|
||||
"rest_framework.authtoken",
|
||||
"django_markdown2",
|
||||
"storage",
|
||||
"django_admin_hstore_widget",
|
||||
]
|
||||
|
||||
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',
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.middleware.cache.UpdateCacheMiddleware",
|
||||
"django.middleware.gzip.GZipMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"storage.middleware.is_authorized_or_in_lan_middleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"social_django.middleware.SocialAuthExceptionMiddleware",
|
||||
"django.middleware.cache.FetchFromCacheMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'spejstore.urls'
|
||||
ROOT_URLCONF = "spejstore.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': ['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',
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": ["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",
|
||||
"social_django.context_processors.backends",
|
||||
"social_django.context_processors.login_redirect",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'spejstore.wsgi.application'
|
||||
WSGI_APPLICATION = "spejstore.wsgi.application"
|
||||
|
||||
# Logging
|
||||
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": "INFO",
|
||||
},
|
||||
}
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql_psycopg2',
|
||||
'NAME': 'postgres',
|
||||
'USER': 'postgres',
|
||||
'HOST': 'db',
|
||||
'PORT': 5432,
|
||||
"default": {
|
||||
"ENGINE": env("DB_ENGINE", "django.db.backends.postgresql_psycopg2"),
|
||||
"NAME": env("DB_NAME", "postgres"),
|
||||
"USER": env("DB_USER", "postgres"),
|
||||
"PASSWORD": env("DB_PASSWORD", None),
|
||||
"HOST": env("DB_HOST", "127.0.0.1"),
|
||||
"PORT": env("DB_PORT", 5432),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -98,95 +136,133 @@ DATABASES = {
|
|||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
# LDAP configuration
|
||||
|
||||
import ldap
|
||||
from django_auth_ldap.config import LDAPSearch, GroupOfUniqueNamesType, LDAPGroupQuery
|
||||
|
||||
AUTHENTICATION_BACKENDS = (
|
||||
'django_auth_ldap.backend.LDAPBackend',
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
"auth.backend.HSWawOAuth2",
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
)
|
||||
|
||||
AUTH_LDAP_SERVER_URI = "ldaps://ldap.hackerspace.pl"
|
||||
AUTH_LDAP_USER_DN_TEMPLATE = "uid=%(user)s,ou=People,dc=hackerspace,dc=pl"
|
||||
AUTH_LDAP_BIND_AS_AUTHENTICATING_USER = True
|
||||
SOCIAL_AUTH_PIPELINE = (
|
||||
"social_core.pipeline.social_auth.social_details",
|
||||
"social_core.pipeline.social_auth.social_uid",
|
||||
"social_core.pipeline.social_auth.social_user",
|
||||
"social_core.pipeline.user.get_username",
|
||||
"social_core.pipeline.social_auth.associate_by_email",
|
||||
"auth.pipeline.associate_by_personal_email",
|
||||
"social_core.pipeline.user.create_user",
|
||||
"social_core.pipeline.social_auth.associate_user",
|
||||
"social_core.pipeline.social_auth.load_extra_data",
|
||||
"social_core.pipeline.user.user_details",
|
||||
"auth.pipeline.staff_me_up",
|
||||
)
|
||||
|
||||
member_ldap_query = (
|
||||
LDAPGroupQuery("cn=fatty,ou=Group,dc=hackerspace,dc=pl") |
|
||||
LDAPGroupQuery("cn=starving,ou=Group,dc=hackerspace,dc=pl") |
|
||||
LDAPGroupQuery("cn=potato,ou=Group,dc=hackerspace,dc=pl"))
|
||||
|
||||
AUTH_LDAP_USER_FLAGS_BY_GROUP = {
|
||||
"is_active": member_ldap_query,
|
||||
"is_superuser": member_ldap_query, # "cn=staff,ou=Group,dc=hackerspace,dc=pl",
|
||||
"is_staff": member_ldap_query,
|
||||
# Determines the storage type for Django static files and media.
|
||||
FILE_STORAGE_TYPE = env("FILE_STORAGE_TYPE", "filesystem")
|
||||
if FILE_STORAGE_TYPE == "filesystem":
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
elif FILE_STORAGE_TYPE == "s3":
|
||||
S3_BUCKET_NAME = env("S3_BUCKET_NAME", "inventory")
|
||||
S3_ENDPOINT_URL = env("S3_ENDPOINT_URL", "https://object.ceph-eu.hswaw.net")
|
||||
S3_DOMAIN_NAME = env("S3_DOMAIN_NAME", "object.ceph-eu.hswaw.net")
|
||||
S3_ACCESS_KEY = env("S3_ACCESS_KEY", "")
|
||||
S3_SECRET_KEY = env("S3_SECRET_KEY", "=")
|
||||
S3_STATIC_LOCATION = "static"
|
||||
S3_MEDIA_LOCATION = "media"
|
||||
STORAGES = {
|
||||
"default": {
|
||||
"BACKEND": "storages.backends.s3.S3Storage",
|
||||
"OPTIONS": {
|
||||
"access_key": S3_ACCESS_KEY,
|
||||
"secret_key": S3_SECRET_KEY,
|
||||
"endpoint_url": S3_ENDPOINT_URL,
|
||||
"bucket_name": S3_BUCKET_NAME,
|
||||
"default_acl": "public-read",
|
||||
"location": S3_MEDIA_LOCATION,
|
||||
"custom_domain": f"{S3_DOMAIN_NAME}/{S3_BUCKET_NAME}",
|
||||
"file_overwrite": False,
|
||||
},
|
||||
},
|
||||
"staticfiles": {
|
||||
"BACKEND": "storages.backends.s3.S3Storage",
|
||||
"OPTIONS": {
|
||||
"access_key": S3_ACCESS_KEY,
|
||||
"secret_key": S3_SECRET_KEY,
|
||||
"endpoint_url": S3_ENDPOINT_URL,
|
||||
"bucket_name": S3_BUCKET_NAME,
|
||||
"default_acl": "public-read",
|
||||
"location": S3_STATIC_LOCATION,
|
||||
"custom_domain": f"{S3_DOMAIN_NAME}/{S3_BUCKET_NAME}",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Populate the Django user from the LDAP directory.
|
||||
AUTH_LDAP_USER_ATTR_MAP = {
|
||||
"first_name": "givenName",
|
||||
"last_name": "sn",
|
||||
"email": "mail"
|
||||
}
|
||||
|
||||
AUTH_LDAP_GROUP_SEARCH = LDAPSearch("ou=Group,dc=hackerspace,dc=pl",
|
||||
ldap.SCOPE_SUBTREE, "(objectClass=groupOfUniqueNames)"
|
||||
)
|
||||
AUTH_LDAP_GROUP_TYPE = GroupOfUniqueNamesType(name_attr="cn")
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger('django_auth_ldap')
|
||||
logger.addHandler(logging.StreamHandler())
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/1.10/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
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/1.10/howto/static-files/
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_URL = "/static/"
|
||||
STATICFILES_DIRS = [os.path.join(BASE_DIR, "static")]
|
||||
|
||||
STATICFILES_DIRS = [
|
||||
os.path.join(BASE_DIR, "static"),
|
||||
]
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = env("MEDIA_ROOT", os.path.join(BASE_DIR, "media"))
|
||||
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
|
||||
REQUIRE_AUTH = env("REQUIRE_AUTH", "true")
|
||||
if REQUIRE_AUTH == "true":
|
||||
REQUIRE_AUTH = True
|
||||
elif REQUIRE_AUTH == "false":
|
||||
REQUIRE_AUTH = False
|
||||
|
||||
# REST Framework
|
||||
REST_FRAMEWORK = {
|
||||
# Use Django's standard `django.contrib.auth` permissions,
|
||||
# or allow read-only access for unauthenticated users.
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly'
|
||||
]
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"rest_framework.permissions.IsAuthenticatedOrReadOnly"
|
||||
if REQUIRE_AUTH
|
||||
else "rest_framework.permissions.IsAuthenticated",
|
||||
],
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"storage.authentication.LanAuthentication",
|
||||
"rest_framework.authentication.BasicAuthentication",
|
||||
"rest_framework.authentication.TokenAuthentication",
|
||||
],
|
||||
}
|
||||
|
||||
LABEL_API = 'http://label.waw.hackerspace.pl:4567'
|
||||
SOCIAL_AUTH_HSWAW_KEY = env("CLIENT_ID")
|
||||
SOCIAL_AUTH_HSWAW_SECRET = env("SECRET")
|
||||
SOCIAL_AUTH_REDIRECT_IS_HTTPS = env("OAUTH_REDIRECT_IS_HTTPS", "true") == "true"
|
||||
|
||||
SOCIAL_AUTH_JSONFIELD_ENABLED = True
|
||||
|
||||
LABEL_API = env("LABEL_API", "http://label.waw.hackerspace.pl:4567")
|
||||
LOGIN_URL = "/admin/login/"
|
||||
# Local LAN address space
|
||||
LAN_ALLOWED_ADDRESS_SPACE = env("LAN_ALLOWED_ADDRESS_SPACE", "")
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/1.10/topics/http/urls/
|
||||
"""
|
||||
from django.conf.urls import url, include
|
||||
from django.urls import re_path, include
|
||||
from django.contrib import admin
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
|
@ -12,19 +12,29 @@ from rest_framework import routers
|
|||
|
||||
from storage import apiviews
|
||||
|
||||
from auth.views import auth_redirect
|
||||
|
||||
|
||||
router = routers.DefaultRouter()
|
||||
router.register(r'items', apiviews.ItemViewSet)
|
||||
router.register(r'labels', apiviews.LabelViewSet)
|
||||
router.register(r"items", apiviews.ItemViewSet)
|
||||
router.register(r"labels", apiviews.LabelViewSet)
|
||||
|
||||
# Wire up our API using automatic URL routing.
|
||||
# Additionally, we include login URLs for the browsable API.
|
||||
urlpatterns = [
|
||||
url(r'^admin/', admin.site.urls),
|
||||
url(r'^select2/', include('django_select2.urls')),
|
||||
|
||||
url(r'^', include('storage.urls')),
|
||||
url(r'^api/1/', include(router.urls)),
|
||||
] \
|
||||
+ static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) \
|
||||
urlpatterns = (
|
||||
(
|
||||
[
|
||||
re_path(r"^admin/login/.*", auth_redirect),
|
||||
]
|
||||
if settings.PROD
|
||||
else []
|
||||
)
|
||||
+ [
|
||||
re_path(r"^admin/", admin.site.urls),
|
||||
re_path(r"^select2/", include("django_select2.urls")),
|
||||
re_path(r"^", include("storage.urls")),
|
||||
re_path(r"^api/1/", include(router.urls)),
|
||||
]
|
||||
+ static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
+ static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
)
|
||||
|
|
|
@ -0,0 +1,216 @@
|
|||
var initDjangoHStoreWidget = function (hstore_field_name, inline_prefix) {
|
||||
// ignore inline templates
|
||||
// if hstore_field_name contains "__prefix__"
|
||||
if (hstore_field_name.indexOf("__prefix__") > -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
var $ = django.jQuery;
|
||||
|
||||
// processing inlines
|
||||
if (hstore_field_name.indexOf("inline") > -1) {
|
||||
var inlineClass = $("#id_" + hstore_field_name)
|
||||
.parents(".inline-related, .grp-group")
|
||||
.attr("class");
|
||||
// if using TabularInlines stop here
|
||||
// TabularInlines not supported
|
||||
if (inlineClass.indexOf("tabular") > -1) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// reusable function that retrieves a template even if ID is not correct
|
||||
// (written to support inlines)
|
||||
var retrieveTemplate = function (template_name, field_name) {
|
||||
var specific_template = $("#" + template_name + "-" + field_name);
|
||||
// if found specific template return that
|
||||
if (specific_template.length) {
|
||||
return specific_template.html();
|
||||
} else {
|
||||
// get fallback template
|
||||
var html = $("." + template_name + "-inline").html();
|
||||
// replace all occurrences of __prefix__ with field_name
|
||||
// and return
|
||||
html = html.replace(/__prefix__/g, inline_prefix);
|
||||
return html;
|
||||
}
|
||||
};
|
||||
|
||||
// reusable function that compiles the UI
|
||||
var compileUI = function (params) {
|
||||
var hstore_field_id = "id_" + hstore_field_name,
|
||||
original_textarea = $("#" + hstore_field_id),
|
||||
original_value = original_textarea.val(),
|
||||
original_container = original_textarea
|
||||
.parents(".form-row, .grp-row")
|
||||
.eq(0),
|
||||
errorHtml = original_container.find(".errorlist").html(),
|
||||
json_data = {};
|
||||
|
||||
if (original_value !== "") {
|
||||
// manage case in which textarea is blank
|
||||
try {
|
||||
json_data = JSON.parse(original_value);
|
||||
} catch (e) {
|
||||
alert("invalid JSON:\n" + e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var hstore_field_data = {
|
||||
id: hstore_field_id,
|
||||
label: original_container.find("label").text(),
|
||||
name: hstore_field_name,
|
||||
value: original_textarea.val(),
|
||||
help: original_container.find(".grp-help, .help").text(),
|
||||
errors: errorHtml,
|
||||
data: json_data,
|
||||
},
|
||||
// compile template
|
||||
ui_html = retrieveTemplate("hstore-ui-template", hstore_field_name),
|
||||
compiled_ui_html = _.template(ui_html, hstore_field_data);
|
||||
|
||||
// this is just to DRY up a bit
|
||||
if (params && params.replace_original === true) {
|
||||
// remove original textarea to avoid having two textareas with same ID
|
||||
original_textarea.remove();
|
||||
// inject compiled template and hide original
|
||||
original_container.after(compiled_ui_html).hide();
|
||||
}
|
||||
|
||||
return compiled_ui_html;
|
||||
};
|
||||
|
||||
// generate UI
|
||||
compileUI({ replace_original: true });
|
||||
|
||||
// cache other objects that we'll reuse
|
||||
var row_html = retrieveTemplate("hstore-row-template", hstore_field_name),
|
||||
empty_row = _.template(row_html, { key: "", value: "" }),
|
||||
$hstore = $("#id_" + hstore_field_name).parents(".hstore");
|
||||
|
||||
// reusable function that updates the textarea value
|
||||
var updateTextarea = function (container) {
|
||||
// init empty json object
|
||||
var new_value = {},
|
||||
raw_textarea = container.find("textarea"),
|
||||
rows = container.find(".form-row, .grp-row");
|
||||
|
||||
// loop over each object and populate json
|
||||
rows.each(function () {
|
||||
var inputs = $(this).find("input"),
|
||||
key = $(this).find(".hs-key").val(),
|
||||
value = $(this).find(".hs-val").val();
|
||||
new_value[key] = value;
|
||||
});
|
||||
|
||||
// update textarea value
|
||||
$(raw_textarea).val(JSON.stringify(new_value, null, 4));
|
||||
};
|
||||
|
||||
// remove row link
|
||||
$hstore.delegate("a.remove-row", "click", function (e) {
|
||||
e.preventDefault();
|
||||
// cache container jquery object before $(this) gets removed
|
||||
$(this).parents(".form-row, .grp-row").eq(0).remove();
|
||||
updateTextarea($hstore);
|
||||
});
|
||||
|
||||
// add row link
|
||||
$hstore.delegate("a.hs-add-row, .hs-add-row a", "click", function (e) {
|
||||
e.preventDefault();
|
||||
$hstore.find(".hstore-rows").append(empty_row);
|
||||
$(".django-select2").select2();
|
||||
$("select").on("select2:close", function () {
|
||||
$(this).focus();
|
||||
});
|
||||
});
|
||||
|
||||
// toggle textarea link
|
||||
$hstore.delegate(".hstore-toggle-txtarea", "click", function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
var raw_textarea = $hstore.find(".hstore-textarea"),
|
||||
hstore_rows = $hstore.find(".hstore-rows"),
|
||||
add_row = $hstore.find(".hs-add-row");
|
||||
|
||||
if (raw_textarea.is(":visible")) {
|
||||
var compiled_ui = compileUI();
|
||||
|
||||
// in case of JSON error
|
||||
if (compiled_ui === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// jquery < 1.8
|
||||
try {
|
||||
var $ui = $(compiled_ui);
|
||||
} catch (e) {
|
||||
// jquery >= 1.8
|
||||
var $ui = $($.parseHTML(compiled_ui));
|
||||
}
|
||||
|
||||
// update rows with only relevant content
|
||||
hstore_rows.html($ui.find(".hstore-rows").html());
|
||||
|
||||
raw_textarea.hide();
|
||||
hstore_rows.show();
|
||||
add_row.show();
|
||||
|
||||
$(".django-select2").select2();
|
||||
} else {
|
||||
raw_textarea.show();
|
||||
hstore_rows.hide();
|
||||
add_row.hide();
|
||||
}
|
||||
});
|
||||
|
||||
// update textarea whenever a field changes
|
||||
$hstore.delegate(".hs-val", "keyup propertychange", function () {
|
||||
updateTextarea($hstore);
|
||||
});
|
||||
|
||||
$hstore.delegate(".hs-key", "change", function () {
|
||||
updateTextarea($hstore);
|
||||
});
|
||||
};
|
||||
window.addEventListener("load", function () {
|
||||
// support inlines
|
||||
// bind only once
|
||||
if (django.hstoreWidgetBoundInlines === undefined) {
|
||||
var $ = django.jQuery;
|
||||
$(
|
||||
".grp-group .grp-add-handler, .inline-group .hs-add-row a, .inline-group .add-row"
|
||||
).click(function (e) {
|
||||
var hstore_original_textareas = $(this)
|
||||
.parents(".grp-group, .inline-group")
|
||||
.eq(0)
|
||||
.find(".hstore-original-textarea");
|
||||
// if module contains .hstore-original-textarea
|
||||
if (hstore_original_textareas.length > 0) {
|
||||
// loop over each inline
|
||||
$(this)
|
||||
.parents(".grp-group, .inline-group")
|
||||
.find(".grp-items div.grp-dynamic-form, .inline-related")
|
||||
.each(function (e, i) {
|
||||
var prefix = i;
|
||||
// loop each textarea
|
||||
$(this)
|
||||
.find(".hstore-original-textarea")
|
||||
.each(function (e, i) {
|
||||
// cache field name
|
||||
var field_name = $(this).attr("name");
|
||||
// ignore templates
|
||||
// if name attribute contains __prefix__
|
||||
if (field_name.indexOf("prefix") > -1) {
|
||||
// skip to next
|
||||
return;
|
||||
}
|
||||
initDjangoHStoreWidget(field_name, prefix);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
django.hstoreWidgetBoundInlines = true;
|
||||
}
|
||||
});
|
|
@ -1,208 +0,0 @@
|
|||
var initDjangoHStoreWidget = function(hstore_field_name, inline_prefix) {
|
||||
// ignore inline templates
|
||||
// if hstore_field_name contains "__prefix__"
|
||||
if(hstore_field_name.indexOf('__prefix__') > -1){
|
||||
return;
|
||||
}
|
||||
|
||||
// processing inlines
|
||||
if(hstore_field_name.indexOf('inline') > -1){
|
||||
var inlineClass = $('#id_'+hstore_field_name).parents('.inline-related, .grp-group').attr('class');
|
||||
// if using TabularInlines stop here
|
||||
// TabularInlines not supported
|
||||
if (inlineClass.indexOf('tabular') > -1) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// reusable function that retrieves a template even if ID is not correct
|
||||
// (written to support inlines)
|
||||
var retrieveTemplate = function(template_name, field_name){
|
||||
var specific_template = $('#'+template_name+'-'+field_name);
|
||||
// if found specific template return that
|
||||
if(specific_template.length){
|
||||
return specific_template.html();
|
||||
}
|
||||
else{
|
||||
// get fallback template
|
||||
var html = $('.'+template_name+'-inline').html();
|
||||
// replace all occurrences of __prefix__ with field_name
|
||||
// and return
|
||||
html = html.replace(/__prefix__/g, inline_prefix);
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
// reusable function that compiles the UI
|
||||
var compileUI = function(params){
|
||||
var hstore_field_id = 'id_'+hstore_field_name,
|
||||
original_textarea = $('#'+hstore_field_id),
|
||||
original_value = original_textarea.val(),
|
||||
original_container = original_textarea.parents('.form-row, .grp-row').eq(0),
|
||||
errorHtml = original_container.find('.errorlist').html(),
|
||||
json_data = {};
|
||||
|
||||
if(original_value !== ''){
|
||||
// manage case in which textarea is blank
|
||||
try{
|
||||
json_data = JSON.parse(original_value);
|
||||
}
|
||||
catch(e){
|
||||
alert('invalid JSON:\n'+e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var hstore_field_data = {
|
||||
"id": hstore_field_id,
|
||||
"label": original_container.find('label').text(),
|
||||
"name": hstore_field_name,
|
||||
"value": original_textarea.val(),
|
||||
"help": original_container.find('.grp-help, .help').text(),
|
||||
"errors": errorHtml,
|
||||
"data": json_data
|
||||
},
|
||||
// compile template
|
||||
ui_html = retrieveTemplate('hstore-ui-template', hstore_field_name),
|
||||
compiled_ui_html = _.template(ui_html, hstore_field_data);
|
||||
|
||||
// this is just to DRY up a bit
|
||||
if(params && params.replace_original === true){
|
||||
// remove original textarea to avoid having two textareas with same ID
|
||||
original_textarea.remove();
|
||||
// inject compiled template and hide original
|
||||
original_container.after(compiled_ui_html).hide();
|
||||
}
|
||||
|
||||
return compiled_ui_html;
|
||||
};
|
||||
|
||||
|
||||
|
||||
// generate UI
|
||||
compileUI({ replace_original: true });
|
||||
|
||||
// cache other objects that we'll reuse
|
||||
var row_html = retrieveTemplate('hstore-row-template', hstore_field_name),
|
||||
empty_row = _.template(row_html, { 'key': '', 'value': '' }),
|
||||
$hstore = $('#id_'+hstore_field_name).parents('.hstore');
|
||||
|
||||
// reusable function that updates the textarea value
|
||||
var updateTextarea = function(container) {
|
||||
// init empty json object
|
||||
var new_value = {},
|
||||
raw_textarea = container.find('textarea'),
|
||||
rows = container.find('.form-row, .grp-row');
|
||||
|
||||
// loop over each object and populate json
|
||||
rows.each(function() {
|
||||
var inputs = $(this).find('input'),
|
||||
key = $(this).find('.hs-key').val(),
|
||||
value = $(this).find('.hs-val').val();
|
||||
new_value[key] = value;
|
||||
});
|
||||
|
||||
// update textarea value
|
||||
$(raw_textarea).val(JSON.stringify(new_value, null, 4));
|
||||
};
|
||||
|
||||
// remove row link
|
||||
$hstore.delegate('a.remove-row', 'click', function(e) {
|
||||
e.preventDefault();
|
||||
// cache container jquery object before $(this) gets removed
|
||||
$(this).parents('.form-row, .grp-row').eq(0).remove();
|
||||
updateTextarea($hstore);
|
||||
});
|
||||
|
||||
// add row link
|
||||
$hstore.delegate('a.hs-add-row, .hs-add-row a', 'click', function(e) {
|
||||
e.preventDefault();
|
||||
$hstore.find('.hstore-rows').append(empty_row);
|
||||
$('.django-select2').djangoSelect2()
|
||||
$('select').on( 'select2:close', function () {
|
||||
$(this).focus();
|
||||
});
|
||||
});
|
||||
|
||||
// toggle textarea link
|
||||
$hstore.delegate('.hstore-toggle-txtarea', 'click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
var raw_textarea = $hstore.find('.hstore-textarea'),
|
||||
hstore_rows = $hstore.find('.hstore-rows'),
|
||||
add_row = $hstore.find('.hs-add-row');
|
||||
|
||||
if(raw_textarea.is(':visible')) {
|
||||
|
||||
var compiled_ui = compileUI();
|
||||
|
||||
// in case of JSON error
|
||||
if(compiled_ui === false){
|
||||
return;
|
||||
}
|
||||
|
||||
// jquery < 1.8
|
||||
try{
|
||||
var $ui = $(compiled_ui);
|
||||
}
|
||||
// jquery >= 1.8
|
||||
catch(e){
|
||||
var $ui = $($.parseHTML(compiled_ui));
|
||||
}
|
||||
|
||||
// update rows with only relevant content
|
||||
hstore_rows.html($ui.find('.hstore-rows').html());
|
||||
|
||||
raw_textarea.hide();
|
||||
hstore_rows.show();
|
||||
add_row.show();
|
||||
|
||||
$('.django-select2').djangoSelect2()
|
||||
}
|
||||
else{
|
||||
raw_textarea.show();
|
||||
hstore_rows.hide();
|
||||
add_row.hide();
|
||||
}
|
||||
});
|
||||
|
||||
// update textarea whenever a field changes
|
||||
$hstore.delegate('.hs-val', 'keyup', function() {
|
||||
updateTextarea($hstore);
|
||||
});
|
||||
|
||||
$hstore.delegate('.hs-key', 'change', function() {
|
||||
updateTextarea($hstore);
|
||||
});
|
||||
};
|
||||
|
||||
django.jQuery(window).load(function() {
|
||||
// support inlines
|
||||
// bind only once
|
||||
if(django.hstoreWidgetBoundInlines === undefined){
|
||||
var $ = django.jQuery;
|
||||
$('.grp-group .grp-add-handler, .inline-group .hs-add-row a, .inline-group .add-row').click(function(e){
|
||||
var hstore_original_textareas = $(this).parents('.grp-group, .inline-group').eq(0).find('.hstore-original-textarea');
|
||||
// if module contains .hstore-original-textarea
|
||||
if(hstore_original_textareas.length > 0){
|
||||
// loop over each inline
|
||||
$(this).parents('.grp-group, .inline-group').find('.grp-items div.grp-dynamic-form, .inline-related').each(function(e, i){
|
||||
var prefix = i;
|
||||
// loop each textarea
|
||||
$(this).find('.hstore-original-textarea').each(function(e, i){
|
||||
// cache field name
|
||||
var field_name = $(this).attr('name');
|
||||
// ignore templates
|
||||
// if name attribute contains __prefix__
|
||||
if(field_name.indexOf('prefix') > -1){
|
||||
// skip to next
|
||||
return;
|
||||
}
|
||||
initDjangoHStoreWidget(field_name, prefix);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
django.hstoreWidgetBoundInlines = true;
|
||||
}
|
||||
});
|
|
@ -1,4 +1,38 @@
|
|||
/* django-admin CSS patches */
|
||||
.select2-container {
|
||||
min-width: 400px;
|
||||
min-width: 400px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
/* Change the appearence of the bakground colour surrounding the search input field */
|
||||
.select2-search {
|
||||
background-color: #343a40 !important;
|
||||
}
|
||||
/* Change the appearence of the search input field */
|
||||
.select2-search input {
|
||||
color: #ffffff !important;
|
||||
background-color: #343a40 !important;
|
||||
}
|
||||
|
||||
/* Change the appearence of the search results container */
|
||||
.select2-results {
|
||||
background-color: #343a40 !important;
|
||||
}
|
||||
|
||||
/* Change the appearence of the dropdown select container */
|
||||
.select2-container .select2-selection {
|
||||
border-color: #6c757d !important;
|
||||
color: #ffffff !important;
|
||||
background-color: #343a40 !important;
|
||||
}
|
||||
|
||||
/* Change the caret down arrow symbol to white */
|
||||
.select2-container .select2-selection__arrow b {
|
||||
border-color: #fff transparent transparent transparent !important;
|
||||
}
|
||||
|
||||
/* Change the color of the default selected item i.e. the first option */
|
||||
.select2-container .select2-selection--single .select2-selection__rendered {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
}
|
||||
|
|
7171
static/css/bootstrap.css
vendored
7171
static/css/bootstrap.css
vendored
File diff suppressed because one or more lines are too long
|
@ -1,32 +1,42 @@
|
|||
.table td.placeholder {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.table td.placeholder a {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.containericon {
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.containericon span {
|
||||
padding-left: 0.5rem;
|
||||
font-weight: bold;
|
||||
padding-left: 0.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.label-item {
|
||||
background: #f5f5f5;
|
||||
border-radius: 3px;
|
||||
display: inline-block;
|
||||
background: #f5f5f5;
|
||||
border-radius: 3px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.label-item code {
|
||||
margin: 0em 1em;
|
||||
margin: 0em 1em;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
a {
|
||||
color: #81d4fa !important;
|
||||
}
|
||||
|
||||
a.navbar-brand {
|
||||
color: #fff !important;
|
||||
}
|
||||
}
|
||||
|
|
14
static/css/theme.min.css
vendored
Normal file
14
static/css/theme.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
static/fonts/pressstart2p-regular-webfont.woff
Normal file
BIN
static/fonts/pressstart2p-regular-webfont.woff
Normal file
Binary file not shown.
BIN
static/fonts/pressstart2p-regular-webfont.woff2
Normal file
BIN
static/fonts/pressstart2p-regular-webfont.woff2
Normal file
Binary file not shown.
7
static/js/bootstrap.min.js
vendored
Normal file
7
static/js/bootstrap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
static/js/jquery.min.js
vendored
Normal file
2
static/js/jquery.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
123
storage/admin.py
123
storage/admin.py
|
@ -1,80 +1,145 @@
|
|||
from django import forms
|
||||
from django.contrib import admin
|
||||
|
||||
from django_select2.forms import ModelSelect2Widget, Select2MultipleWidget
|
||||
|
||||
from .models import Item, ItemImage, Category, Label
|
||||
from .widgets import ItemSelectWidget, PropsSelectWidget
|
||||
from .models import Item, ItemImage, Category, StaffProxyModel
|
||||
|
||||
from .widgets import PropsSelectWidget
|
||||
|
||||
|
||||
class ModelAdminMixin(object):
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return request.user.is_authenticated
|
||||
|
||||
has_change_permission = has_add_permission
|
||||
has_delete_permission = has_add_permission
|
||||
has_module_permission = has_add_permission
|
||||
|
||||
|
||||
class ItemForm(forms.ModelForm):
|
||||
name = forms.CharField(widget=forms.TextInput())
|
||||
wiki_link = forms.CharField(required=False, widget=forms.TextInput())
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
exclude = []
|
||||
widgets = {
|
||||
'parent': ItemSelectWidget,
|
||||
'categories': Select2MultipleWidget,
|
||||
'props': PropsSelectWidget
|
||||
}
|
||||
"props": PropsSelectWidget,
|
||||
}
|
||||
|
||||
|
||||
class ItemImageInline(admin.TabularInline):
|
||||
class ItemImageInline(ModelAdminMixin, admin.TabularInline):
|
||||
model = ItemImage
|
||||
extra = 1
|
||||
|
||||
|
||||
class LabelInline(admin.TabularInline):
|
||||
model = Label
|
||||
|
||||
|
||||
class ItemAdmin(admin.ModelAdmin):
|
||||
list_display = ('_name',)
|
||||
list_filter = ('categories',)
|
||||
class ItemAdmin(ModelAdminMixin, admin.ModelAdmin):
|
||||
list_display = ("_name",)
|
||||
list_filter = ("categories",)
|
||||
form = ItemForm
|
||||
inlines = [ItemImageInline, LabelInline]
|
||||
inlines = [ItemImageInline]
|
||||
save_on_top = True
|
||||
autocomplete_fields = [
|
||||
"parent",
|
||||
"owner",
|
||||
"taken_by",
|
||||
"categories",
|
||||
]
|
||||
search_fields = [
|
||||
"name",
|
||||
]
|
||||
|
||||
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
||||
formfield = super().formfield_for_foreignkey(db_field, request, **kwargs)
|
||||
if db_field.name == "owner":
|
||||
formfield.queryset = formfield.queryset.order_by("username")
|
||||
return formfield
|
||||
|
||||
def _name(self, obj):
|
||||
return '-' * obj.get_level() + '> ' + obj.name
|
||||
return ("-" * (obj.get_level() or 0)) + "> " + obj.name
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
super(ItemAdmin, self).save_model(request, obj, form, change)
|
||||
|
||||
# Store last input parent to use as default on next creation
|
||||
if obj.parent:
|
||||
request.session['last-parent'] = str(obj.parent.uuid)
|
||||
request.session["last-parent"] = str(obj.parent.uuid)
|
||||
else:
|
||||
request.session['last-parent'] = str(obj.uuid)
|
||||
request.session["last-parent"] = str(obj.uuid)
|
||||
|
||||
def get_changeform_initial_data(self, request):
|
||||
data = {
|
||||
'parent': request.GET.get('parent') or request.session.get('last-parent')
|
||||
}
|
||||
"parent": request.GET.get("parent") or request.session.get("last-parent")
|
||||
}
|
||||
|
||||
data.update(super(ItemAdmin, self).get_changeform_initial_data(request))
|
||||
return data
|
||||
|
||||
class Media:
|
||||
js = (
|
||||
# Required by select2
|
||||
'https://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js',
|
||||
)
|
||||
css = {
|
||||
'all': ('css/admin.css',)
|
||||
}
|
||||
css = {"all": ("css/admin.css",)}
|
||||
|
||||
def response_action(self, request, queryset):
|
||||
with Item.disabled_tree_trigger():
|
||||
return super(ItemAdmin, self).response_action(request, queryset)
|
||||
|
||||
|
||||
class NormalModelAdmin(ModelAdminMixin, admin.ModelAdmin):
|
||||
search_fields = ["name"]
|
||||
pass
|
||||
|
||||
|
||||
admin.site.site_title = "Hackerspace Storage Admin"
|
||||
admin.site.site_header = "Hackerspace Storage Admin"
|
||||
|
||||
admin.site.register(Item, ItemAdmin)
|
||||
admin.site.register(Category)
|
||||
admin.site.register(Category, NormalModelAdmin)
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.models import Group
|
||||
|
||||
User.add_to_class("get_short_name", User.get_username)
|
||||
User.add_to_class("get_full_name", User.get_username)
|
||||
|
||||
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
|
||||
|
||||
class StaffProxyModelAdmin(UserAdmin):
|
||||
def has_module_permission(self, request):
|
||||
return request.user.is_superuser
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return request.user.is_superuser
|
||||
|
||||
def __has_view_permission(self, request, obj=None):
|
||||
return True
|
||||
|
||||
has_view_permission = __has_view_permission
|
||||
|
||||
has_change_permission = has_add_permission
|
||||
has_delete_permission = has_add_permission
|
||||
has_module_permission = has_add_permission
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
form = super().get_form(request, obj, **kwargs)
|
||||
is_superuser = request.user.is_superuser
|
||||
|
||||
if not is_superuser:
|
||||
for f in form.base_fields:
|
||||
form.base_fields[f].disabled = True
|
||||
|
||||
return form
|
||||
|
||||
|
||||
# admin.site.register(StaffProxyModel, StaffProxyModelAdmin)
|
||||
|
||||
admin.site.unregister(User)
|
||||
admin.site.unregister(Group)
|
||||
|
||||
from social_django.admin import UserSocialAuth, Nonce, Association
|
||||
|
||||
admin.site.unregister(UserSocialAuth)
|
||||
admin.site.unregister(Nonce)
|
||||
admin.site.unregister(Association)
|
||||
|
||||
admin.site.register(StaffProxyModel, StaffProxyModelAdmin)
|
||||
|
|
|
@ -1,22 +1,30 @@
|
|||
from rest_framework import viewsets, generics, filters
|
||||
from rest_framework import viewsets, filters
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import AllowAny
|
||||
from storage.authentication import LanAuthentication
|
||||
|
||||
from storage.models import Item, Label
|
||||
from storage.serializers import ItemSerializer, LabelSerializer
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.http import Http404
|
||||
|
||||
from storage.views import apply_smart_search
|
||||
|
||||
|
||||
def api_print(quantity, obj):
|
||||
amount = min(int(quantity), 5)
|
||||
for _ in range(amount):
|
||||
obj.print()
|
||||
return Response({"status": "success"})
|
||||
|
||||
|
||||
class SmartSearchFilterBackend(filters.BaseFilterBackend):
|
||||
"""
|
||||
Filters query using smartsearch filter
|
||||
"""
|
||||
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
search_query = request.query_params.get('smartsearch', None)
|
||||
search_query = request.query_params.get("smartsearch", None)
|
||||
if search_query:
|
||||
return apply_smart_search(search_query, queryset)
|
||||
|
||||
|
@ -27,53 +35,84 @@ class LabelViewSet(viewsets.ModelViewSet):
|
|||
"""
|
||||
API endpoint that allows items to be viewed or edited.
|
||||
"""
|
||||
queryset = Label.objects
|
||||
|
||||
queryset = Label.objects.all()
|
||||
serializer_class = LabelSerializer
|
||||
|
||||
@detail_route(methods=['post'], permission_classes=[AllowAny])
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
)
|
||||
def print(self, request, pk):
|
||||
obj = self.get_object()
|
||||
obj.print()
|
||||
return obj
|
||||
return api_print(request.query_params.get("quantity", 1), self.get_object())
|
||||
|
||||
|
||||
class ItemViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint that allows items to be viewed or edited.
|
||||
"""
|
||||
queryset = Item.objects
|
||||
|
||||
queryset = Item.objects.all()
|
||||
serializer_class = ItemSerializer
|
||||
filter_backends = (SmartSearchFilterBackend, filters.OrderingFilter)
|
||||
ordering_fields = '__all__'
|
||||
|
||||
ordering_fields = "__all__"
|
||||
|
||||
def get_queryset(self):
|
||||
return Item.get_roots()
|
||||
return Item.objects.filter(**{"path__level": 1})
|
||||
|
||||
def get_object(self):
|
||||
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
|
||||
|
||||
obj = get_object_or_404(Item, pk=self.kwargs[lookup_url_kwarg])
|
||||
obj = self.get_item_by_id_or_label(self.kwargs[lookup_url_kwarg])
|
||||
self.check_object_permissions(self.request, obj)
|
||||
|
||||
return obj
|
||||
|
||||
@detail_route()
|
||||
def get_item_by_id_or_label(self, id):
|
||||
try:
|
||||
item = Item.objects.get(uuid__startswith=id) # look up by short id
|
||||
return item
|
||||
except Item.DoesNotExist:
|
||||
try:
|
||||
label = Label.objects.get(pk=id)
|
||||
return label.item
|
||||
except Label.DoesNotExist:
|
||||
raise Http404()
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
# AllowAny is correct here, as we require LanAuthentication anyways
|
||||
permission_classes=[AllowAny],
|
||||
authentication_classes=[LanAuthentication],
|
||||
)
|
||||
def print(self, request, pk):
|
||||
return api_print(request.query_params.get("quantity", 1), self.get_object())
|
||||
|
||||
@action(detail=True, authentication_classes=[LanAuthentication])
|
||||
def children(self, request, pk):
|
||||
item = self.get_object()
|
||||
return Response(self.serializer_class(item.get_children().all(), many=True).data)
|
||||
return Response(
|
||||
self.serializer_class(item.get_children().all(), many=True).data
|
||||
)
|
||||
|
||||
@detail_route()
|
||||
@action(detail=True, authentication_classes=[LanAuthentication])
|
||||
def ancestors(self, request, pk):
|
||||
item = self.get_object()
|
||||
return Response(self.serializer_class(item.get_ancestors().all(), many=True).data)
|
||||
return Response(
|
||||
self.serializer_class(item.get_ancestors().all(), many=True).data
|
||||
)
|
||||
|
||||
@detail_route()
|
||||
@action(detail=True, authentication_classes=[LanAuthentication])
|
||||
def descendants(self, request, pk):
|
||||
item = self.get_object()
|
||||
return Response(self.serializer_class(item.get_descendants().all(), many=True).data)
|
||||
return Response(
|
||||
self.serializer_class(item.get_descendants().all(), many=True).data
|
||||
)
|
||||
|
||||
@detail_route()
|
||||
@action(detail=True, authentication_classes=[LanAuthentication])
|
||||
def siblings(self, request, pk):
|
||||
item = self.get_object()
|
||||
return Response(self.serializer_class(item.get_siblings().all(), many=True).data)
|
||||
return Response(
|
||||
self.serializer_class(item.get_siblings().all(), many=True).data
|
||||
)
|
||||
|
|
|
@ -4,4 +4,4 @@ from django.apps import AppConfig
|
|||
|
||||
|
||||
class StorageConfig(AppConfig):
|
||||
name = 'storage'
|
||||
name = "storage"
|
||||
|
|
69
storage/authentication.py
Normal file
69
storage/authentication.py
Normal file
|
@ -0,0 +1,69 @@
|
|||
import ipaddress
|
||||
from rest_framework import exceptions
|
||||
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from spejstore.settings import (
|
||||
LAN_ALLOWED_ADDRESS_SPACE,
|
||||
)
|
||||
|
||||
|
||||
headers_to_check_for_ip = [
|
||||
"HTTP_X_FORWARDED_FOR",
|
||||
"X_FORWARDED_FOR",
|
||||
"HTTP_CLIENT_IP",
|
||||
"HTTP_X_REAL_IP",
|
||||
"HTTP_X_FORWARDED",
|
||||
"HTTP_X_CLUSTER_CLIENT_IP",
|
||||
"HTTP_FORWARDED_FOR",
|
||||
"HTTP_FORWARDED",
|
||||
"HTTP_VIA",
|
||||
]
|
||||
|
||||
|
||||
def get_request_meta(request, key):
|
||||
value = request.META.get(key, "")
|
||||
if value == "":
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def get_ip_from_request(request):
|
||||
for header in headers_to_check_for_ip:
|
||||
ip = get_request_meta(request, header)
|
||||
if not ip:
|
||||
ip = get_request_meta(request, header.replace("_", "-"))
|
||||
if ip:
|
||||
return ip
|
||||
return None
|
||||
|
||||
|
||||
def has_permission(request):
|
||||
# We don't care if address space is undefined
|
||||
if LAN_ALLOWED_ADDRESS_SPACE == '':
|
||||
return (True, '')
|
||||
client_ip = get_ip_from_request(request)
|
||||
if client_ip is None:
|
||||
# This should only happen on localhost env when fiddling with code.
|
||||
# It's technically impossible to get there with proper headers.
|
||||
return (False, "Unauthorized: no ip detected?")
|
||||
in_local_space = ipaddress.IPv4Address(client_ip) in ipaddress.IPv4Network(
|
||||
LAN_ALLOWED_ADDRESS_SPACE
|
||||
)
|
||||
if not in_local_space:
|
||||
return (False, "Unauthorized: " + client_ip + " not in subnet of " + LAN_ALLOWED_ADDRESS_SPACE)
|
||||
return (True, '')
|
||||
|
||||
class LanAuthentication(SessionAuthentication):
|
||||
def authenticate(self, request):
|
||||
is_session_authorized = super().authenticate(request)
|
||||
if is_session_authorized:
|
||||
return is_session_authorized
|
||||
is_authorized, error_message = has_permission(request)
|
||||
if is_authorized:
|
||||
user = getattr(request._request, "user", None)
|
||||
return (user, "authorized")
|
||||
else:
|
||||
raise exceptions.AuthenticationFailed(
|
||||
error_message
|
||||
)
|
||||
|
|
@ -3,28 +3,29 @@ from storage.models import Item
|
|||
from io import StringIO
|
||||
import csv
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Imports book library from specified wiki page dump'
|
||||
help = "Imports book library from specified wiki page dump"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('parent')
|
||||
parser.add_argument('file')
|
||||
parser.add_argument("parent")
|
||||
parser.add_argument("file")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
with open(options['file']) as fd:
|
||||
with open(options["file"]) as fd:
|
||||
sio = StringIO(fd.read())
|
||||
reader = csv.reader(sio, delimiter='|')
|
||||
parent = Item.objects.get(pk=options['parent'])
|
||||
reader = csv.reader(sio, delimiter="|")
|
||||
parent = Item.objects.get(pk=options["parent"])
|
||||
for line in reader:
|
||||
line = list(map(str.strip, line))
|
||||
item = Item(parent=parent)
|
||||
item.name = line[2]
|
||||
item.props['author'] = line[1]
|
||||
item.props['owner'] = line[3]
|
||||
item.props['can_borrow'] = line[4]
|
||||
item.props['borrowed_by'] = line[5]
|
||||
item.props["author"] = line[1]
|
||||
item.props["owner"] = line[3]
|
||||
item.props["can_borrow"] = line[4]
|
||||
item.props["borrowed_by"] = line[5]
|
||||
item.save()
|
||||
|
||||
self.stdout.write(self.style.NOTICE('Book added: %r') % item)
|
||||
self.stdout.write(self.style.NOTICE("Book added: %r") % item)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS('Successfully imported data'))
|
||||
self.stdout.write(self.style.SUCCESS("Successfully imported data"))
|
||||
|
|
32
storage/middleware.py
Normal file
32
storage/middleware.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
from storage.authentication import has_permission
|
||||
from django.http import HttpResponseRedirect
|
||||
from spejstore.settings import STATIC_URL, MEDIA_URL, LOGIN_URL
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
def is_authorized_or_in_lan_middleware(get_response):
|
||||
# One-time configuration and initialization.
|
||||
login_paths_to_ignore = [
|
||||
"/login",
|
||||
LOGIN_URL[:-1],
|
||||
STATIC_URL[:-1],
|
||||
MEDIA_URL[:-1],
|
||||
"/admin/static",
|
||||
"/complete",
|
||||
"/favicon.ico",
|
||||
"/api/1",
|
||||
]
|
||||
|
||||
def middleware(request):
|
||||
if request.user.is_authenticated:
|
||||
return get_response(request)
|
||||
is_within_lan, error_message = has_permission(request)
|
||||
if is_within_lan:
|
||||
return get_response(request)
|
||||
else:
|
||||
for login_path in login_paths_to_ignore:
|
||||
if request.path.startswith(login_path):
|
||||
return get_response(request)
|
||||
else:
|
||||
raise PermissionDenied()
|
||||
|
||||
return middleware
|
|
@ -3,24 +3,34 @@
|
|||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django_hstore.fields
|
||||
from django.contrib.postgres.fields import HStoreField
|
||||
from django.contrib.postgres.operations import HStoreExtension
|
||||
from django.contrib.postgres.operations import TrigramExtension
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
HStoreExtension(),
|
||||
TrigramExtension(),
|
||||
migrations.CreateModel(
|
||||
name='Item',
|
||||
name="Item",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.TextField()),
|
||||
('description', models.TextField()),
|
||||
('props', django_hstore.fields.DictionaryField()),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.TextField()),
|
||||
("description", models.TextField()),
|
||||
("props", HStoreField()),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
|
@ -5,13 +5,25 @@ from __future__ import unicode_literals
|
|||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_hstore.fields
|
||||
from django.contrib.postgres.fields import HStoreField
|
||||
from django.contrib.postgres.operations import HStoreExtension
|
||||
from django.contrib.postgres.operations import TrigramExtension
|
||||
|
||||
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [('storage', '0001_initial'), ('storage', '0002_auto_20160929_2125'), ('storage', '0003_auto_20160929_2134'), ('storage', '0004_auto_20160929_2143'), ('storage', '0005_auto_20160929_2151'), ('storage', '0006_auto_20160929_2153'), ('storage', '0007_auto_20160929_2153'), ('storage', '0008_item_state')]
|
||||
replaces = [
|
||||
("storage", "0001_initial"),
|
||||
("storage", "0002_auto_20160929_2125"),
|
||||
("storage", "0003_auto_20160929_2134"),
|
||||
("storage", "0004_auto_20160929_2143"),
|
||||
("storage", "0005_auto_20160929_2151"),
|
||||
("storage", "0006_auto_20160929_2153"),
|
||||
("storage", "0007_auto_20160929_2153"),
|
||||
("storage", "0008_item_state"),
|
||||
]
|
||||
|
||||
initial = True
|
||||
|
||||
|
@ -20,63 +32,117 @@ class Migration(migrations.Migration):
|
|||
]
|
||||
|
||||
operations = [
|
||||
HStoreExtension(),
|
||||
TrigramExtension(),
|
||||
migrations.CreateModel(
|
||||
name='Item',
|
||||
name="Item",
|
||||
fields=[
|
||||
('name', models.TextField()),
|
||||
('description', models.TextField(blank=True)),
|
||||
('props', django_hstore.fields.DictionaryField()),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
("name", models.TextField()),
|
||||
("description", models.TextField(blank=True)),
|
||||
("props", HStoreField()),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ItemImage',
|
||||
name="ItemImage",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('image', models.ImageField(upload_to='')),
|
||||
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='storage.Item')),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("image", models.ImageField(upload_to="")),
|
||||
(
|
||||
"item",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="images",
|
||||
to="storage.Item",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Category',
|
||||
name="Category",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=127)),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=127)),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='categories',
|
||||
field=models.ManyToManyField(to='storage.Category'),
|
||||
model_name="item",
|
||||
name="categories",
|
||||
field=models.ManyToManyField(to="storage.Category"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_items', to=settings.AUTH_USER_MODEL),
|
||||
model_name="item",
|
||||
name="owner",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="owned_items",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='taken_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='taken_items', to=settings.AUTH_USER_MODEL),
|
||||
model_name="item",
|
||||
name="taken_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="taken_items",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='taken_on',
|
||||
model_name="item",
|
||||
name="taken_on",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='taken_until',
|
||||
model_name="item",
|
||||
name="taken_until",
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='description',
|
||||
model_name="item",
|
||||
name="description",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='state',
|
||||
field=models.CharField(choices=[('present', 'Present'), ('taken', 'Taken'), ('broken', 'Broken'), ('missing', 'Missing')], default='present', max_length=31),
|
||||
model_name="item",
|
||||
name="state",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("present", "Present"),
|
||||
("taken", "Taken"),
|
||||
("broken", "Broken"),
|
||||
("missing", "Missing"),
|
||||
],
|
||||
default="present",
|
||||
max_length=31,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -9,22 +9,26 @@ from tree.operations import CreateTreeTrigger
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('storage', '0001_squashed_0008_item_state'),
|
||||
('tree', '0001_initial'),
|
||||
("storage", "0001_squashed_0008_item_state"),
|
||||
("tree", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='parent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='storage.Item'),
|
||||
model_name="item",
|
||||
name="parent",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="storage.Item",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='item',
|
||||
name='path',
|
||||
model_name="item",
|
||||
name="path",
|
||||
field=tree.fields.PathField(),
|
||||
),
|
||||
CreateTreeTrigger('storage.Item'),
|
||||
CreateTreeTrigger("storage.Item"),
|
||||
]
|
||||
|
|
|
@ -4,40 +4,46 @@ from __future__ import unicode_literals
|
|||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_hstore.fields
|
||||
from django.contrib.postgres.fields import HStoreField
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('storage', '0002_auto_20170215_0115'),
|
||||
("storage", "0002_auto_20170215_0115"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Label',
|
||||
name="Label",
|
||||
fields=[
|
||||
('id', models.CharField(max_length=64, primary_key=True, serialize=False)),
|
||||
('revision', models.IntegerField()),
|
||||
(
|
||||
"id",
|
||||
models.CharField(max_length=64, primary_key=True, serialize=False),
|
||||
),
|
||||
("revision", models.IntegerField()),
|
||||
],
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='item',
|
||||
options={'ordering': ('path',)},
|
||||
name="item",
|
||||
options={"ordering": ("path",)},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='categories',
|
||||
field=models.ManyToManyField(blank=True, to='storage.Category'),
|
||||
model_name="item",
|
||||
name="categories",
|
||||
field=models.ManyToManyField(blank=True, to="storage.Category"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='props',
|
||||
field=django_hstore.fields.DictionaryField(blank=True),
|
||||
model_name="item",
|
||||
name="props",
|
||||
field=HStoreField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='label',
|
||||
name='item',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='labels', to='storage.Item'),
|
||||
model_name="label",
|
||||
name="item",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="labels",
|
||||
to="storage.Item",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -7,25 +7,30 @@ import django.utils.timezone
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('storage', '0003_auto_20170424_2002'),
|
||||
("storage", "0003_auto_20170424_2002"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='label',
|
||||
name='revision',
|
||||
model_name="label",
|
||||
name="revision",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='label',
|
||||
name='created',
|
||||
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||
model_name="label",
|
||||
name="created",
|
||||
field=models.DateTimeField(
|
||||
auto_now_add=True, default=django.utils.timezone.now
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='label',
|
||||
name='style',
|
||||
field=models.CharField(choices=[('basic_99012_v1', 'Basic Dymo 89x36mm label')], default='basic_99012_v1', max_length=32),
|
||||
model_name="label",
|
||||
name="style",
|
||||
field=models.CharField(
|
||||
choices=[("basic_99012_v1", "Basic Dymo 89x36mm label")],
|
||||
default="basic_99012_v1",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -4,14 +4,14 @@ from __future__ import unicode_literals
|
|||
from django.db import migrations, models
|
||||
from tree.operations import CreateTreeTrigger, DeleteTreeTrigger, RebuildPaths
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('storage', '0004_auto_20170528_1945'),
|
||||
("storage", "0004_auto_20170528_1945"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
DeleteTreeTrigger('storage.Item'),
|
||||
CreateTreeTrigger('storage.Item', order_by=('name',)),
|
||||
RebuildPaths('storage.Item')
|
||||
DeleteTreeTrigger("storage.Item"),
|
||||
CreateTreeTrigger("storage.Item"),
|
||||
RebuildPaths("storage.Item"),
|
||||
]
|
||||
|
|
|
@ -6,15 +6,14 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('storage', '0005'),
|
||||
("storage", "0005"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='category',
|
||||
name='icon_id',
|
||||
model_name="category",
|
||||
name="icon_id",
|
||||
field=models.CharField(blank=True, max_length=64, null=True),
|
||||
),
|
||||
]
|
||||
|
|
22
storage/migrations/0007_auto_20230710_1721.py
Normal file
22
storage/migrations/0007_auto_20230710_1721.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
# Generated by Django 3.2.20 on 2023-07-10 17:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('storage', '0006_category_icon_id'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='category',
|
||||
options={'ordering': ['name'], 'verbose_name_plural': 'categories'},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='state',
|
||||
field=models.CharField(choices=[('present', 'Present'), ('taken', 'Taken'), ('broken', 'Broken'), ('missing', 'Missing'), ('depleted', 'Depleted')], default='present', max_length=31),
|
||||
),
|
||||
]
|
18
storage/migrations/0008_force_extensions_via_django.py
Normal file
18
storage/migrations/0008_force_extensions_via_django.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.20 on 2023-07-10 22:40
|
||||
|
||||
from django.db import migrations
|
||||
from django.contrib.postgres.operations import HStoreExtension
|
||||
from django.contrib.postgres.operations import TrigramExtension
|
||||
|
||||
|
||||
# This migration is necessary for current production purposes.
|
||||
# Technically is a no-op if extensions are turned on already.
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("storage", "0007_auto_20230710_1721"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
HStoreExtension(),
|
||||
TrigramExtension(),
|
||||
]
|
29
storage/migrations/0009_migrate_tree_fields.py
Normal file
29
storage/migrations/0009_migrate_tree_fields.py
Normal file
|
@ -0,0 +1,29 @@
|
|||
# Generated by Django 3.2.20 on 2023-07-11 12:27
|
||||
|
||||
from django.db import migrations
|
||||
from django.contrib.postgres.operations import HStoreExtension
|
||||
from django.contrib.postgres.operations import TrigramExtension
|
||||
from tree.operations import (
|
||||
DeleteTreeTrigger,
|
||||
CreateTreeTrigger,
|
||||
RebuildPaths,
|
||||
)
|
||||
from tree.fields import PathField
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("storage", "0008_force_extensions_via_django"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
DeleteTreeTrigger("storage.Item"),
|
||||
migrations.RemoveField("Item", "path"),
|
||||
migrations.AddField(
|
||||
model_name="item",
|
||||
name="path",
|
||||
field=PathField(db_index=True, order_by=["name"], size=None),
|
||||
),
|
||||
CreateTreeTrigger("item"),
|
||||
RebuildPaths("item"),
|
||||
]
|
22
storage/migrations/0010_alter_item_path.py
Normal file
22
storage/migrations/0010_alter_item_path.py
Normal file
|
@ -0,0 +1,22 @@
|
|||
# Generated by Django 3.2.20 on 2023-07-11 19:35
|
||||
|
||||
from django.db import migrations
|
||||
import tree.fields
|
||||
from tree.operations import (
|
||||
RebuildPaths,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("storage", "0009_migrate_tree_fields"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="item",
|
||||
name="path",
|
||||
field=tree.fields.PathField(),
|
||||
),
|
||||
RebuildPaths("item"),
|
||||
]
|
42
storage/migrations/0011_auto_20230720_1240.py
Normal file
42
storage/migrations/0011_auto_20230720_1240.py
Normal file
|
@ -0,0 +1,42 @@
|
|||
# Generated by Django 3.2.20 on 2023-07-20 12:40
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import storage.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('storage', '0010_alter_item_path'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='StaffProxyModel',
|
||||
fields=[
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'User',
|
||||
'verbose_name_plural': 'Users',
|
||||
'proxy': True,
|
||||
'indexes': [],
|
||||
'constraints': [],
|
||||
},
|
||||
bases=('auth.user',),
|
||||
managers=[
|
||||
('objects', storage.models.StaffManager()),
|
||||
],
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_items', to='storage.staffproxymodel'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='taken_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='taken_items', to='storage.staffproxymodel'),
|
||||
),
|
||||
]
|
17
storage/migrations/0012_item_wiki_link.py
Normal file
17
storage/migrations/0012_item_wiki_link.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 3.2.20 on 2023-12-03 18:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("storage", "0011_auto_20230720_1240"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="item",
|
||||
name="wiki_link",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
19
storage/migrations/0013_alter_itemimage_image.py
Normal file
19
storage/migrations/0013_alter_itemimage_image.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
# Generated by Django 5.0.1 on 2024-01-14 20:48
|
||||
|
||||
import storage.models
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('storage', '0012_item_wiki_link'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='itemimage',
|
||||
name='image',
|
||||
field=storage.models.ImageFieldWithUuid(upload_to=''),
|
||||
),
|
||||
]
|
|
@ -1,4 +1,5 @@
|
|||
from __future__ import unicode_literals
|
||||
import os
|
||||
|
||||
import uuid
|
||||
import re
|
||||
|
@ -6,22 +7,29 @@ import re
|
|||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django_hstore import hstore
|
||||
from tree.fields import PathField
|
||||
from tree.models import TreeModelMixin
|
||||
from django.contrib.postgres.fields import HStoreField
|
||||
from django.contrib.auth.models import UserManager
|
||||
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
STATES = (
|
||||
('present', 'Present'),
|
||||
('taken', 'Taken'),
|
||||
('broken', 'Broken'),
|
||||
('missing', 'Missing'),
|
||||
('depleted', 'Depleted'),
|
||||
("present", "Present"),
|
||||
("taken", "Taken"),
|
||||
("broken", "Broken"),
|
||||
("missing", "Missing"),
|
||||
("depleted", "Depleted"),
|
||||
)
|
||||
|
||||
|
||||
def api_print(id):
|
||||
resp = requests.post("{}/api/1/print/{}".format(settings.LABEL_API, id))
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
class Category(models.Model):
|
||||
name = models.CharField(max_length=127)
|
||||
|
||||
|
@ -31,7 +39,8 @@ class Category(models.Model):
|
|||
return self.name
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
ordering = ["name"]
|
||||
verbose_name_plural = "categories"
|
||||
|
||||
|
||||
# TODO label versioning
|
||||
|
@ -41,38 +50,68 @@ class Category(models.Model):
|
|||
# also qrcody w stylu //s/ID (żeby się resolvowało w sieci lokalnej)
|
||||
# Also ID zawierające część name
|
||||
|
||||
|
||||
class StaffManager(UserManager):
|
||||
pass
|
||||
|
||||
|
||||
class StaffProxyModel(User):
|
||||
objects = StaffManager()
|
||||
|
||||
class Meta:
|
||||
proxy = True
|
||||
verbose_name = "User"
|
||||
verbose_name_plural = "Users"
|
||||
|
||||
|
||||
class Item(models.Model, TreeModelMixin):
|
||||
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
|
||||
parent = models.ForeignKey('self', null=True, blank=True)
|
||||
parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.CASCADE)
|
||||
path = PathField()
|
||||
|
||||
name = models.TextField()
|
||||
|
||||
wiki_link = models.TextField(null=True, blank=True)
|
||||
description = models.TextField(blank=True, null=True)
|
||||
state = models.CharField(max_length=31, choices=STATES, default=STATES[0][0])
|
||||
categories = models.ManyToManyField(Category, blank=True)
|
||||
owner = models.ForeignKey(User, null=True, blank=True, related_name='owned_items')
|
||||
owner = models.ForeignKey(
|
||||
StaffProxyModel,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="owned_items",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
|
||||
taken_by = models.ForeignKey(User, null=True, blank=True, related_name='taken_items')
|
||||
taken_by = models.ForeignKey(
|
||||
StaffProxyModel,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="taken_items",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
taken_on = models.DateTimeField(blank=True, null=True)
|
||||
taken_until = models.DateTimeField(blank=True, null=True)
|
||||
|
||||
props = hstore.DictionaryField(blank=True)
|
||||
props = HStoreField(blank=True)
|
||||
|
||||
objects = hstore.HStoreManager()
|
||||
def short_id(self):
|
||||
# let's just hope we never have 4 294 967 296 things :)
|
||||
return str(self.pk)[:8] # collisions? what collisions?
|
||||
|
||||
def __str__(self):
|
||||
return '- ' * (self.get_level() or 0) + self.name
|
||||
return "- " * (self.get_level() or 0) + self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
from django.urls import reverse
|
||||
return reverse('item-display', kwargs={'pk': str(self.pk)})
|
||||
|
||||
return reverse("item-display", kwargs={"pk": str(self.pk)})
|
||||
|
||||
def get_or_create_label(self, **kwargs):
|
||||
defaults = {
|
||||
'id': re.sub('[^A-Z0-9]', '', self.name.upper())[:16],
|
||||
}
|
||||
"id": re.sub("[^A-Z0-9]", "", self.name.upper())[:16],
|
||||
}
|
||||
|
||||
defaults.update(kwargs)
|
||||
|
||||
|
@ -84,30 +123,41 @@ class Item(models.Model, TreeModelMixin):
|
|||
def primary_category(self):
|
||||
return next((c for c in self.categories.all() if c.icon_id), None)
|
||||
|
||||
def print(self):
|
||||
api_print(self.short_id())
|
||||
|
||||
class Meta:
|
||||
ordering = ('path',)
|
||||
ordering = ("path",)
|
||||
|
||||
|
||||
class ImageFieldWithUuid(models.ImageField):
|
||||
def generate_filename(self, instance, filename):
|
||||
ext = filename.split(".")[-1]
|
||||
filename = "%s.%s" % (uuid.uuid4(), ext)
|
||||
return super().generate_filename(instance, filename)
|
||||
|
||||
|
||||
class ItemImage(models.Model):
|
||||
item = models.ForeignKey(Item, related_name='images')
|
||||
image = models.ImageField()
|
||||
item = models.ForeignKey(Item, related_name="images", on_delete=models.CASCADE)
|
||||
image = ImageFieldWithUuid()
|
||||
|
||||
def __str__(self):
|
||||
return '{}'.format(self.image.name)
|
||||
return "{}".format(self.image.name)
|
||||
|
||||
|
||||
# Deprecated, left in db due to legacy reasons
|
||||
class Label(models.Model):
|
||||
id = models.CharField(max_length=64, primary_key=True)
|
||||
item = models.ForeignKey(Item, related_name='labels')
|
||||
style = models.CharField(max_length=32, choices=(
|
||||
('basic_99012_v1', 'Basic Dymo 89x36mm label'),
|
||||
), default='basic_99012_v1')
|
||||
item = models.ForeignKey(Item, related_name="labels", on_delete=models.CASCADE)
|
||||
style = models.CharField(
|
||||
max_length=32,
|
||||
choices=(("basic_99012_v1", "Basic Dymo 89x36mm label"),),
|
||||
default="basic_99012_v1",
|
||||
)
|
||||
created = models.DateTimeField(auto_now_add=True, blank=True)
|
||||
|
||||
def __str__(self):
|
||||
return '{}'.format(self.id)
|
||||
return "{}".format(self.id)
|
||||
|
||||
def print(self):
|
||||
resp = requests.post(
|
||||
'{}/api/1/print/{}'.format(settings.LABEL_API, self.id))
|
||||
resp.raise_for_status()
|
||||
api_print(self.id)
|
||||
|
|
|
@ -1,16 +1,40 @@
|
|||
from storage.models import Item, Label
|
||||
from django.contrib.auth.models import User
|
||||
from storage.models import Item, Label, Category
|
||||
from rest_framework import serializers
|
||||
from rest_framework_hstore.serializers import HStoreSerializer
|
||||
|
||||
|
||||
class ItemSerializer(HStoreSerializer):
|
||||
class ItemSerializer(serializers.ModelSerializer):
|
||||
categories = serializers.SlugRelatedField(
|
||||
queryset=Category.objects, many=True, slug_field="name"
|
||||
)
|
||||
owner = serializers.SlugRelatedField(queryset=User.objects, slug_field="username")
|
||||
taken_by = serializers.SlugRelatedField(
|
||||
queryset=User.objects, slug_field="username"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
fields = ('uuid', 'name', 'description', 'props', 'state', 'parent')
|
||||
fields = (
|
||||
"uuid",
|
||||
"short_id",
|
||||
"name",
|
||||
"description",
|
||||
"props",
|
||||
"state",
|
||||
"parent",
|
||||
"labels",
|
||||
"owner",
|
||||
"taken_by",
|
||||
"taken_on",
|
||||
"taken_until",
|
||||
"categories",
|
||||
)
|
||||
|
||||
|
||||
class LabelSerializer(serializers.ModelSerializer):
|
||||
item = ItemSerializer(required=False)
|
||||
item_id = serializers.PrimaryKeyRelatedField(queryset=Item.objects, source='item')
|
||||
item_id = serializers.PrimaryKeyRelatedField(queryset=Item.objects, source="item")
|
||||
|
||||
class Meta:
|
||||
model = Label
|
||||
fields = ('id', 'item', 'item_id', 'style')
|
||||
fields = ("id", "item", "item_id", "style")
|
||||
|
|
|
@ -14,22 +14,33 @@
|
|||
|
||||
{% block content %}{{ block.super }}
|
||||
<script>
|
||||
$(function() {
|
||||
function fmt (state) {
|
||||
if (!state.id) {
|
||||
return state.text;
|
||||
}
|
||||
var result = $('<div><div><small></small></div><b></b></div>');
|
||||
result.find('small').text(state.path.join(' → ')).css({
|
||||
'opacity': 0.6,
|
||||
'letter-spacing': -0.5
|
||||
})
|
||||
result.find('b').text(state.text)
|
||||
return result;
|
||||
};
|
||||
$('.django-select2[name=parent]').djangoSelect2({
|
||||
templateResult: fmt,
|
||||
django.jQuery(function () {
|
||||
function fmt(state) {
|
||||
if (!state.id) {
|
||||
return state.text;
|
||||
}
|
||||
var result = django.jQuery(
|
||||
"<div><div><small></small></div><b></b></div>"
|
||||
);
|
||||
result.find("small").text(state.path.join(" → ")).css({
|
||||
opacity: 0.6,
|
||||
"letter-spacing": -0.5,
|
||||
});
|
||||
result.find("b").text(state.text);
|
||||
return result;
|
||||
}
|
||||
django.jQuery(".django-select2[name=parent]").select2({
|
||||
templateResult: fmt,
|
||||
});
|
||||
});
|
||||
/**
|
||||
* Extremely ugly hack to make sure autocomplete loads for the props
|
||||
* There's like 4 select2 versions and jQuery versions.
|
||||
* I give up trying to make it work in a better way.
|
||||
* This is good enough.
|
||||
*/
|
||||
setTimeout(function () {
|
||||
django.jQuery(".hstore-toggle-txtarea").click().click();
|
||||
}, 100);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,16 +1,5 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-8 col-md-offset-2">
|
||||
<form action="/search">
|
||||
<div class="input-group input-group-lg">
|
||||
<input type="text" class="form-control" name="q" placeholder="search term">
|
||||
<span class="input-group-btn">
|
||||
<button class="btn btn-primary" type="submit"><i class="glyphicon glyphicon-search"></i></button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% include "widgets/bigsearch.html" %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -2,27 +2,64 @@
|
|||
|
||||
{% block content %}
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="/item/">Everything</a></li>
|
||||
<li><a href="/item/">All</a></li>
|
||||
{% for ancestor in ancestors %}
|
||||
<li><a href="{{ ancestor.get_absolute_url }}">{{ ancestor.name }}</a></li>
|
||||
{% endfor %}
|
||||
<li class="active">{{ item.name }}</li>
|
||||
</ol>
|
||||
<h2>
|
||||
<small class="pull-right"><a href="{% url 'admin:storage_item_change' item.pk %}"><span class="glyphicon glyphicon-pencil"></span></a></small>
|
||||
|
||||
{% include "widgets/categoryicon.html" with category=item.primary_category %}
|
||||
{{ item.name }} <small>{{ item.pk }}</small>
|
||||
{% if item.wiki_link %}
|
||||
<a href="{{item.wiki_link}}" target="_blank">{{ item.name }}</a>
|
||||
{% else %}
|
||||
{{ item.name }}
|
||||
{% endif %}
|
||||
</h2>
|
||||
|
||||
{% if item.wiki_link %}
|
||||
<span>Click <a href="{{item.wiki_link}}" target="_blank">HERE</a> or title for wiki</span>
|
||||
{% endif %}
|
||||
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<h3>Details</h3>
|
||||
|
||||
<iframe name="printframe" style="display: none"></iframe>
|
||||
|
||||
<div class="btn-group" role="group" style="margin-bottom: 10px">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-default btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="glyphicon glyphicon-print"></i> Print labels <span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><div style="padding: 5px 10px">Put one label in front, one on the back!</div></li>
|
||||
<li role="separator" class="divider"></li>
|
||||
<li style="padding: 5px 10px">
|
||||
<form action="/api/1/items/{{ item.short_id }}/print/?quantity=2" method="POST" target="printframe" style="display:inline-block">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default btn-lg">Print 2 labels (recommended)</button>
|
||||
</form>
|
||||
</li>
|
||||
<li style="padding: 5px 10px">
|
||||
<form action="/api/1/items/{{ item.short_id }}/print/" method="POST" target="printframe" style="display:inline-block">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default btn-lg">Print 1 label</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<a href="{% url 'admin:storage_item_change' item.pk %}" class="btn btn-default">Edit</a>
|
||||
</div>
|
||||
|
||||
<table class="table table-hover table-striped">
|
||||
{% if item.owner %}
|
||||
<tr><td>owner</td><td>{{ item.owner }}</td></tr>
|
||||
{% endif %}
|
||||
|
||||
{% if item.state != "present" %}
|
||||
<tr><td>state</td><td>{{ item.state }}</td></tr>
|
||||
{% endif %}
|
||||
{% if item.taken_by %}
|
||||
<tr><td>taken by</td><td>{{ item.taken_by }}</td></tr>
|
||||
{% endif %}
|
||||
|
@ -34,38 +71,35 @@
|
|||
{% if item.taken_until %}
|
||||
<tr><td>taken until</td><td>{{ item.taken_until }}</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
<h3>Properties</h3>
|
||||
<table class="table table-hover table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>key</th>
|
||||
<th>value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{% for k, v in item.props.items %}
|
||||
<tr><td>{{ k }}</td><td>{{ v|urlize }}</td></tr>
|
||||
{% empty %}
|
||||
<tr><td colspan=2 class="placeholder">No properties</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<!-- no one cares, except for dev? -->
|
||||
<!-- <tr><td>pk</td><td><small>{{ item.pk }}</small></td></tr> -->
|
||||
<!-- <tr><td>short_id</td><td><small>{{ item.short_id }}</small></td></tr> -->
|
||||
|
||||
{% if categories %}
|
||||
<h3>Categories</h3>
|
||||
<table class="table table-hover table-striped">
|
||||
{% for category in categories %}
|
||||
<tr>
|
||||
<td style="padding: 0px 8px; width: 2rem;">
|
||||
{% include "widgets/categoryicon.html" with category=category %}
|
||||
<td>
|
||||
category
|
||||
</td>
|
||||
<td>
|
||||
{{ category.name }}
|
||||
{% include "widgets/categoryicon.html" with category=category %}
|
||||
<span style="padding-top: 1rem; padding-left: 10px">{{ category.name }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
{% for k, v in item.props.items %}
|
||||
<tr><td>{{ k }}</td><td>{{ v|urlize }}</td></tr>
|
||||
{% endfor %}
|
||||
|
||||
{% if labels %}
|
||||
<tr><td>legacy labels</td><td>
|
||||
{% for label in labels %}
|
||||
<code>{{ label.id }}</code>,
|
||||
{% endfor %}
|
||||
</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if images %}
|
||||
<h3>Photos</h3>
|
||||
|
@ -80,21 +114,13 @@
|
|||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if labels %}
|
||||
<h3>Labels</h3>
|
||||
<iframe name="printframe" style="display: none"></iframe>
|
||||
{% for label in labels %}
|
||||
<form action="/api/1/labels/{{ label.id }}/print/" method="POST" target="printframe" onsubmit="return confirm('Want to print this label?')">
|
||||
{% csrf_token %}
|
||||
<div class="label-item">
|
||||
<button class="btn btn-default btn-sm"><i class="glyphicon glyphicon-print"></i></button>
|
||||
<code>{{ label.id }}</code>
|
||||
</div>
|
||||
</form>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if history %}
|
||||
<div class="row" style="margin-top: 20px">
|
||||
<div class="col-md-6">
|
||||
<a class="btn btn-default btn-sm" data-toggle="collapse" data-target="#hs-item-history" >Toggle changes</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hs-item-history" class="collapse">
|
||||
<h3>Changes</h3>
|
||||
<table class="table table-striped table-hover">
|
||||
{% for entry in history %}
|
||||
|
@ -106,6 +132,7 @@
|
|||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
@ -115,7 +142,7 @@
|
|||
{{ item.description|markdown:"code-color" }}
|
||||
{% endif %}
|
||||
|
||||
<h3>Children</h3>
|
||||
<h3>What's inside?</h3>
|
||||
{% include "widgets/itemlist.html" with list=children|dictsort:"name" item=item %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
{% include "widgets/bigsearch.html" %}
|
||||
<br />
|
||||
{% include "widgets/itemlist.html" with list=results show_paths=True show_placeholder=True %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -1,13 +1,23 @@
|
|||
from django.conf.urls import include, url
|
||||
from django.urls import re_path, include
|
||||
from storage.views import (
|
||||
index, search, item_display, label_lookup, ItemSelectView, PropSelectView
|
||||
index,
|
||||
search,
|
||||
item_display,
|
||||
label_lookup,
|
||||
apitoken,
|
||||
ItemSelectView,
|
||||
PropSelectView,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^$', index),
|
||||
url(r'^search$', search),
|
||||
url(r'^item/(?P<pk>.*)$', item_display, name='item-display'),
|
||||
url(r'^autocomplete.json$', ItemSelectView.as_view(), name='item-complete'),
|
||||
url(r'^autocomplete_prop.json$', PropSelectView.as_view(), name='prop-complete'),
|
||||
url(r'^(?P<pk>[^/]*)$', label_lookup, name='label-lookup'),
|
||||
re_path(r"^$", index),
|
||||
re_path(r"^search$", search),
|
||||
re_path(r"^apitoken$", apitoken),
|
||||
re_path(r"^item/(?P<pk>.*)$", item_display, name="item-display"),
|
||||
re_path(r"^autocomplete.json$", ItemSelectView.as_view(), name="item-complete"),
|
||||
re_path(
|
||||
r"^autocomplete_prop.json$", PropSelectView.as_view(), name="prop-complete"
|
||||
),
|
||||
re_path(r"^(?P<pk>[^/]*)$", label_lookup, name="label-lookup"),
|
||||
re_path("", include("social_django.urls", namespace="social")),
|
||||
]
|
||||
|
|
178
storage/views.py
178
storage/views.py
|
@ -2,7 +2,7 @@ import shlex
|
|||
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.contrib.postgres.search import SearchVector, TrigramSimilarity
|
||||
from django.http import Http404, JsonResponse
|
||||
from django.http import Http404, JsonResponse, HttpResponse
|
||||
from django.contrib.admin.models import LogEntry
|
||||
from django_select2.views import AutoResponseView
|
||||
from django.db import connection
|
||||
|
@ -10,6 +10,9 @@ from django.db.models import Q
|
|||
|
||||
from storage.models import Item, Label
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
|
||||
def apply_smart_search(query, objects):
|
||||
general_term = []
|
||||
|
@ -17,23 +20,23 @@ def apply_smart_search(query, objects):
|
|||
filters = {}
|
||||
|
||||
for prop in shlex.split(query):
|
||||
if ':' not in prop:
|
||||
if ":" not in prop:
|
||||
general_term.append(prop)
|
||||
else:
|
||||
key, value = prop.split(':', 1)
|
||||
if key in ['owner', 'taken_by']:
|
||||
filters[key + '__username'] = value
|
||||
key, value = prop.split(":", 1)
|
||||
if key in ["owner", "taken_by"]:
|
||||
filters[key + "__username"] = value
|
||||
elif hasattr(Item, key):
|
||||
filters[key + '__search'] = value
|
||||
elif key == 'ancestor':
|
||||
filters[key + "__search"] = value
|
||||
elif key == "ancestor":
|
||||
objects = Item.objects.get(pk=value).get_children()
|
||||
elif key == 'prop' or value:
|
||||
if key == 'prop':
|
||||
key, _, value = value.partition(':')
|
||||
elif key == "prop" or value:
|
||||
if key == "prop":
|
||||
key, _, value = value.partition(":")
|
||||
if not value:
|
||||
filters['props__isnull'] = {key: False}
|
||||
filters["props__isnull"] = {key: False}
|
||||
else:
|
||||
filters['props__contains'] = {key: value}
|
||||
filters["props__contains"] = {key: value}
|
||||
else:
|
||||
# "Whatever:"
|
||||
general_term.append(prop)
|
||||
|
@ -42,97 +45,136 @@ def apply_smart_search(query, objects):
|
|||
|
||||
if not general_term:
|
||||
return objects
|
||||
general_term = ' '.join(general_term)
|
||||
general_term = " ".join(general_term)
|
||||
|
||||
objects = objects.annotate(
|
||||
search=SearchVector('name', 'description', 'props', config='simple'),
|
||||
similarity=TrigramSimilarity('name', general_term)
|
||||
).filter(
|
||||
Q(similarity__gte=0.15) | Q(search__contains=general_term)
|
||||
).order_by('-similarity')
|
||||
objects = (
|
||||
objects.annotate(
|
||||
search=SearchVector("name", "description", "props", config="simple"),
|
||||
similarity=TrigramSimilarity("name", general_term),
|
||||
)
|
||||
.filter(Q(similarity__gte=0.15) | Q(search__contains=general_term))
|
||||
.order_by("-similarity")
|
||||
)
|
||||
return objects
|
||||
|
||||
|
||||
def index(request):
|
||||
return render(request, 'index.html')
|
||||
# get_roots was removed, so we're doing it this way now.
|
||||
return render(
|
||||
request, "results.html", {"results": Item.objects.filter(**{"path__level": 1})}
|
||||
)
|
||||
|
||||
|
||||
def search(request):
|
||||
query = request.GET.get('q', '')
|
||||
query = request.GET.get("q", "")
|
||||
|
||||
results = apply_smart_search(query, Item.objects).all()
|
||||
|
||||
if results and (len(results) == 1 or getattr(results[0], 'similarity', 0) == 1):
|
||||
if results and (len(results) == 1 or getattr(results[0], "similarity", 0) == 1):
|
||||
return redirect(results[0])
|
||||
|
||||
return render(request, 'results.html', {
|
||||
'query': query,
|
||||
'results': results,
|
||||
})
|
||||
return render(
|
||||
request,
|
||||
"results.html",
|
||||
{
|
||||
"query": query,
|
||||
"results": results,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def item_display(request, pk):
|
||||
if not pk:
|
||||
return render(request, 'results.html', {
|
||||
'results': Item.get_roots()
|
||||
})
|
||||
return index(request)
|
||||
item = get_object_or_404(Item, pk=pk)
|
||||
|
||||
return render(request, 'item.html', {
|
||||
'item': item,
|
||||
'categories': item.categories.all(),
|
||||
'props': sorted(item.props.items()),
|
||||
'images': item.images.all(),
|
||||
'labels': item.labels.all(),
|
||||
'history': LogEntry.objects.filter(object_id=item.pk),
|
||||
'ancestors': item.get_ancestors(),
|
||||
'children': item.get_children().prefetch_related('categories'),
|
||||
})
|
||||
labels = item.labels.all()
|
||||
has_one_label = len(labels) == 1
|
||||
|
||||
return render(
|
||||
request,
|
||||
"item.html",
|
||||
{
|
||||
"title": item.name,
|
||||
"item": item,
|
||||
"categories": item.categories.all(),
|
||||
"props": sorted(item.props.items()),
|
||||
"images": item.images.all(),
|
||||
"labels": labels,
|
||||
"has_one_label": has_one_label,
|
||||
"history": LogEntry.objects.filter(object_id=item.pk),
|
||||
"ancestors": item.get_ancestors(),
|
||||
"children": item.get_children().prefetch_related("categories"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def label_lookup(request, pk):
|
||||
label = get_object_or_404(Label, pk=pk)
|
||||
return redirect(label.item)
|
||||
try:
|
||||
label = Label.objects.get(pk=pk)
|
||||
return redirect(label.item)
|
||||
except Label.DoesNotExist:
|
||||
try:
|
||||
# look up by short id
|
||||
item = Item.objects.get(uuid__startswith=pk)
|
||||
return redirect(item)
|
||||
except Item.DoesNotExist:
|
||||
raise Http404("Very sad to say, I could not find this thing")
|
||||
|
||||
|
||||
def apitoken(request):
|
||||
print(Token)
|
||||
token, created = Token.objects.get_or_create(user=request.user)
|
||||
return HttpResponse(token.key, content_type="text/plain")
|
||||
|
||||
|
||||
class ItemSelectView(AutoResponseView):
|
||||
def get(self, request, *args, **kwargs):
|
||||
self.widget = self.get_widget_or_404()
|
||||
self.term = kwargs.get('term', request.GET.get('term', ''))
|
||||
self.term = kwargs.get("term", request.GET.get("term", ""))
|
||||
self.object_list = apply_smart_search(self.term, Item.objects)
|
||||
context = self.get_context_data()
|
||||
return JsonResponse({
|
||||
'results': [
|
||||
{
|
||||
'text': obj.name,
|
||||
'path': [o.name for o in obj.get_ancestors()],
|
||||
'id': obj.pk,
|
||||
}
|
||||
for obj in context['object_list']
|
||||
return JsonResponse(
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"text": obj.name,
|
||||
"path": [o.name for o in obj.get_ancestors()],
|
||||
"id": obj.pk,
|
||||
}
|
||||
for obj in context["object_list"]
|
||||
],
|
||||
'more': context['page_obj'].has_next()
|
||||
})
|
||||
"more": context["page_obj"].has_next(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PropSelectView(AutoResponseView):
|
||||
def get(self, request, *args, **kwargs):
|
||||
# self.widget = self.get_widget_or_404()
|
||||
self.term = kwargs.get('term', request.GET.get('term', ''))
|
||||
self.term = kwargs.get("term", request.GET.get("term", ""))
|
||||
# context = self.get_context_data()
|
||||
with connection.cursor() as c:
|
||||
c.execute("""
|
||||
select e from (
|
||||
select skeys(props) as e, count(skeys(props)) as e_count
|
||||
from storage_item group by e order by e_count desc) as xD
|
||||
where e like %s limit 10;""", ['%' + self.term + '%'])
|
||||
props = c.fetchall()
|
||||
|
||||
return JsonResponse({
|
||||
'results': [
|
||||
{
|
||||
'text': p,
|
||||
'id': p,
|
||||
}
|
||||
for p in props
|
||||
c.execute(
|
||||
"""
|
||||
SELECT key, count(*) FROM
|
||||
(SELECT (each(props)).key FROM storage_item) AS stat
|
||||
WHERE key like %s
|
||||
GROUP BY key
|
||||
ORDER BY count DESC, key
|
||||
limit 10;
|
||||
""",
|
||||
["%" + self.term + "%"],
|
||||
)
|
||||
props = [e[0] for e in c.fetchall()]
|
||||
return JsonResponse(
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"text": p,
|
||||
"id": p,
|
||||
}
|
||||
for p in props
|
||||
],
|
||||
})
|
||||
}
|
||||
)
|
||||
|
|
|
@ -1,50 +1,60 @@
|
|||
from pkg_resources import parse_version
|
||||
|
||||
from django_select2.forms import ModelSelect2Widget, HeavySelect2Widget
|
||||
from django_hstore.forms import DictionaryFieldWidget
|
||||
from django_select2.forms import HeavySelect2Widget
|
||||
|
||||
|
||||
from django import get_version
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
from django import forms
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.template import Context
|
||||
from django.template.loader import get_template
|
||||
from django.contrib.admin.widgets import AdminTextareaWidget
|
||||
|
||||
|
||||
class ItemSelectWidget(ModelSelect2Widget):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs['data_view'] = 'item-complete'
|
||||
super(ItemSelectWidget, self).__init__(*args, **kwargs)
|
||||
|
||||
def label_from_instance(self, obj):
|
||||
return obj.name
|
||||
from django_admin_hstore_widget.forms import HStoreFormWidget
|
||||
from django.contrib.postgres.forms import forms
|
||||
from django.templatetags.static import static
|
||||
|
||||
|
||||
class PropsSelectWidget(DictionaryFieldWidget):
|
||||
class PropsSelectWidget(HStoreFormWidget):
|
||||
@property
|
||||
def media(self):
|
||||
internal_js = [
|
||||
"vendor/jquery/jquery.js",
|
||||
"django_admin_hstore_widget/underscore-min.js",
|
||||
"django_admin_hstore_widget/django_admin_hstore_widget.js",
|
||||
]
|
||||
|
||||
js = [static("admin/js/%s" % path) for path in internal_js]
|
||||
|
||||
return forms.Media(js=js)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def render(self, name, value, attrs=None):
|
||||
def render(self, name, value, attrs=None, renderer=None):
|
||||
if attrs is None:
|
||||
attrs = {}
|
||||
# it's called "original" because it will be replaced by a copy
|
||||
attrs['class'] = 'hstore-original-textarea'
|
||||
w = HeavySelect2Widget(data_view='prop-complete', attrs={'data-tags': 'true', 'class': 'hs-key'})
|
||||
attrs["class"] = "hstore-original-textarea"
|
||||
w = HeavySelect2Widget(
|
||||
data_view="prop-complete", attrs={"data-tags": "true", "class": "hs-key"}
|
||||
)
|
||||
|
||||
# get default HTML from AdminTextareaWidget
|
||||
html = AdminTextareaWidget.render(self, name, value, attrs)
|
||||
html = AdminTextareaWidget.render(self, name, value, attrs, renderer)
|
||||
# prepare template context
|
||||
template_context = Context({
|
||||
'field_name': name,
|
||||
'STATIC_URL': settings.STATIC_URL,
|
||||
'use_svg': parse_version(get_version()) >= parse_version('1.9'), # use svg icons if django >= 1.9
|
||||
'ajax_url': reverse('prop-complete'),
|
||||
'w': w.build_attrs()
|
||||
})
|
||||
template_context = {
|
||||
"field_name": name,
|
||||
"STATIC_URL": settings.STATIC_URL,
|
||||
"use_svg": parse_version(get_version())
|
||||
>= parse_version("1.9"), # use svg icons if django >= 1.9
|
||||
"ajax_url": reverse("prop-complete"),
|
||||
"w": w.build_attrs(base_attrs=w.attrs),
|
||||
}
|
||||
# get template object
|
||||
template = get_template('hstore_%s_widget.html' % self.admin_style)
|
||||
template = get_template("hstore_default_widget.html")
|
||||
# render additional html
|
||||
additional_html = template.render(template_context)
|
||||
|
||||
|
|
5
templates/400.html
Normal file
5
templates/400.html
Normal file
|
@ -0,0 +1,5 @@
|
|||
{% extends "error_template.html" %}
|
||||
{% block content %}
|
||||
<div>400</div>
|
||||
<div class="txt">Invalid request<span class="blink">_</span></div>
|
||||
{% endblock %}
|
7
templates/403.html
Normal file
7
templates/403.html
Normal file
|
@ -0,0 +1,7 @@
|
|||
{% extends "error_template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div>403</div>
|
||||
<div class="txt">Forbidden<span class="blink">_</span></div>
|
||||
<div><a href="/admin/login">Login</a></div>
|
||||
{% endblock %}
|
8
templates/404.html
Normal file
8
templates/404.html
Normal file
|
@ -0,0 +1,8 @@
|
|||
{% extends "error_template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div>404</div>
|
||||
<div class="txt">Not found<span class="blink">_</span></div>
|
||||
{% endblock %}
|
||||
|
||||
|
8
templates/500.html
Normal file
8
templates/500.html
Normal file
|
@ -0,0 +1,8 @@
|
|||
{% extends "error_template.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div>500</div>
|
||||
<div class="txt">Something went wrong...<span class="blink">_</span></div>
|
||||
{% endblock %}
|
||||
|
||||
|
|
@ -2,52 +2,82 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Hackerspace Storage System</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta charset="utf-8" />
|
||||
<title>{% if title %}{{ title }} - {% endif %}Hackerspace Storage</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<link rel="stylesheet" href="{% static 'css/bootstrap.css' %}" media="screen">
|
||||
<link rel="stylesheet" href="{% static 'css/custom.css' %}" media="screen">
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{% static 'css/bootstrap.css' %}"
|
||||
media="screen"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{% static 'css/theme.min.css' %}"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{% static 'css/custom.css' %}"
|
||||
media="screen"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}
|
||||
<nav class="navbar navbar-default">
|
||||
<div class="container">
|
||||
<div class="navbar-header">
|
||||
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#bs-example-navbar-collapse-1">
|
||||
<span class="sr-only">Toggle navigation</span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="/">spejstore</a>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="navbar-header">
|
||||
<button
|
||||
type="button"
|
||||
class="navbar-toggle collapsed"
|
||||
data-toggle="collapse"
|
||||
data-target="#bs-example-navbar-collapse-1"
|
||||
>
|
||||
<span class="sr-only">Toggle navigation</span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
<span class="icon-bar"></span>
|
||||
</button>
|
||||
<a class="navbar-brand" href="/">Hackerspace Storage</a>
|
||||
</div>
|
||||
|
||||
<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
|
||||
<ul class="nav navbar-nav">
|
||||
<li><a href="/item/">Items</a></li>
|
||||
<li><a href="/admin/">Add</a></li>
|
||||
<li><a href="https://wiki.hackerspace.pl/projects:spejstore">Wiki</a></li>
|
||||
</ul>
|
||||
<form class="navbar-form navbar-right" role="search" action="/search">
|
||||
<div class="form-group">
|
||||
<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
|
||||
<ul class="nav navbar-nav">
|
||||
<li><a href="/admin/storage/item/add/">Add thing</a></li>
|
||||
<li>
|
||||
<a href="https://wiki.hackerspace.pl/members:services:inventory"
|
||||
>How to use</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<form class="navbar-form navbar-right" role="search" action="/search">
|
||||
<div class="form-group">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" placeholder="Search" name="q" autofocus>
|
||||
<span class="input-group-btn">
|
||||
<button class="btn btn-primary" type="submit"><i class="glyphicon glyphicon-search"></i></button>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search Hackerspace"
|
||||
name="q"
|
||||
autofocus
|
||||
/>
|
||||
<span class="input-group-btn">
|
||||
<button class="btn btn-primary" type="submit">
|
||||
<i class="glyphicon glyphicon-search"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container">
|
||||
<!--<h1 class="page-header">Warsaw Hackerspace <small class="hidden-sm
|
||||
<!--<h1 class="page-header">Warsaw Hackerspace <small class="hidden-sm
|
||||
hidden-xs">Enjoy your stay</small></h1>-->
|
||||
{% block content %}
|
||||
{% endblock %}
|
||||
{% block content %} {% endblock %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
<script src="{% static 'js/jquery.min.js' %}"></script>
|
||||
<script src="{% static 'js/bootstrap.min.js' %}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
81
templates/error_template.html
Normal file
81
templates/error_template.html
Normal file
|
@ -0,0 +1,81 @@
|
|||
{% load static %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: "Press Start 2P";
|
||||
src: url("{% static 'fonts/pressstart2p-regular-webfont.woff2' %}")
|
||||
format("woff2"),
|
||||
url("{% static 'fonts/pressstart2p-regular-webfont.woff' %}") format("woff");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color: #54fe55;
|
||||
--glowSize: 10px;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: "Press Start 2P", cursive;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#app {
|
||||
padding: 1rem;
|
||||
background: black;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--color);
|
||||
text-shadow: 0px 0px var(--glowSize);
|
||||
font-size: 6rem;
|
||||
flex-direction: column;
|
||||
|
||||
.txt {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
49% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.blink {
|
||||
animation-name: blink;
|
||||
animation-duration: 1s;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
</style>
|
||||
<html lang="en">
|
||||
<div id="app">
|
||||
{% block content %}
|
||||
<div>MISSING_ERROR_CODE</div>
|
||||
<div class="txt">
|
||||
MISSING ERROR DESCRIPTION <span class="blink">_</span>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
</html>
|
|
@ -66,5 +66,7 @@
|
|||
</script>
|
||||
|
||||
<script>
|
||||
django.jQuery(function() { initDjangoHStoreWidget('{{ field_name }}') });
|
||||
window.addEventListener("load", function (event) {
|
||||
django.jQuery(function() { initDjangoHStoreWidget('{{ field_name }}') });
|
||||
});
|
||||
</script>
|
||||
|
|
12
templates/widgets/bigsearch.html
Normal file
12
templates/widgets/bigsearch.html
Normal file
|
@ -0,0 +1,12 @@
|
|||
<div class="row">
|
||||
<div class="col-md-8 col-md-offset-2">
|
||||
<form action="/search">
|
||||
<div class="input-group input-group-lg">
|
||||
<input type="text" class="form-control" name="q" placeholder="Whatcha wanna find today?">
|
||||
<span class="input-group-btn">
|
||||
<button class="btn btn-primary" type="submit"><i class="glyphicon glyphicon-search"></i></button>
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
|
@ -1,3 +1,4 @@
|
|||
{% if category and category.icon_id %}
|
||||
<div class="containericon" title="{{ category.name }}"><img src="/static/icons/{{ category.icon_id }}.svg" /></div>
|
||||
<div class="containericon" title="{{ category.name }}"><img src="/static/icons/{{ category.icon_id }}.svg" />
|
||||
</div>
|
||||
{% endif %}
|
||||
|
|
|
@ -1,4 +1,14 @@
|
|||
<table class="table table-striped table-hover">
|
||||
|
||||
{% if item %}
|
||||
<tr><td colspan=2 class="placeholder">
|
||||
<a href="/admin/storage/item/add?parent={{ item.uuid }}">
|
||||
<span class="glyphicon glyphicon-plus"></span>
|
||||
Add child
|
||||
</a>
|
||||
</td></tr>
|
||||
{% endif %}
|
||||
|
||||
{% for item in list %}
|
||||
<tr>
|
||||
<td style="padding: 0px 8px; width: 2rem;">
|
||||
|
@ -29,10 +39,10 @@
|
|||
|
||||
{% if item %}
|
||||
<tr><td colspan=2 class="placeholder">
|
||||
<a href="/admin/storage/item/add?parent={{ item.uuid }}">
|
||||
<span class="glyphicon glyphicon-plus"></span>
|
||||
Add child
|
||||
</a>
|
||||
</td></tr>
|
||||
{% endif %}
|
||||
<a href="/admin/storage/item/add?parent={{ item.uuid }}">
|
||||
<span class="glyphicon glyphicon-plus"></span>
|
||||
Add child
|
||||
</a>
|
||||
</td></tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
|
Loading…
Reference in a new issue