Compare commits

...

137 commits

Author SHA1 Message Date
Patryk Jakuszew
1bbd28933f Add S3 storage configuration 2024-01-14 23:56:26 +01:00
650fc34115
fix: add missing migration (just for clarity) 2024-01-14 21:48:55 +01:00
ab631aeb90
feat: make media uploaded files generate uuid name 2024-01-14 21:47:19 +01:00
81417f58be
fix: trusted origins settings.py 2024-01-14 21:46:54 +01:00
6c81441f00
fix: make wiki link optional in admin panel 2024-01-14 15:31:13 +01:00
d36a84e25b
fix: very dumb accidental commit of ALLOWED_HOSTS 2024-01-14 15:21:35 +01:00
67ac858323
fix: missing comma because python 2024-01-14 15:20:04 +01:00
fe496e85f8
fix: add csrf config 2024-01-14 15:15:14 +01:00
ea5e223fcc
fix: properly use threads and workers 2024-01-14 13:53:39 +01:00
2323263ccb
fix: wrong static template tag usage 2024-01-14 13:52:59 +01:00
f5740e1543
fix: make wiki link nullable 2024-01-14 00:24:44 +01:00
c9be16b76f
fix: missing files config 2024-01-14 00:21:30 +01:00
35a48c3eee
fix: statics 2024-01-14 00:21:23 +01:00
6af75328f4
fix: .env file is not explicitely needed 2024-01-13 23:34:05 +01:00
c3c80d650c
fix: weird docker compose hacks 2024-01-13 23:30:50 +01:00
142e38ad95
fix: bump django version 2024-01-12 18:57:02 +01:00
f94e7b3207
fix: use proper uri rewrites 2024-01-12 18:56:27 +01:00
ee2c93908a
fix: use static template 2024-01-12 18:56:08 +01:00
d1b9beca6d
feat: Add wiki_link field 2023-12-03 19:55:26 +01:00
1fcbbd9dd3
vscode: Add .devcontainer support 2023-12-03 19:54:29 +01:00
2892923389
Fix wrong psql healthcheck user 2023-11-06 20:50:00 +01:00
d71885d264
Revert "hscloud: Add single address authentication"
This reverts commit f1143dc4f1.
2023-09-19 18:21:11 +02:00
f1143dc4f1
hscloud: Add single address authentication 2023-09-19 18:03:57 +02:00
38c7245a3f
auth: cleanup 2023-09-09 19:26:36 +02:00
156df0a8a5
auth: add login button 2023-09-09 19:25:44 +02:00
f8b3dd6bf7
auth: require necessary authentication or in lan
middleware was not written properly, now requires authentication or
being in lan for readaccess, otherwise redirecting to login page
2023-09-09 17:00:19 +02:00
3c3ba16811
middleware: Fix ordering again 2023-09-09 16:31:49 +02:00
f1335f0565
auth: fix auth paths 2023-09-09 15:43:23 +02:00
daea8dda22
404: fix wrong status code in html 2023-08-25 21:52:13 +02:00
f92635f5f3
Revert "auth: do not automatically staff new members"
This reverts commit 0fa9762bea.
2023-08-25 21:51:09 +02:00
8ce869393e
cache: cache statics 2023-08-25 21:51:04 +02:00
0fa9762bea
auth: do not automatically staff new members 2023-08-25 21:36:19 +02:00
15bf813b04
django: force auth for all requests 2023-08-25 21:06:01 +02:00
401fcc088d
errors: add cute error pages 2023-08-25 21:04:43 +02:00
820f04cc01
settings: properly order middleware and and gzip 2023-08-25 21:03:55 +02:00
c5a9fba034
Add cache 2023-08-25 20:55:17 +02:00
7f19fe7c7a improve readme for docker-compose newbs 2023-08-21 21:38:13 +02:00
cc3fddfd22 settings: fix oauth_redirect_is_http 2023-08-21 21:36:44 +02:00
20303a14a7 update readme 2023-08-18 16:39:14 +02:00
cc6f00da08 docker-compose: clean up dev-override 2023-08-18 16:38:41 +02:00
23244bdf24 fix colors in light mode 2023-08-18 15:31:53 +02:00
ee9e9becf5 settings: make redirect_is_https configurable separate from prod 2023-08-18 15:28:57 +02:00
6cd7ec529b docker-compose: change postgres healthcheck interval to 1s 2023-08-18 15:27:56 +02:00
d615da3a0f add error logging in production 2023-08-18 15:27:33 +02:00
4fc47030db docker-compose: build locally 2023-08-18 13:00:53 +02:00
ce3f07de5f add whitenoise lib to serve statics in production w/o nginx 2023-08-18 13:00:32 +02:00
e3229ffd7e Docker: fix broken build_static 2023-08-17 14:21:10 +02:00
d4a305c362 Docker: update dev overrides so that app starts 2023-08-17 14:20:39 +02:00
255e0f0d08 Docker: wait for postgres until starting web container 2023-08-17 14:20:22 +02:00
af9cb2db32 Docker: update postgres to 15.4 2023-08-17 14:20:00 +02:00
9200bdbb3b
authentication: always require if defined env
if SPEJSTORE_REQUIRE_AUTH is 'true' then always require auth
otherwise make it read-only on unauthorized access
2023-08-13 20:10:00 +02:00
d942c99cb9
Fix parent autocompletes 2023-08-04 18:02:08 +02:00
e1a22100c4
Remove unused select2 refs 2023-08-04 17:46:08 +02:00
4fc3629dcd
Fix users searchfield not working 2023-08-04 17:26:25 +02:00
Dariusz Niemczyk
875e385f68
WIP new docs 2023-07-23 17:31:46 +02:00
Dariusz Niemczyk
8048fccede
Make paths publically available due to beyondspace 2023-07-23 17:31:37 +02:00
3a286a5bc6
Add autocomplete for users in Admin and fix perms 2023-07-20 14:49:44 +02:00
2a70d2cb31
Add configurable auth-by-lan values 2023-07-17 23:12:02 +02:00
ae219a2533
Properly collectstatic on docker build 2023-07-17 23:09:11 +02:00
d026e41ac5
Update docker-compose setup 2023-07-17 23:08:24 +02:00
a6705a956f
Remove legacy labels from django admin 2023-07-17 23:04:30 +02:00
5012a10298
Hack: admin props combobox works(?) 2023-07-17 23:04:30 +02:00
027bcfcde5
Hack: admin props combobox works(?) 2023-07-17 22:44:44 +02:00
154e1079da
Make HSLan always authenticated for GET 2023-07-17 21:35:45 +02:00
30c3c3eb7a
Deduplicate print function 2023-07-17 20:18:18 +02:00
c15f1bb840
Make gunicorn properly report errots 2023-07-17 15:47:29 +02:00
f7688262e4
Fix API views 2023-07-17 15:46:00 +02:00
d2e25c0801
Add overrides to docker-ignore 2023-07-14 22:38:48 +02:00
e2e82b1a2e
Replace docker-compose to gunicorn run 2023-07-14 16:29:47 +02:00
8210381027
Fix docker port to 8000 2023-07-14 16:29:39 +02:00
b09016ea3b
Add .env to .gitignore 2023-07-14 16:28:07 +02:00
af85d191ad
Force authentication for API usage 2023-07-13 22:37:53 +02:00
a20e14a8d3
Add LOGIN_URL to redirect properly 2023-07-13 21:01:41 +02:00
b6ce1516d2
Migrate deprecated field in settings 2023-07-13 21:01:17 +02:00
a8f7530263
Force authorization for all routes 2023-07-13 21:01:00 +02:00
b74c1b3c8f
Add inventory.hackerspace.pl to ALLOWED_HOSTS 2023-07-11 23:36:35 +02:00
bfe9d27d71
Make dockerable for hscloud 2023-07-11 23:29:54 +02:00
ad73094b67
Fix import for oauth 2023-07-11 23:29:39 +02:00
a0c6d87adb
Add missing migration (django unhappy) 2023-07-11 23:29:28 +02:00
Dariusz Niemczyk
5ed4128151
Add terrible dark mode for inventory 2023-07-11 18:34:18 +02:00
Dariusz Niemczyk
d473901f8c
Minify bootstrap.css 2023-07-11 18:34:01 +02:00
Dariusz Niemczyk
878f246b08
Admin dark-mode properly done 2023-07-11 18:12:12 +02:00
Dariusz Niemczyk
af1be4aca7
Add __pycache__ to gitignore 2023-07-11 17:54:36 +02:00
Dariusz Niemczyk
72e668622d
Remove unnecessary create_extension 2023-07-11 17:53:17 +02:00
Dariusz Niemczyk
150c405468
Fix settings 2023-07-11 17:52:38 +02:00
Dariusz Niemczyk
efcd932481
re-implement get_roots in view 2023-07-11 17:52:38 +02:00
Dariusz Niemczyk
837734a655
Fix admin not searching properly and make it great 2023-07-11 17:52:38 +02:00
Dariusz Niemczyk
45ad9bf88c
Migrate old django to the newest version
Django 1.x is no longer supported, and the app needed migration to 4.x
A lot of libraries has been unsupported or removed, so there's a few
of unrelated changes, but necessary for the migration process to work.
2023-07-11 17:52:38 +02:00
Dariusz Niemczyk
659f04ce9c
Blackify the code (autoformat) 2023-07-11 15:34:35 +02:00
Dariusz Niemczyk
3fdf788168
Add .venv and .vscode to gitignore 2023-07-11 15:34:26 +02:00
3b5439ef74 auth: fix broken deletion permission on non-superusers 2020-05-28 21:53:58 +02:00
3de626d5c5 labels via api + remove redundant nav item 2020-05-28 21:23:51 +02:00
1f5a053a19 Less confusing "Print labels" button + explanation 2020-05-28 21:23:51 +02:00
5160052dfd You can now print Items, w/o explicitly defining Labels 2020-05-28 21:23:51 +02:00
2e0c031fab /items/:id now fetches via short_id or label.id (now considered legacy) 2020-05-28 21:23:51 +02:00
a82668ca01 improve /api/1/items/:item_id 2020-05-28 21:23:51 +02:00
0c883bed2f simplify item info 2020-05-28 21:23:51 +02:00
7ac99dd44b introduce item.short_id + lookup by short id 2020-05-28 21:23:51 +02:00
d313274615 better index 2020-05-28 21:23:51 +02:00
1587f26181 attempt at fixing missing label editor 2020-05-14 23:16:53 +02:00
4c4d889aac add build_static to ignorefiles 2020-05-12 21:14:18 +02:00
83e2afcf06 Check in some random production changes 2020-05-12 20:58:10 +02:00
5e598d80b4 Pin container versions, simplify initial setup 2020-05-12 20:56:44 +02:00
af9ed46861 fix print button 2020-05-12 20:27:08 +02:00
4acccb7d94 Hide item history by default 2020-05-12 20:27:08 +02:00
786f2a3675 title nice 2020-05-12 20:27:08 +02:00
7f12474e76 some admin ux improvements 2020-05-12 20:27:08 +02:00
60601b270e Search everywhere 2020-05-12 20:27:08 +02:00
5a70e382b8 UX improvements 2020-05-12 20:27:08 +02:00
16ff203aaa Nicer printing UI, allow to print 2 easily, hide useless/empty shit 2020-05-12 20:27:08 +02:00
93ebd810aa Allow label printing api to print multiple copies of a label 2020-05-12 20:27:08 +02:00
35da79f0b2 better onboarding readme 2020-05-12 20:27:08 +02:00
323da8e0df improve readme for the newbs 2020-05-12 20:27:08 +02:00
a427c34dff allow localhost in dev 2020-05-12 20:27:08 +02:00
388f67eb80 fix wiki link 2020-05-12 20:27:08 +02:00
0aee39af76 postgres-hstore: fix database initialization with custom username 2019-05-12 15:58:49 +02:00
d39cfd9b42 Dockerfile: add missing CMD 2019-05-12 15:58:19 +02:00
43c1c94844 settings: load all relevant options from environment 2019-05-12 15:58:01 +02:00
fedf3dca80 Add apitoken endpoint 2019-02-02 13:21:15 +01:00
3f58a080f4 settings: Just a bunch of default hostnames 2019-01-10 20:03:03 +01:00
6b91ed4d25 Fix account associations by email 2019-01-10 13:33:45 +01:00
d42
638ea2a2aa unnecessary code 2018-10-10 21:38:44 +02:00
d42
1f8dadcf18 confidential donut read 2018-10-10 21:34:45 +02:00
d42
95694890ea associate_by_email 2018-10-10 21:33:16 +02:00
d42
0df044b267 ldap is no more ['] 2018-10-10 21:28:57 +02:00
d42
5fa30b7e3a unregister social admin views 2018-10-10 21:18:25 +02:00
d42
9577c2d27d yolo admin login redirect 2018-10-10 21:18:06 +02:00
d42
45c4772f78 staff ItemAdmin 2018-10-10 20:37:56 +02:00
d42
2690136711 undefecate prop query :^) 2018-10-10 20:37:30 +02:00
d42
2ff57e3e0a initial oauth commit 2018-10-10 19:59:54 +02:00
d42
65871f578d categories 2018-09-26 22:39:51 +02:00
d42
1723535394 rESPONSIVE ADMIN 2018-09-26 22:27:24 +02:00
d42
1039211bba update props on paste 2018-09-26 22:21:08 +02:00
d42
87db1c4747 vendor me daddy 2018-09-26 22:20:36 +02:00
d42
85a85ed4c6 bump django 2018-09-26 22:20:16 +02:00
d42
812c883964 jquery bootstrapjs for dat sweet sweet burgermenu ;~; 2018-09-26 19:25:57 +02:00
d42
8d26bac6f9 username as name everywhere 2018-04-28 21:25:08 +02:00
69 changed files with 2006 additions and 7949 deletions

View 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"
}

View 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
View 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
View 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
View file

@ -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__

View file

@ -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"

View file

@ -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
View file

42
auth/backend.py Normal file
View 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
View 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
View file

@ -0,0 +1,5 @@
from django.shortcuts import redirect
def auth_redirect(request):
return redirect("social:begin", "hswaw")

View 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

View file

@ -0,0 +1,8 @@
version: "3"
services:
db:
volumes:
- /var/spejstore-data-new:/var/lib/postgresql/data
web:
build: .

View file

@ -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

View file

@ -1,3 +0,0 @@
FROM postgres:latest
MAINTAINER Piotr Dobrowolski
ADD create_extension.sh /docker-entrypoint-initdb.d/create_extension.sh

View file

@ -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

View file

@ -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
View 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

View file

@ -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", "")

View file

@ -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)
)

View file

@ -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;
}
});

View file

@ -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;
}
});

View file

@ -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

File diff suppressed because one or more lines are too long

View file

@ -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

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

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

File diff suppressed because one or more lines are too long

View file

@ -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)

View file

@ -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
)

View file

@ -4,4 +4,4 @@ from django.apps import AppConfig
class StorageConfig(AppConfig):
name = 'storage'
name = "storage"

69
storage/authentication.py Normal file
View 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
)

View file

@ -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
View 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

View file

@ -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()),
],
),
]

View file

@ -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,
),
),
]

View file

@ -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"),
]

View file

@ -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",
),
),
]

View file

@ -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,
),
),
]

View file

@ -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"),
]

View file

@ -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),
),
]

View 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),
),
]

View 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(),
]

View 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"),
]

View 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"),
]

View 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'),
),
]

View 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),
),
]

View 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=''),
),
]

View file

@ -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)

View file

@ -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")

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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>&nbsp;&nbsp;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>

View file

@ -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 %}

View file

@ -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")),
]

View file

@ -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
],
})
}
)

View file

@ -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
View 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
View 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
View 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
View 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 %}

View file

@ -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>

View 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>

View file

@ -66,5 +66,7 @@
</script>
<script>
django.jQuery(function() { initDjangoHStoreWidget('{{ field_name }}') });
window.addEventListener("load", function (event) {
django.jQuery(function() { initDjangoHStoreWidget('{{ field_name }}') });
});
</script>

View 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>

View file

@ -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 %}

View file

@ -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>