mirror of
https://github.com/daniviga/django-ram.git
synced 2026-02-03 17:40:39 +01:00
Compare commits
163 Commits
v0.10.0
...
8c216c7e56
| Author | SHA1 | Date | |
|---|---|---|---|
|
8c216c7e56
|
|||
|
d1e741ebfd
|
|||
|
650a93676e
|
|||
|
265aed56fe
|
|||
|
167a0593de
|
|||
|
a254786ddc
|
|||
|
8d899e4d9f
|
|||
|
40df9eb376
|
|||
|
226f0b32ba
|
|||
|
3c121a60a4
|
|||
|
ab606859d1
|
|||
|
a16801eb4b
|
|||
|
b8d10a68ca
|
|||
|
e690ded04f
|
|||
|
15a7ffaf4f
|
|||
|
a11f97bcad
|
|||
|
3c854bda1b
|
|||
|
564416b3d5
|
|||
|
967ea5d495
|
|||
|
7656aa8b68
|
|||
| 1be102b9d4 | |||
| 4ec7b8fc18 | |||
| 9a469378df | |||
| ede8741473 | |||
|
49c8d804d6
|
|||
|
2ab2d00585
|
|||
|
c95064ddec
|
|||
|
16bd82de39
|
|||
|
2ae7f2685d
|
|||
|
29f9a213b4
|
|||
|
884661d4e1
|
|||
|
c7cace96f7
|
|||
|
d3c099c05b
|
|||
|
903633b5a7
|
|||
|
ee775d737e
|
|||
|
8087ab5997
|
|||
|
1899747909
|
|||
|
0880bd0817
|
|||
|
74d7df2c8b
|
|||
|
c81508bbd5
|
|||
|
b4f69d8a34
|
|||
| 676418cb67 | |||
|
98d2e7beab
|
|||
|
fb17dc2a7c
|
|||
|
5a71dc36fa
|
|||
|
c539255bf9
|
|||
|
fc527d5cd1
|
|||
|
f45d754c91
|
|||
|
e9c9ede357
|
|||
| 39b0a9378b | |||
| 6b10051bc4 | |||
|
3804c3379b
|
|||
|
1b769da553
|
|||
|
f655900411
|
|||
|
3e69b9ae6e
|
|||
|
66c3c3f51c
|
|||
|
935c439084
|
|||
|
d757388ca8
|
|||
| 536101d2ff | |||
|
955397acd5
|
|||
|
672cadd7e1
|
|||
|
464fe57536
|
|||
|
bd16c7eee7
|
|||
|
cc2e374558
|
|||
|
1c25ac9b14
|
|||
|
de126a735d
|
|||
|
18b5ab8053
|
|||
|
3acc80e2ad
|
|||
|
552ba39970
|
|||
|
222e2075ec
|
|||
|
b5c57dcd94
|
|||
|
b81c63898f
|
|||
|
76b266b1f9
|
|||
|
86657a3b9f
|
|||
|
d0d25424fb
|
|||
|
292b95b8ed
|
|||
|
dea7a594bc
|
|||
|
60195bc99f
|
|||
|
7673f0514a
|
|||
|
40f42a9ee9
|
|||
|
2e06e94fde
|
|||
|
ece8d1ad94
|
|||
|
e9ec126ada
|
|||
|
1222116874
|
|||
|
85741f090c
|
|||
|
88d718fa94
|
|||
|
a2c857a3cd
|
|||
|
647894bca7
|
|||
|
c8cc8c5ed0
|
|||
| e80dc604a7 | |||
|
5088f19b33
|
|||
|
50bfc44978
|
|||
|
453729b05c
|
|||
| 5d89cb96d2 | |||
| 04757d868a | |||
| b897141212 | |||
|
3df8b461a0
|
|||
|
284632892d
|
|||
|
bb58dcf6fa
|
|||
|
c971ff9601
|
|||
| b10e1f3952 | |||
| d16e00d66b | |||
| 1a8f2aace8 | |||
| 0413c1c5ab | |||
|
f914c79786
|
|||
|
456f1b7294
|
|||
|
f19a0995b0
|
|||
|
3dd134f132
|
|||
| ddcf06994d | |||
| c467fb24ca | |||
| db79a02c85 | |||
| d237129c99 | |||
| af54acae86 | |||
| 90211562f9 | |||
|
1e7f72e9ec
|
|||
|
26be22c0bd
|
|||
| f286ec9780 | |||
| ead9fe649b | |||
| 206b9aea57 | |||
| 8557e2b778 | |||
| 6457486445 | |||
| ee5b5f0b3a | |||
| 159bc66b59 | |||
| 0ea9978ffb | |||
| 026ab06354 | |||
| 7eddd1b52b | |||
| 11515d79ef | |||
| f2b817103f | |||
| 2d00436a87 | |||
| 6ff5450124 | |||
| f4af44c41c | |||
| e3ae18a4bd | |||
| 2695358d9b | |||
| 3fbae0417e | |||
| 7a51ab9095 | |||
| dad40b3ee7 | |||
| d55bce6e78 | |||
| cbf6c942b9 | |||
| 64f616d89f | |||
| f8246c31d3 | |||
| 005ea11011 | |||
| 83444266cb | |||
| 1a3b30ace3 | |||
| 21c99f73c3 | |||
| b5b88f7714 | |||
| 119d25ede6 | |||
| 41d9338459 | |||
| 32785f321a | |||
| 5b975355a1 | |||
| 7d8c539e47 | |||
| 9a832bca82 | |||
| 54254bda7d | |||
| 1c07c6a7a9 | |||
| 61b6d7a84e | |||
| d0854a4cff | |||
|
456272b93a
|
|||
| 35905bafdf | |||
| 6a9f37ca05 | |||
| 54a68d9b1f | |||
|
aa02404dfe
|
|||
| e4ad98fa38 | |||
| b37f5420c5 | |||
| 4b74a69f3f |
2
.github/workflows/django.yml
vendored
2
.github/workflows/django.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
max-parallel: 2
|
max-parallel: 2
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ['3.9', '3.10', '3.11', '3.12']
|
python-version: ['3.13', '3.14']
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -10,7 +10,6 @@ __pycache__/
|
|||||||
.Python
|
.Python
|
||||||
build/
|
build/
|
||||||
develop-eggs/
|
develop-eggs/
|
||||||
dist/
|
|
||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
@@ -128,7 +127,13 @@ dmypy.json
|
|||||||
# Pyre type checker
|
# Pyre type checker
|
||||||
.pyre/
|
.pyre/
|
||||||
|
|
||||||
|
# node.js / npm stuff
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
# our own stuff
|
||||||
*.swp
|
*.swp
|
||||||
ram/storage/
|
ram/storage/
|
||||||
!ram/storage/.gitignore
|
!ram/storage/.gitignore
|
||||||
arduino/CommandStation-EX/build/
|
arduino/CommandStation-EX/build/
|
||||||
|
utils
|
||||||
|
|||||||
100
README.md
100
README.md
@@ -23,7 +23,8 @@ security assesment, pentest, ISO certification, etc.
|
|||||||
|
|
||||||
This project probably doesn't match your needs nor expectations. Be aware.
|
This project probably doesn't match your needs nor expectations. Be aware.
|
||||||
|
|
||||||
Your model train may also catch fire while using this software.
|
> [!CAUTION]
|
||||||
|
> Your model train may catch fire while using this software.
|
||||||
|
|
||||||
Check out [my own instance](https://daniele.mynarrowgauge.org).
|
Check out [my own instance](https://daniele.mynarrowgauge.org).
|
||||||
|
|
||||||
@@ -40,23 +41,49 @@ Project is based on the following technologies and components:
|
|||||||
|
|
||||||
It has been developed with:
|
It has been developed with:
|
||||||
|
|
||||||
- [vim](https://www.vim.org/): because it rocks
|
- [neovim](https://neovim.io/): because `vim` rocks, `neovim` rocks more
|
||||||
- [arduino-cli](https://github.com/arduino/arduino-cli/): a mouse? What the heck?
|
- [arduino-cli](https://github.com/arduino/arduino-cli/): a mouse? What the heck?
|
||||||
- [vim-arduino](https://github.com/stevearc/vim-arduino): another IDE? No thanks
|
- [vim-arduino](https://github.com/stevearc/vim-arduino): another IDE? No thanks
|
||||||
- [podman](https://podman.io/): because containers are fancy
|
- [podman](https://podman.io/): because containers are fancy
|
||||||
- [QEMU (avr)](https://qemu-project.gitlab.io/qemu/system/target-avr.html): QEMU can even make toast!
|
- [QEMU (avr)](https://qemu-project.gitlab.io/qemu/system/target-avr.html): QEMU can even make toasts!
|
||||||
|
|
||||||
|
## Future developments
|
||||||
|
|
||||||
|
A bunch of random, probably useless, ideas:
|
||||||
|
|
||||||
|
### A bookshelf
|
||||||
|
|
||||||
|
✅DONE
|
||||||
|
|
||||||
|
Because books matter more than model trains themselves.
|
||||||
|
|
||||||
|
### Live assets KPI collection
|
||||||
|
|
||||||
|
Realtime data usage is collected via a daemon connected over TCP to the EX-CommandStation and recorded for every asset with a DCC address.
|
||||||
|
|
||||||
|
### Asset lifecycle
|
||||||
|
|
||||||
|
Data is collected to compute the asset usage and then the wear level of its components (eg. the engine).
|
||||||
|
|
||||||
|
### Required mainentance forecast
|
||||||
|
|
||||||
|
Eventually data is used to "forecast" any required maintenance, like for example the replacement of carbon brushes, gear and motor oiling.
|
||||||
|
|
||||||
|
### Asset export to JMRI
|
||||||
|
|
||||||
|
Export assets (locomotives) into the JMRI format to be loaded in the JMRI
|
||||||
|
roster.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.9+
|
- Python 3.11+
|
||||||
- A USB port when running Arduino hardware (and adaptors if you have a Mac)
|
- A USB port when running Arduino hardware (and adaptors if you have a Mac)
|
||||||
|
|
||||||
## Web portal installation
|
## Web portal installation
|
||||||
|
|
||||||
### Using containers
|
### Using containers
|
||||||
|
|
||||||
coming soon
|
Do it yourself, otherwise, raise a request :)
|
||||||
|
|
||||||
### Manual installation
|
### Manual installation
|
||||||
|
|
||||||
@@ -83,6 +110,8 @@ $ python manage.py migrate
|
|||||||
$ python manage.py createsuperuser
|
$ python manage.py createsuperuser
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To load some sample metadata, see the [sample_data folder instructions](./sample_data/README.md).
|
||||||
|
|
||||||
Run Django
|
Run Django
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -99,43 +128,52 @@ connected via serial port, to the network, allowing commands to be sent via a
|
|||||||
TCP socket. A response generated by the DCC++ EX board is sent to all connected clients,
|
TCP socket. A response generated by the DCC++ EX board is sent to all connected clients,
|
||||||
providing synchronization between multiple clients (eg. multiple JMRI instances).
|
providing synchronization between multiple clients (eg. multiple JMRI instances).
|
||||||
|
|
||||||
Its use is not needed when running DCC++ EX from a [WiFi](https://dcc-ex.com/get-started/wifi-setup.html) capable board (like when
|
Its use is not needed when running DCC++ EX from a [WiFi](https://dcc-ex.com/get-started/wifi-setup.html) capable board, like when
|
||||||
using an ESP8266 module or a [Mega+WiFi board](https://dcc-ex.com/advanced-setup/supported-microcontrollers/wifi-mega.html)).
|
using an ESP8266 module, a [Mega+WiFi board](https://dcc-ex.com/reference/hardware/microcontrollers/wifi-mega.html), or an
|
||||||
|
[ESP32](https://dcc-ex.com/reference/hardware/microcontrollers/esp32.html) (recommended).
|
||||||
|
|
||||||
### Customize the settings
|
### Manual setup
|
||||||
|
|
||||||
The daemon comes with default settings in `config.ini`.
|
You'll need [namp-ncat](https://nmap.org/ncat/) , and `stty` to setup the serial port.
|
||||||
Settings may need to be customized based on your setup.
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Other variants of `nc` or `ncat` may not work as expected.
|
||||||
|
|
||||||
|
Then you can run the following commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ stty -F /dev/ttyACM0 -echo 115200
|
||||||
|
$ ncat -n -k -l 2560 </dev/ttyACM0 >/dev/ttyACM0
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> You'll might need to change the serial port (`/dev/ttyACM0`) to match your board.
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> Your user will also need access to the device file, so you might need to add it to the `dialout` group.
|
||||||
|
|
||||||
|
|
||||||
### Using containers
|
### Using containers
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ cd daemons
|
$ cd connector
|
||||||
$ podman build -t dcc/net-to-serial .
|
$ podman build -t dcc/connector .
|
||||||
$ podman run --group-add keep-groups --device /dev/ttyACM0 -p 2560:2560 dcc/net-to-serial
|
$ podman run -d --group-add keep-groups --device /dev/ttyACM0:/dev/arduino -p 2560:2560 dcc/connector
|
||||||
```
|
|
||||||
|
|
||||||
### Manual setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ cd daemons
|
|
||||||
$ pip install -r requirements.txt
|
|
||||||
$ python ./net-to-serial.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Test with a simulator
|
### Test with a simulator
|
||||||
|
|
||||||
A [QEMU AVR based simulator](daemons/simulator/README.md) running DCC++ EX is bundled togheter with the `net-to-serial.py`
|
A [QEMU AVR based simulator](daemons/simulator/README.md) running DCC++ EX is bundled togheter with the connector
|
||||||
daemon into a container. To run it:
|
into a container. To run it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ cd daemons/simulator
|
$ cd connector/simulator
|
||||||
$ podman build -t dcc/net-to-serial:sim .
|
$ podman build -t dcc/connector:sim .
|
||||||
$ podman run --init --cpus 0.1 -d -p 2560:2560 dcc/net-to-serial:sim
|
$ podman run --init --cpus 0.1 -d -p 2560:2560 dcc/connector:sim
|
||||||
```
|
```
|
||||||
|
|
||||||
To be continued ...
|
> [!WARNING]
|
||||||
|
> The simulator is intended for light development and testing purposes only and far from being a complete replacement for a real hardware.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
@@ -146,15 +184,12 @@ To be continued ...
|
|||||||

|

|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#### Dark mode
|
#### Dark mode
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Backoffice
|
### Backoffice
|
||||||
|
|
||||||

|

|
||||||
@@ -166,8 +201,3 @@ To be continued ...
|
|||||||
### Rest API
|
### Rest API
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Submodule arduino/CommandStation-EX updated: 3b162996ad...313d2cd3e0
Submodule arduino/WebThrottle-EX updated: c67e4080d0...eeec7d4af6
Submodule arduino/arduino-cli updated: 048415c5e6...08ff7e2b76
Submodule arduino/dcc-ex.github.io updated: 9acc446358...190d3adfa1
Submodule arduino/vim-arduino updated: 111db616db...2ded67cdf0
9
connector/Dockerfile
Normal file
9
connector/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
FROM alpine:edge
|
||||||
|
|
||||||
|
RUN apk add --no-cache coreutils nmap-ncat
|
||||||
|
|
||||||
|
EXPOSE 2560/tcp
|
||||||
|
|
||||||
|
SHELL ["/bin/ash", "-c"]
|
||||||
|
CMD stty -F /dev/arduino -echo 115200 && \
|
||||||
|
ncat -n -k -l 2560 </dev/arduino >/dev/arduino
|
||||||
19
connector/README.md
Normal file
19
connector/README.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Use a container to implement a serial to net bridge
|
||||||
|
|
||||||
|
This uses `ncat` from [nmap](https://nmap.org/ncat/) to bridge a serial port to a network port. The serial port is passed to the Podman command (eg. `/dev/ttyACM0`) and the network port is `2560`.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Other variants of `nc` or `ncat` may not work as expected.
|
||||||
|
|
||||||
|
## Build and run the container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ podman buil -t dcc/bridge .
|
||||||
|
$ podman run -d --group-add keep-groups --device=/dev/ttyACM0:/dev/arduino -p 2560:2560 --name dcc-bridge dcc/bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
It can be tested with `telnet`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ telnet localhost 2560
|
||||||
|
```
|
||||||
BIN
connector/simulator/CommandStation-EX-uno-13488e1.elf
Executable file
BIN
connector/simulator/CommandStation-EX-uno-13488e1.elf
Executable file
Binary file not shown.
8
connector/simulator/Dockerfile
Normal file
8
connector/simulator/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
FROM dcc/bridge
|
||||||
|
|
||||||
|
RUN apk update && apk add --no-cache qemu-system-avr \
|
||||||
|
&& mkdir /io
|
||||||
|
ADD start.sh /usr/local/bin
|
||||||
|
ADD CommandStation-EX*.elf /io
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/start.sh"]
|
||||||
13
connector/simulator/README.md
Normal file
13
connector/simulator/README.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Connector and AVR simulator
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> The simulator is intended for light development and testing purposes only and far from being a complete replacement for a real hardware.
|
||||||
|
|
||||||
|
`qemu-system-avr` tries to use all the CPU cycles (leaving a CPU core stuck at 100%; limit CPU core usage to 10% via `--cpus 0.1`. It can be adjusted on slower machines.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ podman build -t dcc/connector:sim .
|
||||||
|
$ podman run --init --cpus 0.1 -d -p 2560:2560 dcc/connector:sim
|
||||||
|
```
|
||||||
|
|
||||||
|
All traffic will be collected on the container's `stderr` for debugging purposes.
|
||||||
@@ -7,7 +7,5 @@ if [ -c /dev/pts/0 ]; then
|
|||||||
PTY=1
|
PTY=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
sed -i "s/ttyACM0/pts\/${PTY}/" /opt/dcc/config.ini
|
|
||||||
|
|
||||||
qemu-system-avr -machine uno -bios /io/CommandStation-EX*.elf -serial pty -daemonize
|
qemu-system-avr -machine uno -bios /io/CommandStation-EX*.elf -serial pty -daemonize
|
||||||
/opt/dcc/net-to-serial.py
|
ncat -n -k -l 2560 -o /dev/stderr </dev/pts/${PTY} >/dev/pts/${PTY}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
FROM python:3.11-alpine
|
|
||||||
|
|
||||||
RUN mkdir /opt/dcc && pip -q install pyserial
|
|
||||||
ADD net-to-serial.py config.ini /opt/dcc
|
|
||||||
RUN python3 -q -m compileall /opt/dcc/net-to-serial.py
|
|
||||||
|
|
||||||
EXPOSE 2560/tcp
|
|
||||||
|
|
||||||
CMD ["python3", "/opt/dcc/net-to-serial.py"]
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
## DCC++ EX connector
|
|
||||||
|
|
||||||
See [README.md](../README.md)
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
[Daemon]
|
|
||||||
LogLevel = debug
|
|
||||||
ListeningIP = 0.0.0.0
|
|
||||||
ListeningPort = 2560
|
|
||||||
MaxClients = 10
|
|
||||||
|
|
||||||
[Serial]
|
|
||||||
# UNO
|
|
||||||
Port = /dev/ttyACM0
|
|
||||||
# Mega WiFi
|
|
||||||
# Port = /dev/ttyUSB0
|
|
||||||
Baudrate = 115200
|
|
||||||
# Timeout in milliseconds
|
|
||||||
Timeout = 50
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import re
|
|
||||||
import logging
|
|
||||||
import serial
|
|
||||||
import asyncio
|
|
||||||
import configparser
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
class SerialDaemon:
|
|
||||||
connected_clients = set()
|
|
||||||
|
|
||||||
def __init__(self, config):
|
|
||||||
self.ser = serial.Serial(
|
|
||||||
config["Serial"]["Port"],
|
|
||||||
timeout=int(config["Serial"]["Timeout"]) / 1000,
|
|
||||||
)
|
|
||||||
self.ser.baudrate = config["Serial"]["Baudrate"]
|
|
||||||
self.max_clients = int(config["Daemon"]["MaxClients"])
|
|
||||||
|
|
||||||
def __del__(self):
|
|
||||||
try:
|
|
||||||
self.ser.close()
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def __read_serial(self):
|
|
||||||
"""Serial reader wrapper"""
|
|
||||||
response = b""
|
|
||||||
while True:
|
|
||||||
line = self.ser.read_until()
|
|
||||||
if not line.strip(): # empty line
|
|
||||||
break
|
|
||||||
if line.decode().startswith("<*"):
|
|
||||||
logging.debug("Serial debug: {}".format(line))
|
|
||||||
else:
|
|
||||||
response += line
|
|
||||||
logging.debug("Serial read: {}".format(response))
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
def __write_serial(self, data):
|
|
||||||
"""Serial writer wrapper"""
|
|
||||||
self.ser.write(data)
|
|
||||||
|
|
||||||
async def handle_echo(self, reader, writer):
|
|
||||||
"""Process a request from socket and return the response"""
|
|
||||||
logging.info(
|
|
||||||
"Clients already connected: {} (max: {})".format(
|
|
||||||
len(self.connected_clients),
|
|
||||||
self.max_clients,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
addr = writer.get_extra_info("peername")[0]
|
|
||||||
if len(self.connected_clients) < self.max_clients:
|
|
||||||
self.connected_clients.add(writer)
|
|
||||||
while True: # keep connection to client open
|
|
||||||
data = await reader.read(100)
|
|
||||||
if not data: # client has disconnected
|
|
||||||
break
|
|
||||||
logging.info("Received {} from {}".format(data, addr))
|
|
||||||
self.__write_serial(data)
|
|
||||||
response = self.__read_serial()
|
|
||||||
for client in self.connected_clients:
|
|
||||||
client.write(response)
|
|
||||||
await client.drain()
|
|
||||||
logging.info("Sent: {}".format(response))
|
|
||||||
self.connected_clients.remove(writer)
|
|
||||||
else:
|
|
||||||
logging.warning(
|
|
||||||
"TooManyClients: client {} disconnected".format(addr)
|
|
||||||
)
|
|
||||||
|
|
||||||
writer.close()
|
|
||||||
await writer.wait_closed()
|
|
||||||
|
|
||||||
async def return_board(self):
|
|
||||||
"""Return the board signature"""
|
|
||||||
line = ""
|
|
||||||
# drain the serial until we are ready to go
|
|
||||||
self.__write_serial(b"<s>")
|
|
||||||
while "DCC-EX" not in line:
|
|
||||||
line = self.__read_serial().decode()
|
|
||||||
board = re.findall(r"<iDCC-EX.*>", line)[0]
|
|
||||||
return board
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
config = configparser.ConfigParser()
|
|
||||||
config.read(
|
|
||||||
Path(__file__).resolve().parent / "config.ini"
|
|
||||||
) # mimick os.path.join
|
|
||||||
logging.basicConfig(level=config["Daemon"]["LogLevel"].upper())
|
|
||||||
|
|
||||||
sd = SerialDaemon(config)
|
|
||||||
server = await asyncio.start_server(
|
|
||||||
sd.handle_echo,
|
|
||||||
config["Daemon"]["ListeningIP"],
|
|
||||||
config["Daemon"]["ListeningPort"],
|
|
||||||
)
|
|
||||||
addr = server.sockets[0].getsockname()
|
|
||||||
logging.info("Serving on {} port {}".format(addr[0], addr[1]))
|
|
||||||
logging.info(
|
|
||||||
"Proxying to {} (Baudrate: {}, Timeout: {})".format(
|
|
||||||
config["Serial"]["Port"],
|
|
||||||
config["Serial"]["Baudrate"],
|
|
||||||
config["Serial"]["Timeout"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
logging.info("Initializing board")
|
|
||||||
logging.info("Board {} ready".format(await sd.return_board()))
|
|
||||||
|
|
||||||
async with server:
|
|
||||||
await server.serve_forever()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
PySerial
|
|
||||||
Binary file not shown.
@@ -1,7 +0,0 @@
|
|||||||
FROM dcc/net-to-serial
|
|
||||||
|
|
||||||
RUN apk update && apk add qemu-system-avr && mkdir /io
|
|
||||||
ADD start.sh /opt/dcc
|
|
||||||
ADD CommandStation-EX*.elf /io
|
|
||||||
|
|
||||||
ENTRYPOINT ["/opt/dcc/start.sh"]
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# AVR Simulator
|
|
||||||
|
|
||||||
`qemu-system-avr` tries to use all the CPU cicles (leaving a CPU core stuck at 100%; limit CPU core usage to 10% via `--cpus 0.1`. It can be adjusted on slower machines.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ podman build -t dcc/net-to-serial:sim .
|
|
||||||
$ podman run --init --cpus 0.1 -d -p 2560:2560 dcc/net-to-serial:sim
|
|
||||||
```
|
|
||||||
43
docs/nginx/nginx.conf
Normal file
43
docs/nginx/nginx.conf
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
server {
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name myhost;
|
||||||
|
|
||||||
|
# ssl_certificate ...;
|
||||||
|
|
||||||
|
add_header X-Xss-Protection "1; mode=block";
|
||||||
|
add_header Strict-Transport-Security "max-age=15768000";
|
||||||
|
add_header Permissions-Policy "geolocation=(),midi=(),sync-xhr=(),microphone=(),camera=(),magnetometer=(),gyroscope=(),fullscreen=(self),payment=()";
|
||||||
|
add_header Content-Security-Policy "child-src 'none'; object-src 'none'";
|
||||||
|
|
||||||
|
client_max_body_size 250M;
|
||||||
|
error_page 403 404 https://$server_name/404;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_redirect http:// https://;
|
||||||
|
proxy_connect_timeout 1800;
|
||||||
|
proxy_read_timeout 1800;
|
||||||
|
proxy_max_temp_file_size 8192m;
|
||||||
|
}
|
||||||
|
|
||||||
|
# static files
|
||||||
|
location /static {
|
||||||
|
root /myroot/ram/storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
# media files
|
||||||
|
location ~ ^/media/(images|uploads) {
|
||||||
|
root /myroot/ram/storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
# protected filed to be served via X-Accel-Redirect
|
||||||
|
location /private {
|
||||||
|
internal;
|
||||||
|
alias /myroot/ram/storage/media;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
package.json
Normal file
6
package.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"clean-css-cli": "^5.6.3",
|
||||||
|
"terser": "^5.44.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,35 +1,166 @@
|
|||||||
|
import html
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.utils.html import (
|
||||||
|
format_html,
|
||||||
|
format_html_join,
|
||||||
|
strip_tags,
|
||||||
|
mark_safe,
|
||||||
|
)
|
||||||
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
|
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
|
||||||
|
|
||||||
from bookshelf.models import BookProperty, BookImage, Book, Author, Publisher
|
from ram.admin import publish, unpublish
|
||||||
|
from ram.utils import generate_csv
|
||||||
|
from portal.utils import get_site_conf
|
||||||
|
from repository.models import (
|
||||||
|
BookDocument,
|
||||||
|
CatalogDocument,
|
||||||
|
MagazineIssueDocument,
|
||||||
|
)
|
||||||
|
from bookshelf.models import (
|
||||||
|
BaseBookProperty,
|
||||||
|
BaseBookImage,
|
||||||
|
Book,
|
||||||
|
Author,
|
||||||
|
Publisher,
|
||||||
|
Catalog,
|
||||||
|
Magazine,
|
||||||
|
MagazineIssue,
|
||||||
|
TocEntry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BookImageInline(SortableInlineAdminMixin, admin.TabularInline):
|
class BookImageInline(SortableInlineAdminMixin, admin.TabularInline):
|
||||||
model = BookImage
|
model = BaseBookImage
|
||||||
min_num = 0
|
min_num = 0
|
||||||
extra = 0
|
extra = 1
|
||||||
readonly_fields = ("image_thumbnail",)
|
readonly_fields = ("image_thumbnail",)
|
||||||
classes = ["collapse"]
|
classes = ["collapse"]
|
||||||
|
verbose_name = "Image"
|
||||||
|
|
||||||
|
|
||||||
class BookPropertyInline(admin.TabularInline):
|
class BookPropertyInline(admin.TabularInline):
|
||||||
model = BookProperty
|
model = BaseBookProperty
|
||||||
min_num = 0
|
min_num = 0
|
||||||
extra = 0
|
extra = 0
|
||||||
|
autocomplete_fields = ("property",)
|
||||||
|
verbose_name = "Property"
|
||||||
|
verbose_name_plural = "Properties"
|
||||||
|
|
||||||
|
|
||||||
|
class BookDocInline(admin.TabularInline):
|
||||||
|
model = BookDocument
|
||||||
|
min_num = 0
|
||||||
|
extra = 1
|
||||||
|
classes = ["collapse"]
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogDocInline(BookDocInline):
|
||||||
|
model = CatalogDocument
|
||||||
|
|
||||||
|
|
||||||
|
class MagazineIssueDocInline(BookDocInline):
|
||||||
|
model = MagazineIssueDocument
|
||||||
|
|
||||||
|
|
||||||
|
class BookTocInline(admin.TabularInline):
|
||||||
|
model = TocEntry
|
||||||
|
min_num = 0
|
||||||
|
extra = 0
|
||||||
|
fields = (
|
||||||
|
"title",
|
||||||
|
"subtitle",
|
||||||
|
"authors",
|
||||||
|
"page",
|
||||||
|
"featured",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Book)
|
@admin.register(Book)
|
||||||
class BookAdmin(SortableAdminBase, admin.ModelAdmin):
|
class BookAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||||
inlines = (BookImageInline, BookPropertyInline,)
|
inlines = (
|
||||||
|
BookTocInline,
|
||||||
|
BookPropertyInline,
|
||||||
|
BookImageInline,
|
||||||
|
BookDocInline,
|
||||||
|
)
|
||||||
list_display = (
|
list_display = (
|
||||||
"title",
|
"title",
|
||||||
"get_authors",
|
"get_authors",
|
||||||
"get_publisher",
|
"get_publisher",
|
||||||
"publication_year",
|
"publication_year",
|
||||||
"number_of_pages"
|
"number_of_pages",
|
||||||
|
"published",
|
||||||
)
|
)
|
||||||
|
autocomplete_fields = ("authors", "publisher", "shop")
|
||||||
|
readonly_fields = ("invoices", "creation_time", "updated_time")
|
||||||
search_fields = ("title", "publisher__name", "authors__last_name")
|
search_fields = ("title", "publisher__name", "authors__last_name")
|
||||||
list_filter = ("publisher__name", "authors")
|
list_filter = ("publisher__name", "authors", "published")
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"published",
|
||||||
|
"title",
|
||||||
|
"authors",
|
||||||
|
"publisher",
|
||||||
|
"ISBN",
|
||||||
|
"language",
|
||||||
|
"number_of_pages",
|
||||||
|
"publication_year",
|
||||||
|
"description",
|
||||||
|
"tags",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Purchase data",
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"shop",
|
||||||
|
"purchase_date",
|
||||||
|
"price",
|
||||||
|
"invoices",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Notes",
|
||||||
|
{"classes": ("collapse",), "fields": ("notes",)},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Audit",
|
||||||
|
{
|
||||||
|
"classes": ("collapse",),
|
||||||
|
"fields": (
|
||||||
|
"creation_time",
|
||||||
|
"updated_time",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_form(self, request, obj=None, **kwargs):
|
||||||
|
form = super().get_form(request, obj, **kwargs)
|
||||||
|
form.base_fields["price"].label = "Price ({})".format(
|
||||||
|
get_site_conf().currency
|
||||||
|
)
|
||||||
|
return form
|
||||||
|
|
||||||
|
@admin.display(description="Invoices")
|
||||||
|
def invoices(self, obj):
|
||||||
|
if obj.invoice.exists():
|
||||||
|
html = format_html_join(
|
||||||
|
mark_safe("<br>"),
|
||||||
|
'<a href="{}" target="_blank">{}</a>',
|
||||||
|
((i.file.url, i) for i in obj.invoice.all()),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
html = "-"
|
||||||
|
return html
|
||||||
|
|
||||||
@admin.display(description="Publisher")
|
@admin.display(description="Publisher")
|
||||||
def get_publisher(self, obj):
|
def get_publisher(self, obj):
|
||||||
@@ -37,16 +168,358 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.display(description="Authors")
|
@admin.display(description="Authors")
|
||||||
def get_authors(self, obj):
|
def get_authors(self, obj):
|
||||||
return ", ".join(a.short_name() for a in obj.authors.all())
|
return obj.authors_list
|
||||||
|
|
||||||
|
def download_csv(modeladmin, request, queryset):
|
||||||
|
header = [
|
||||||
|
"Title",
|
||||||
|
"Authors",
|
||||||
|
"Publisher",
|
||||||
|
"ISBN",
|
||||||
|
"Language",
|
||||||
|
"Number of Pages",
|
||||||
|
"Publication Year",
|
||||||
|
"Description",
|
||||||
|
"Tags",
|
||||||
|
"Shop",
|
||||||
|
"Purchase Date",
|
||||||
|
"Price ({})".format(get_site_conf().currency),
|
||||||
|
"Notes",
|
||||||
|
"Properties",
|
||||||
|
]
|
||||||
|
|
||||||
|
data = []
|
||||||
|
for obj in queryset:
|
||||||
|
properties = settings.CSV_SEPARATOR_ALT.join(
|
||||||
|
"{}:{}".format(property.property.name, property.value)
|
||||||
|
for property in obj.property.all()
|
||||||
|
)
|
||||||
|
data.append(
|
||||||
|
[
|
||||||
|
obj.title,
|
||||||
|
obj.authors_list.replace(",", settings.CSV_SEPARATOR_ALT),
|
||||||
|
obj.publisher.name,
|
||||||
|
obj.ISBN,
|
||||||
|
dict(settings.LANGUAGES)[obj.language],
|
||||||
|
obj.number_of_pages,
|
||||||
|
obj.publication_year,
|
||||||
|
html.unescape(strip_tags(obj.description)),
|
||||||
|
settings.CSV_SEPARATOR_ALT.join(
|
||||||
|
t.name for t in obj.tags.all()
|
||||||
|
),
|
||||||
|
obj.shop,
|
||||||
|
obj.purchase_date,
|
||||||
|
obj.price,
|
||||||
|
html.unescape(strip_tags(obj.notes)),
|
||||||
|
properties,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return generate_csv(header, data, "bookshelf_books.csv")
|
||||||
|
|
||||||
|
download_csv.short_description = "Download selected items as CSV"
|
||||||
|
actions = [publish, unpublish, download_csv]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Author)
|
@admin.register(Author)
|
||||||
class AuthorAdmin(admin.ModelAdmin):
|
class AuthorAdmin(admin.ModelAdmin):
|
||||||
search_fields = ("first_name", "last_name",)
|
search_fields = (
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
)
|
||||||
list_filter = ("last_name",)
|
list_filter = ("last_name",)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Publisher)
|
@admin.register(Publisher)
|
||||||
class PublisherAdmin(admin.ModelAdmin):
|
class PublisherAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "country")
|
list_display = ("name", "country_flag_name")
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
|
|
||||||
|
@admin.display(description="Country")
|
||||||
|
def country_flag_name(self, obj):
|
||||||
|
return format_html(
|
||||||
|
'<img src="{}" /> {}', obj.country.flag, obj.country.name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Catalog)
|
||||||
|
class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||||
|
inlines = (
|
||||||
|
BookPropertyInline,
|
||||||
|
BookImageInline,
|
||||||
|
CatalogDocInline,
|
||||||
|
)
|
||||||
|
list_display = (
|
||||||
|
"__str__",
|
||||||
|
"manufacturer",
|
||||||
|
"years",
|
||||||
|
"get_scales",
|
||||||
|
"published",
|
||||||
|
)
|
||||||
|
autocomplete_fields = ("manufacturer",)
|
||||||
|
readonly_fields = ("invoices", "creation_time", "updated_time")
|
||||||
|
search_fields = ("manufacturer__name", "years", "scales__scale")
|
||||||
|
list_filter = (
|
||||||
|
"published",
|
||||||
|
"manufacturer__name",
|
||||||
|
"publication_year",
|
||||||
|
"scales__scale",
|
||||||
|
)
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"published",
|
||||||
|
"manufacturer",
|
||||||
|
"years",
|
||||||
|
"scales",
|
||||||
|
"ISBN",
|
||||||
|
"language",
|
||||||
|
"number_of_pages",
|
||||||
|
"publication_year",
|
||||||
|
"description",
|
||||||
|
"tags",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Purchase data",
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"shop",
|
||||||
|
"purchase_date",
|
||||||
|
"price",
|
||||||
|
"invoices",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Notes",
|
||||||
|
{"classes": ("collapse",), "fields": ("notes",)},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Audit",
|
||||||
|
{
|
||||||
|
"classes": ("collapse",),
|
||||||
|
"fields": (
|
||||||
|
"creation_time",
|
||||||
|
"updated_time",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_form(self, request, obj=None, **kwargs):
|
||||||
|
form = super().get_form(request, obj, **kwargs)
|
||||||
|
form.base_fields["price"].label = "Price ({})".format(
|
||||||
|
get_site_conf().currency
|
||||||
|
)
|
||||||
|
return form
|
||||||
|
|
||||||
|
@admin.display(description="Invoices")
|
||||||
|
def invoices(self, obj):
|
||||||
|
if obj.invoice.exists():
|
||||||
|
html = format_html_join(
|
||||||
|
mark_safe("<br>"),
|
||||||
|
'<a href="{}" target="_blank">{}</a>',
|
||||||
|
((i.file.url, i) for i in obj.invoice.all()),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
html = "-"
|
||||||
|
return html
|
||||||
|
|
||||||
|
def download_csv(modeladmin, request, queryset):
|
||||||
|
header = [
|
||||||
|
"Catalog",
|
||||||
|
"Manufacturer",
|
||||||
|
"Years",
|
||||||
|
"Scales",
|
||||||
|
"ISBN",
|
||||||
|
"Language",
|
||||||
|
"Number of Pages",
|
||||||
|
"Publication Year",
|
||||||
|
"Description",
|
||||||
|
"Tags",
|
||||||
|
"Shop",
|
||||||
|
"Purchase Date",
|
||||||
|
"Price ({})".format(get_site_conf().currency),
|
||||||
|
"Notes",
|
||||||
|
"Properties",
|
||||||
|
]
|
||||||
|
|
||||||
|
data = []
|
||||||
|
for obj in queryset:
|
||||||
|
properties = settings.CSV_SEPARATOR_ALT.join(
|
||||||
|
"{}:{}".format(property.property.name, property.value)
|
||||||
|
for property in obj.property.all()
|
||||||
|
)
|
||||||
|
data.append(
|
||||||
|
[
|
||||||
|
obj.__str__(),
|
||||||
|
obj.manufacturer.name,
|
||||||
|
obj.years,
|
||||||
|
obj.get_scales(),
|
||||||
|
obj.ISBN,
|
||||||
|
dict(settings.LANGUAGES)[obj.language],
|
||||||
|
obj.number_of_pages,
|
||||||
|
obj.publication_year,
|
||||||
|
html.unescape(strip_tags(obj.description)),
|
||||||
|
settings.CSV_SEPARATOR_ALT.join(
|
||||||
|
t.name for t in obj.tags.all()
|
||||||
|
),
|
||||||
|
obj.shop,
|
||||||
|
obj.purchase_date,
|
||||||
|
obj.price,
|
||||||
|
html.unescape(strip_tags(obj.notes)),
|
||||||
|
properties,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return generate_csv(header, data, "bookshelf_catalogs.csv")
|
||||||
|
|
||||||
|
download_csv.short_description = "Download selected items as CSV"
|
||||||
|
actions = [publish, unpublish, download_csv]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(MagazineIssue)
|
||||||
|
class MagazineIssueAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||||
|
inlines = (
|
||||||
|
BookTocInline,
|
||||||
|
BookPropertyInline,
|
||||||
|
BookImageInline,
|
||||||
|
MagazineIssueDocInline,
|
||||||
|
)
|
||||||
|
list_display = (
|
||||||
|
"__str__",
|
||||||
|
"issue_number",
|
||||||
|
"published",
|
||||||
|
)
|
||||||
|
autocomplete_fields = ("shop",)
|
||||||
|
readonly_fields = ("magazine", "creation_time", "updated_time")
|
||||||
|
|
||||||
|
def get_model_perms(self, request):
|
||||||
|
"""
|
||||||
|
Return empty perms dict thus hiding the model from admin index.
|
||||||
|
"""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"published",
|
||||||
|
"magazine",
|
||||||
|
"issue_number",
|
||||||
|
"publication_year",
|
||||||
|
"publication_month",
|
||||||
|
"ISBN",
|
||||||
|
"language",
|
||||||
|
"number_of_pages",
|
||||||
|
"description",
|
||||||
|
"tags",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Purchase data",
|
||||||
|
{
|
||||||
|
"classes": ("collapse",),
|
||||||
|
"fields": (
|
||||||
|
"shop",
|
||||||
|
"purchase_date",
|
||||||
|
"price",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Notes",
|
||||||
|
{"classes": ("collapse",), "fields": ("notes",)},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Audit",
|
||||||
|
{
|
||||||
|
"classes": ("collapse",),
|
||||||
|
"fields": (
|
||||||
|
"creation_time",
|
||||||
|
"updated_time",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
actions = [publish, unpublish]
|
||||||
|
|
||||||
|
|
||||||
|
class MagazineIssueInline(admin.TabularInline):
|
||||||
|
model = MagazineIssue
|
||||||
|
min_num = 0
|
||||||
|
extra = 0
|
||||||
|
autocomplete_fields = ("shop",)
|
||||||
|
show_change_link = True
|
||||||
|
fields = (
|
||||||
|
"preview",
|
||||||
|
"published",
|
||||||
|
"issue_number",
|
||||||
|
"publication_year",
|
||||||
|
"publication_month",
|
||||||
|
"number_of_pages",
|
||||||
|
"language",
|
||||||
|
)
|
||||||
|
readonly_fields = ("preview",)
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
js = ("admin/js/magazine_issue_defaults.js",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Magazine)
|
||||||
|
class MagazineAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||||
|
inlines = (MagazineIssueInline,)
|
||||||
|
|
||||||
|
list_display = (
|
||||||
|
"__str__",
|
||||||
|
"publisher",
|
||||||
|
"published",
|
||||||
|
)
|
||||||
|
autocomplete_fields = ("publisher",)
|
||||||
|
readonly_fields = ("creation_time", "updated_time")
|
||||||
|
search_fields = ("name", "publisher__name")
|
||||||
|
list_filter = (
|
||||||
|
"published",
|
||||||
|
"publisher__name",
|
||||||
|
)
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
None,
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"published",
|
||||||
|
"name",
|
||||||
|
"website",
|
||||||
|
"publisher",
|
||||||
|
"ISBN",
|
||||||
|
"language",
|
||||||
|
"description",
|
||||||
|
"image",
|
||||||
|
"tags",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Notes",
|
||||||
|
{"classes": ("collapse",), "fields": ("notes",)},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Audit",
|
||||||
|
{
|
||||||
|
"classes": ("collapse",),
|
||||||
|
"fields": (
|
||||||
|
"creation_time",
|
||||||
|
"updated_time",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
actions = [publish, unpublish]
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from django.conf import settings
|
|||||||
|
|
||||||
def move_images(apps, schema_editor):
|
def move_images(apps, schema_editor):
|
||||||
sys.stdout.write("\n Processing files. Please await...")
|
sys.stdout.write("\n Processing files. Please await...")
|
||||||
for r in bookshelf.models.BookImage.objects.all():
|
for r in bookshelf.models.BaseBookImage.objects.all():
|
||||||
fname = os.path.basename(r.image.path)
|
fname = os.path.basename(r.image.path)
|
||||||
new_image = bookshelf.models.book_image_upload(r, fname)
|
new_image = bookshelf.models.book_image_upload(r, fname)
|
||||||
new_path = os.path.join(settings.MEDIA_ROOT, new_image)
|
new_path = os.path.join(settings.MEDIA_ROOT, new_image)
|
||||||
@@ -31,19 +31,21 @@ class Migration(migrations.Migration):
|
|||||||
("bookshelf", "0008_alter_author_options_alter_publisher_options"),
|
("bookshelf", "0008_alter_author_options_alter_publisher_options"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Migration is stale and shouldn't be used since model hes been heavily
|
||||||
|
# modified since then. Leaving it here for reference.
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
# migrations.AlterField(
|
||||||
model_name="bookimage",
|
# model_name="bookimage",
|
||||||
name="image",
|
# name="image",
|
||||||
field=models.ImageField(
|
# field=models.ImageField(
|
||||||
blank=True,
|
# blank=True,
|
||||||
null=True,
|
# null=True,
|
||||||
storage=ram.utils.DeduplicatedStorage,
|
# storage=ram.utils.DeduplicatedStorage,
|
||||||
upload_to=bookshelf.models.book_image_upload,
|
# upload_to=bookshelf.models.book_image_upload,
|
||||||
),
|
# ),
|
||||||
),
|
# ),
|
||||||
migrations.RunPython(
|
# migrations.RunPython(
|
||||||
move_images,
|
# move_images,
|
||||||
reverse_code=migrations.RunPython.noop
|
# reverse_code=migrations.RunPython.noop
|
||||||
),
|
# ),
|
||||||
]
|
]
|
||||||
|
|||||||
19
ram/bookshelf/migrations/0013_book_description.py
Normal file
19
ram/bookshelf/migrations/0013_book_description.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.0.2 on 2024-03-02 14:31
|
||||||
|
|
||||||
|
import tinymce.models
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookshelf", "0012_alter_book_notes"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="book",
|
||||||
|
name="description",
|
||||||
|
field=tinymce.models.HTMLField(blank=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
ram/bookshelf/migrations/0014_book_published.py
Normal file
18
ram/bookshelf/migrations/0014_book_published.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.2 on 2024-11-04 13:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookshelf", "0013_book_description"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="book",
|
||||||
|
name="published",
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
ram/bookshelf/migrations/0015_alter_book_authors.py
Normal file
18
ram/bookshelf/migrations/0015_alter_book_authors.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.2 on 2024-11-26 22:21
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookshelf", "0014_book_published"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="book",
|
||||||
|
name="authors",
|
||||||
|
field=models.ManyToManyField(blank=True, to="bookshelf.author"),
|
||||||
|
),
|
||||||
|
]
|
||||||
163
ram/bookshelf/migrations/0016_basebook_book_catalogue.py
Normal file
163
ram/bookshelf/migrations/0016_basebook_book_catalogue.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# Generated by Django 5.1.2 on 2024-11-27 16:35
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models, connection
|
||||||
|
from django.db.utils import ProgrammingError, OperationalError
|
||||||
|
|
||||||
|
|
||||||
|
def basebook_to_book(apps, schema_editor):
|
||||||
|
basebook = apps.get_model("bookshelf", "BaseBook")
|
||||||
|
book = apps.get_model("bookshelf", "Book")
|
||||||
|
for row in basebook.objects.all():
|
||||||
|
b = book.objects.create(
|
||||||
|
basebook_ptr=row,
|
||||||
|
title=row.old_title,
|
||||||
|
publisher=row.old_publisher,
|
||||||
|
)
|
||||||
|
b.authors.set(row.old_authors.all())
|
||||||
|
|
||||||
|
|
||||||
|
def drop_temporary_tables(apps, schema_editor):
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(
|
||||||
|
'DROP TABLE IF EXISTS bookshelf_basebook_old_authors'
|
||||||
|
)
|
||||||
|
cursor.execute(
|
||||||
|
'DROP TABLE IF EXISTS bookshelf_basebook_authors'
|
||||||
|
)
|
||||||
|
except (ProgrammingError, OperationalError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookshelf", "0015_alter_book_authors"),
|
||||||
|
("metadata", "0019_alter_scale_gauge"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="Book",
|
||||||
|
options={"ordering": ["creation_time"]},
|
||||||
|
),
|
||||||
|
migrations.RenameModel(
|
||||||
|
old_name="BookImage",
|
||||||
|
new_name="BaseBookImage",
|
||||||
|
),
|
||||||
|
migrations.RenameModel(
|
||||||
|
old_name="BookProperty",
|
||||||
|
new_name="BaseBookProperty",
|
||||||
|
),
|
||||||
|
migrations.RenameModel(
|
||||||
|
old_name="Book",
|
||||||
|
new_name="BaseBook",
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="basebook",
|
||||||
|
old_name="title",
|
||||||
|
new_name="old_title",
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="basebook",
|
||||||
|
old_name="authors",
|
||||||
|
new_name="old_authors",
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="basebook",
|
||||||
|
old_name="publisher",
|
||||||
|
new_name="old_publisher",
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="basebookimage",
|
||||||
|
options={"ordering": ["order"], "verbose_name_plural": "Images"},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Book",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"basebook_ptr",
|
||||||
|
models.OneToOneField(
|
||||||
|
auto_created=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
parent_link=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
to="bookshelf.basebook",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("title", models.CharField(max_length=200)),
|
||||||
|
(
|
||||||
|
"authors",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
to="bookshelf.author"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"publisher",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="bookshelf.publisher"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["title"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
basebook_to_book,
|
||||||
|
reverse_code=migrations.RunPython.noop
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="basebook",
|
||||||
|
name="old_title",
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="basebook",
|
||||||
|
name="old_publisher",
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Catalog",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"basebook_ptr",
|
||||||
|
models.OneToOneField(
|
||||||
|
auto_created=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
parent_link=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
to="bookshelf.basebook",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("years", models.CharField(max_length=12)),
|
||||||
|
(
|
||||||
|
"manufacturer",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="metadata.manufacturer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("scales", models.ManyToManyField(to="metadata.scale")),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["manufacturer", "publication_year"],
|
||||||
|
},
|
||||||
|
bases=("bookshelf.basebook",),
|
||||||
|
),
|
||||||
|
# Required by Dajngo 6.0 on SQLite
|
||||||
|
migrations.SeparateDatabaseAndState(
|
||||||
|
state_operations=[
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="basebook",
|
||||||
|
name="old_authors",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
database_operations=[
|
||||||
|
migrations.RunPython(drop_temporary_tables)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Generated by Django 5.1.2 on 2024-12-22 20:38
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import ram.utils
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookshelf", "0016_basebook_book_catalogue"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="basebook",
|
||||||
|
options={},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="BaseBookDocument",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("description", models.CharField(blank=True, max_length=128)),
|
||||||
|
(
|
||||||
|
"file",
|
||||||
|
models.FileField(
|
||||||
|
storage=ram.utils.DeduplicatedStorage(), upload_to="files/"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("private", models.BooleanField(default=False)),
|
||||||
|
(
|
||||||
|
"book",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="document",
|
||||||
|
to="bookshelf.basebook",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"unique_together": {("book", "file")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2024-12-22 20:44
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookshelf", "0017_alter_basebook_options_basebookdocument"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="basebookdocument",
|
||||||
|
options={"verbose_name_plural": "Documents"},
|
||||||
|
),
|
||||||
|
]
|
||||||
36
ram/bookshelf/migrations/0019_basebook_price.py
Normal file
36
ram/bookshelf/migrations/0019_basebook_price.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2024-12-29 17:06
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def price_to_property(apps, schema_editor):
|
||||||
|
basebook = apps.get_model("bookshelf", "BaseBook")
|
||||||
|
for row in basebook.objects.all():
|
||||||
|
prop = row.property.filter(property__name__icontains="price")
|
||||||
|
for p in prop:
|
||||||
|
try:
|
||||||
|
row.price = float(p.value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
row.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookshelf", "0018_alter_basebookdocument_options"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="basebook",
|
||||||
|
name="price",
|
||||||
|
field=models.DecimalField(
|
||||||
|
blank=True, decimal_places=2, max_digits=10, null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
price_to_property,
|
||||||
|
reverse_code=migrations.RunPython.noop
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-01-08 22:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookshelf", "0019_basebook_price"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="basebookdocument",
|
||||||
|
unique_together=set(),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="basebookdocument",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("book", "file"), name="unique_book_file"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-01-18 11:20
|
||||||
|
|
||||||
|
import django.utils.timezone
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookshelf", "0020_alter_basebookdocument_unique_together_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="basebookdocument",
|
||||||
|
name="creation_time",
|
||||||
|
field=models.DateTimeField(
|
||||||
|
auto_now_add=True, default=django.utils.timezone.now
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="basebookdocument",
|
||||||
|
name="updated_time",
|
||||||
|
field=models.DateTimeField(auto_now=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="basebookdocument",
|
||||||
|
name="private",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False, help_text="Document will be visible only to logged users"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
50
ram/bookshelf/migrations/0022_basebook_shop.py
Normal file
50
ram/bookshelf/migrations/0022_basebook_shop.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-01-26 14:32
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def shop_from_property(apps, schema_editor):
|
||||||
|
basebook = apps.get_model("bookshelf", "BaseBook")
|
||||||
|
shop_model = apps.get_model("metadata", "Shop")
|
||||||
|
for row in basebook.objects.all():
|
||||||
|
property = row.property.filter(
|
||||||
|
property__name__icontains="shop"
|
||||||
|
).first()
|
||||||
|
if property:
|
||||||
|
shop, created = shop_model.objects.get_or_create(
|
||||||
|
name=property.value,
|
||||||
|
defaults={"on_line": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
row.shop = shop
|
||||||
|
row.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookshelf", "0021_basebookdocument_creation_time_and_more"),
|
||||||
|
("metadata", "0023_shop"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveConstraint(
|
||||||
|
model_name="basebookdocument",
|
||||||
|
name="unique_book_file",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="basebook",
|
||||||
|
name="shop",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="metadata.shop",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
shop_from_property,
|
||||||
|
reverse_code=migrations.RunPython.noop
|
||||||
|
),
|
||||||
|
]
|
||||||
17
ram/bookshelf/migrations/0023_delete_basebookdocument.py
Normal file
17
ram/bookshelf/migrations/0023_delete_basebookdocument.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-02-09 13:47
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookshelf", "0022_basebook_shop"),
|
||||||
|
("repository", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="BaseBookDocument",
|
||||||
|
),
|
||||||
|
]
|
||||||
123
ram/bookshelf/migrations/0024_alter_basebook_language.py
Normal file
123
ram/bookshelf/migrations/0024_alter_basebook_language.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# Generated by Django 6.0 on 2025-12-03 22:07
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookshelf", "0023_delete_basebookdocument"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="basebook",
|
||||||
|
name="language",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("af", "Afrikaans"),
|
||||||
|
("ar", "Arabic"),
|
||||||
|
("ar-dz", "Algerian Arabic"),
|
||||||
|
("ast", "Asturian"),
|
||||||
|
("az", "Azerbaijani"),
|
||||||
|
("bg", "Bulgarian"),
|
||||||
|
("be", "Belarusian"),
|
||||||
|
("bn", "Bengali"),
|
||||||
|
("br", "Breton"),
|
||||||
|
("bs", "Bosnian"),
|
||||||
|
("ca", "Catalan"),
|
||||||
|
("ckb", "Central Kurdish (Sorani)"),
|
||||||
|
("cs", "Czech"),
|
||||||
|
("cy", "Welsh"),
|
||||||
|
("da", "Danish"),
|
||||||
|
("de", "German"),
|
||||||
|
("dsb", "Lower Sorbian"),
|
||||||
|
("el", "Greek"),
|
||||||
|
("en", "English"),
|
||||||
|
("en-au", "Australian English"),
|
||||||
|
("en-gb", "British English"),
|
||||||
|
("eo", "Esperanto"),
|
||||||
|
("es", "Spanish"),
|
||||||
|
("es-ar", "Argentinian Spanish"),
|
||||||
|
("es-co", "Colombian Spanish"),
|
||||||
|
("es-mx", "Mexican Spanish"),
|
||||||
|
("es-ni", "Nicaraguan Spanish"),
|
||||||
|
("es-ve", "Venezuelan Spanish"),
|
||||||
|
("et", "Estonian"),
|
||||||
|
("eu", "Basque"),
|
||||||
|
("fa", "Persian"),
|
||||||
|
("fi", "Finnish"),
|
||||||
|
("fr", "French"),
|
||||||
|
("fy", "Frisian"),
|
||||||
|
("ga", "Irish"),
|
||||||
|
("gd", "Scottish Gaelic"),
|
||||||
|
("gl", "Galician"),
|
||||||
|
("he", "Hebrew"),
|
||||||
|
("hi", "Hindi"),
|
||||||
|
("hr", "Croatian"),
|
||||||
|
("hsb", "Upper Sorbian"),
|
||||||
|
("ht", "Haitian Creole"),
|
||||||
|
("hu", "Hungarian"),
|
||||||
|
("hy", "Armenian"),
|
||||||
|
("ia", "Interlingua"),
|
||||||
|
("id", "Indonesian"),
|
||||||
|
("ig", "Igbo"),
|
||||||
|
("io", "Ido"),
|
||||||
|
("is", "Icelandic"),
|
||||||
|
("it", "Italian"),
|
||||||
|
("ja", "Japanese"),
|
||||||
|
("ka", "Georgian"),
|
||||||
|
("kab", "Kabyle"),
|
||||||
|
("kk", "Kazakh"),
|
||||||
|
("km", "Khmer"),
|
||||||
|
("kn", "Kannada"),
|
||||||
|
("ko", "Korean"),
|
||||||
|
("ky", "Kyrgyz"),
|
||||||
|
("lb", "Luxembourgish"),
|
||||||
|
("lt", "Lithuanian"),
|
||||||
|
("lv", "Latvian"),
|
||||||
|
("mk", "Macedonian"),
|
||||||
|
("ml", "Malayalam"),
|
||||||
|
("mn", "Mongolian"),
|
||||||
|
("mr", "Marathi"),
|
||||||
|
("ms", "Malay"),
|
||||||
|
("my", "Burmese"),
|
||||||
|
("nb", "Norwegian Bokmål"),
|
||||||
|
("ne", "Nepali"),
|
||||||
|
("nl", "Dutch"),
|
||||||
|
("nn", "Norwegian Nynorsk"),
|
||||||
|
("os", "Ossetic"),
|
||||||
|
("pa", "Punjabi"),
|
||||||
|
("pl", "Polish"),
|
||||||
|
("pt", "Portuguese"),
|
||||||
|
("pt-br", "Brazilian Portuguese"),
|
||||||
|
("ro", "Romanian"),
|
||||||
|
("ru", "Russian"),
|
||||||
|
("sk", "Slovak"),
|
||||||
|
("sl", "Slovenian"),
|
||||||
|
("sq", "Albanian"),
|
||||||
|
("sr", "Serbian"),
|
||||||
|
("sr-latn", "Serbian Latin"),
|
||||||
|
("sv", "Swedish"),
|
||||||
|
("sw", "Swahili"),
|
||||||
|
("ta", "Tamil"),
|
||||||
|
("te", "Telugu"),
|
||||||
|
("tg", "Tajik"),
|
||||||
|
("th", "Thai"),
|
||||||
|
("tk", "Turkmen"),
|
||||||
|
("tr", "Turkish"),
|
||||||
|
("tt", "Tatar"),
|
||||||
|
("udm", "Udmurt"),
|
||||||
|
("ug", "Uyghur"),
|
||||||
|
("uk", "Ukrainian"),
|
||||||
|
("ur", "Urdu"),
|
||||||
|
("uz", "Uzbek"),
|
||||||
|
("vi", "Vietnamese"),
|
||||||
|
("zh-hans", "Simplified Chinese"),
|
||||||
|
("zh-hant", "Traditional Chinese"),
|
||||||
|
],
|
||||||
|
default="en",
|
||||||
|
max_length=7,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
224
ram/bookshelf/migrations/0025_magazine_magazineissue.py
Normal file
224
ram/bookshelf/migrations/0025_magazine_magazineissue.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
# Generated by Django 6.0 on 2025-12-08 17:47
|
||||||
|
|
||||||
|
import bookshelf.models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import ram.utils
|
||||||
|
import tinymce.models
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookshelf", "0024_alter_basebook_language"),
|
||||||
|
("metadata", "0025_alter_company_options_alter_manufacturer_options_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Magazine",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("description", tinymce.models.HTMLField(blank=True)),
|
||||||
|
("notes", tinymce.models.HTMLField(blank=True)),
|
||||||
|
("creation_time", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_time", models.DateTimeField(auto_now=True)),
|
||||||
|
("published", models.BooleanField(default=True)),
|
||||||
|
("name", models.CharField(max_length=200)),
|
||||||
|
("ISBN", models.CharField(blank=True, max_length=17)),
|
||||||
|
(
|
||||||
|
"image",
|
||||||
|
models.ImageField(
|
||||||
|
blank=True,
|
||||||
|
storage=ram.utils.DeduplicatedStorage,
|
||||||
|
upload_to=bookshelf.models.book_image_upload,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"language",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("af", "Afrikaans"),
|
||||||
|
("ar", "Arabic"),
|
||||||
|
("ar-dz", "Algerian Arabic"),
|
||||||
|
("ast", "Asturian"),
|
||||||
|
("az", "Azerbaijani"),
|
||||||
|
("bg", "Bulgarian"),
|
||||||
|
("be", "Belarusian"),
|
||||||
|
("bn", "Bengali"),
|
||||||
|
("br", "Breton"),
|
||||||
|
("bs", "Bosnian"),
|
||||||
|
("ca", "Catalan"),
|
||||||
|
("ckb", "Central Kurdish (Sorani)"),
|
||||||
|
("cs", "Czech"),
|
||||||
|
("cy", "Welsh"),
|
||||||
|
("da", "Danish"),
|
||||||
|
("de", "German"),
|
||||||
|
("dsb", "Lower Sorbian"),
|
||||||
|
("el", "Greek"),
|
||||||
|
("en", "English"),
|
||||||
|
("en-au", "Australian English"),
|
||||||
|
("en-gb", "British English"),
|
||||||
|
("eo", "Esperanto"),
|
||||||
|
("es", "Spanish"),
|
||||||
|
("es-ar", "Argentinian Spanish"),
|
||||||
|
("es-co", "Colombian Spanish"),
|
||||||
|
("es-mx", "Mexican Spanish"),
|
||||||
|
("es-ni", "Nicaraguan Spanish"),
|
||||||
|
("es-ve", "Venezuelan Spanish"),
|
||||||
|
("et", "Estonian"),
|
||||||
|
("eu", "Basque"),
|
||||||
|
("fa", "Persian"),
|
||||||
|
("fi", "Finnish"),
|
||||||
|
("fr", "French"),
|
||||||
|
("fy", "Frisian"),
|
||||||
|
("ga", "Irish"),
|
||||||
|
("gd", "Scottish Gaelic"),
|
||||||
|
("gl", "Galician"),
|
||||||
|
("he", "Hebrew"),
|
||||||
|
("hi", "Hindi"),
|
||||||
|
("hr", "Croatian"),
|
||||||
|
("hsb", "Upper Sorbian"),
|
||||||
|
("ht", "Haitian Creole"),
|
||||||
|
("hu", "Hungarian"),
|
||||||
|
("hy", "Armenian"),
|
||||||
|
("ia", "Interlingua"),
|
||||||
|
("id", "Indonesian"),
|
||||||
|
("ig", "Igbo"),
|
||||||
|
("io", "Ido"),
|
||||||
|
("is", "Icelandic"),
|
||||||
|
("it", "Italian"),
|
||||||
|
("ja", "Japanese"),
|
||||||
|
("ka", "Georgian"),
|
||||||
|
("kab", "Kabyle"),
|
||||||
|
("kk", "Kazakh"),
|
||||||
|
("km", "Khmer"),
|
||||||
|
("kn", "Kannada"),
|
||||||
|
("ko", "Korean"),
|
||||||
|
("ky", "Kyrgyz"),
|
||||||
|
("lb", "Luxembourgish"),
|
||||||
|
("lt", "Lithuanian"),
|
||||||
|
("lv", "Latvian"),
|
||||||
|
("mk", "Macedonian"),
|
||||||
|
("ml", "Malayalam"),
|
||||||
|
("mn", "Mongolian"),
|
||||||
|
("mr", "Marathi"),
|
||||||
|
("ms", "Malay"),
|
||||||
|
("my", "Burmese"),
|
||||||
|
("nb", "Norwegian Bokmål"),
|
||||||
|
("ne", "Nepali"),
|
||||||
|
("nl", "Dutch"),
|
||||||
|
("nn", "Norwegian Nynorsk"),
|
||||||
|
("os", "Ossetic"),
|
||||||
|
("pa", "Punjabi"),
|
||||||
|
("pl", "Polish"),
|
||||||
|
("pt", "Portuguese"),
|
||||||
|
("pt-br", "Brazilian Portuguese"),
|
||||||
|
("ro", "Romanian"),
|
||||||
|
("ru", "Russian"),
|
||||||
|
("sk", "Slovak"),
|
||||||
|
("sl", "Slovenian"),
|
||||||
|
("sq", "Albanian"),
|
||||||
|
("sr", "Serbian"),
|
||||||
|
("sr-latn", "Serbian Latin"),
|
||||||
|
("sv", "Swedish"),
|
||||||
|
("sw", "Swahili"),
|
||||||
|
("ta", "Tamil"),
|
||||||
|
("te", "Telugu"),
|
||||||
|
("tg", "Tajik"),
|
||||||
|
("th", "Thai"),
|
||||||
|
("tk", "Turkmen"),
|
||||||
|
("tr", "Turkish"),
|
||||||
|
("tt", "Tatar"),
|
||||||
|
("udm", "Udmurt"),
|
||||||
|
("ug", "Uyghur"),
|
||||||
|
("uk", "Ukrainian"),
|
||||||
|
("ur", "Urdu"),
|
||||||
|
("uz", "Uzbek"),
|
||||||
|
("vi", "Vietnamese"),
|
||||||
|
("zh-hans", "Simplified Chinese"),
|
||||||
|
("zh-hant", "Traditional Chinese"),
|
||||||
|
],
|
||||||
|
default="en",
|
||||||
|
max_length=7,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"publisher",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="bookshelf.publisher",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"tags",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True, related_name="magazine", to="metadata.tag"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["name"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="MagazineIssue",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"basebook_ptr",
|
||||||
|
models.OneToOneField(
|
||||||
|
auto_created=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
parent_link=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
to="bookshelf.basebook",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("issue_number", models.CharField(max_length=100)),
|
||||||
|
(
|
||||||
|
"publication_month",
|
||||||
|
models.SmallIntegerField(
|
||||||
|
blank=True,
|
||||||
|
choices=[
|
||||||
|
(1, "January"),
|
||||||
|
(2, "February"),
|
||||||
|
(3, "March"),
|
||||||
|
(4, "April"),
|
||||||
|
(5, "May"),
|
||||||
|
(6, "June"),
|
||||||
|
(7, "July"),
|
||||||
|
(8, "August"),
|
||||||
|
(9, "September"),
|
||||||
|
(10, "October"),
|
||||||
|
(11, "November"),
|
||||||
|
(12, "December"),
|
||||||
|
],
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"magazine",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="issue",
|
||||||
|
to="bookshelf.magazine",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["magazine", "issue_number"],
|
||||||
|
"unique_together": {("magazine", "issue_number")},
|
||||||
|
},
|
||||||
|
bases=("bookshelf.basebook",),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# Generated by Django 6.0 on 2025-12-10 20:59
|
||||||
|
|
||||||
|
import bookshelf.models
|
||||||
|
import ram.utils
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookshelf", "0025_magazine_magazineissue"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="basebook",
|
||||||
|
name="language",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("af", "Afrikaans"),
|
||||||
|
("sq", "Albanian"),
|
||||||
|
("ar-dz", "Algerian Arabic"),
|
||||||
|
("ar", "Arabic"),
|
||||||
|
("es-ar", "Argentinian Spanish"),
|
||||||
|
("hy", "Armenian"),
|
||||||
|
("ast", "Asturian"),
|
||||||
|
("en-au", "Australian English"),
|
||||||
|
("az", "Azerbaijani"),
|
||||||
|
("eu", "Basque"),
|
||||||
|
("be", "Belarusian"),
|
||||||
|
("bn", "Bengali"),
|
||||||
|
("bs", "Bosnian"),
|
||||||
|
("pt-br", "Brazilian Portuguese"),
|
||||||
|
("br", "Breton"),
|
||||||
|
("en-gb", "British English"),
|
||||||
|
("bg", "Bulgarian"),
|
||||||
|
("my", "Burmese"),
|
||||||
|
("ca", "Catalan"),
|
||||||
|
("ckb", "Central Kurdish (Sorani)"),
|
||||||
|
("es-co", "Colombian Spanish"),
|
||||||
|
("hr", "Croatian"),
|
||||||
|
("cs", "Czech"),
|
||||||
|
("da", "Danish"),
|
||||||
|
("nl", "Dutch"),
|
||||||
|
("en", "English"),
|
||||||
|
("eo", "Esperanto"),
|
||||||
|
("et", "Estonian"),
|
||||||
|
("fi", "Finnish"),
|
||||||
|
("fr", "French"),
|
||||||
|
("fy", "Frisian"),
|
||||||
|
("gl", "Galician"),
|
||||||
|
("ka", "Georgian"),
|
||||||
|
("de", "German"),
|
||||||
|
("el", "Greek"),
|
||||||
|
("ht", "Haitian Creole"),
|
||||||
|
("he", "Hebrew"),
|
||||||
|
("hi", "Hindi"),
|
||||||
|
("hu", "Hungarian"),
|
||||||
|
("is", "Icelandic"),
|
||||||
|
("io", "Ido"),
|
||||||
|
("ig", "Igbo"),
|
||||||
|
("id", "Indonesian"),
|
||||||
|
("ia", "Interlingua"),
|
||||||
|
("ga", "Irish"),
|
||||||
|
("it", "Italian"),
|
||||||
|
("ja", "Japanese"),
|
||||||
|
("kab", "Kabyle"),
|
||||||
|
("kn", "Kannada"),
|
||||||
|
("kk", "Kazakh"),
|
||||||
|
("km", "Khmer"),
|
||||||
|
("ko", "Korean"),
|
||||||
|
("ky", "Kyrgyz"),
|
||||||
|
("lv", "Latvian"),
|
||||||
|
("lt", "Lithuanian"),
|
||||||
|
("dsb", "Lower Sorbian"),
|
||||||
|
("lb", "Luxembourgish"),
|
||||||
|
("mk", "Macedonian"),
|
||||||
|
("ms", "Malay"),
|
||||||
|
("ml", "Malayalam"),
|
||||||
|
("mr", "Marathi"),
|
||||||
|
("es-mx", "Mexican Spanish"),
|
||||||
|
("mn", "Mongolian"),
|
||||||
|
("ne", "Nepali"),
|
||||||
|
("es-ni", "Nicaraguan Spanish"),
|
||||||
|
("nb", "Norwegian Bokmål"),
|
||||||
|
("nn", "Norwegian Nynorsk"),
|
||||||
|
("os", "Ossetic"),
|
||||||
|
("fa", "Persian"),
|
||||||
|
("pl", "Polish"),
|
||||||
|
("pt", "Portuguese"),
|
||||||
|
("pa", "Punjabi"),
|
||||||
|
("ro", "Romanian"),
|
||||||
|
("ru", "Russian"),
|
||||||
|
("gd", "Scottish Gaelic"),
|
||||||
|
("sr", "Serbian"),
|
||||||
|
("sr-latn", "Serbian Latin"),
|
||||||
|
("zh-hans", "Simplified Chinese"),
|
||||||
|
("sk", "Slovak"),
|
||||||
|
("sl", "Slovenian"),
|
||||||
|
("es", "Spanish"),
|
||||||
|
("sw", "Swahili"),
|
||||||
|
("sv", "Swedish"),
|
||||||
|
("tg", "Tajik"),
|
||||||
|
("ta", "Tamil"),
|
||||||
|
("tt", "Tatar"),
|
||||||
|
("te", "Telugu"),
|
||||||
|
("th", "Thai"),
|
||||||
|
("zh-hant", "Traditional Chinese"),
|
||||||
|
("tr", "Turkish"),
|
||||||
|
("tk", "Turkmen"),
|
||||||
|
("udm", "Udmurt"),
|
||||||
|
("uk", "Ukrainian"),
|
||||||
|
("hsb", "Upper Sorbian"),
|
||||||
|
("ur", "Urdu"),
|
||||||
|
("ug", "Uyghur"),
|
||||||
|
("uz", "Uzbek"),
|
||||||
|
("es-ve", "Venezuelan Spanish"),
|
||||||
|
("vi", "Vietnamese"),
|
||||||
|
("cy", "Welsh"),
|
||||||
|
],
|
||||||
|
default="en",
|
||||||
|
max_length=7,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="magazine",
|
||||||
|
name="image",
|
||||||
|
field=models.ImageField(
|
||||||
|
blank=True,
|
||||||
|
storage=ram.utils.DeduplicatedStorage,
|
||||||
|
upload_to=bookshelf.models.magazine_image_upload,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="magazine",
|
||||||
|
name="language",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("af", "Afrikaans"),
|
||||||
|
("sq", "Albanian"),
|
||||||
|
("ar-dz", "Algerian Arabic"),
|
||||||
|
("ar", "Arabic"),
|
||||||
|
("es-ar", "Argentinian Spanish"),
|
||||||
|
("hy", "Armenian"),
|
||||||
|
("ast", "Asturian"),
|
||||||
|
("en-au", "Australian English"),
|
||||||
|
("az", "Azerbaijani"),
|
||||||
|
("eu", "Basque"),
|
||||||
|
("be", "Belarusian"),
|
||||||
|
("bn", "Bengali"),
|
||||||
|
("bs", "Bosnian"),
|
||||||
|
("pt-br", "Brazilian Portuguese"),
|
||||||
|
("br", "Breton"),
|
||||||
|
("en-gb", "British English"),
|
||||||
|
("bg", "Bulgarian"),
|
||||||
|
("my", "Burmese"),
|
||||||
|
("ca", "Catalan"),
|
||||||
|
("ckb", "Central Kurdish (Sorani)"),
|
||||||
|
("es-co", "Colombian Spanish"),
|
||||||
|
("hr", "Croatian"),
|
||||||
|
("cs", "Czech"),
|
||||||
|
("da", "Danish"),
|
||||||
|
("nl", "Dutch"),
|
||||||
|
("en", "English"),
|
||||||
|
("eo", "Esperanto"),
|
||||||
|
("et", "Estonian"),
|
||||||
|
("fi", "Finnish"),
|
||||||
|
("fr", "French"),
|
||||||
|
("fy", "Frisian"),
|
||||||
|
("gl", "Galician"),
|
||||||
|
("ka", "Georgian"),
|
||||||
|
("de", "German"),
|
||||||
|
("el", "Greek"),
|
||||||
|
("ht", "Haitian Creole"),
|
||||||
|
("he", "Hebrew"),
|
||||||
|
("hi", "Hindi"),
|
||||||
|
("hu", "Hungarian"),
|
||||||
|
("is", "Icelandic"),
|
||||||
|
("io", "Ido"),
|
||||||
|
("ig", "Igbo"),
|
||||||
|
("id", "Indonesian"),
|
||||||
|
("ia", "Interlingua"),
|
||||||
|
("ga", "Irish"),
|
||||||
|
("it", "Italian"),
|
||||||
|
("ja", "Japanese"),
|
||||||
|
("kab", "Kabyle"),
|
||||||
|
("kn", "Kannada"),
|
||||||
|
("kk", "Kazakh"),
|
||||||
|
("km", "Khmer"),
|
||||||
|
("ko", "Korean"),
|
||||||
|
("ky", "Kyrgyz"),
|
||||||
|
("lv", "Latvian"),
|
||||||
|
("lt", "Lithuanian"),
|
||||||
|
("dsb", "Lower Sorbian"),
|
||||||
|
("lb", "Luxembourgish"),
|
||||||
|
("mk", "Macedonian"),
|
||||||
|
("ms", "Malay"),
|
||||||
|
("ml", "Malayalam"),
|
||||||
|
("mr", "Marathi"),
|
||||||
|
("es-mx", "Mexican Spanish"),
|
||||||
|
("mn", "Mongolian"),
|
||||||
|
("ne", "Nepali"),
|
||||||
|
("es-ni", "Nicaraguan Spanish"),
|
||||||
|
("nb", "Norwegian Bokmål"),
|
||||||
|
("nn", "Norwegian Nynorsk"),
|
||||||
|
("os", "Ossetic"),
|
||||||
|
("fa", "Persian"),
|
||||||
|
("pl", "Polish"),
|
||||||
|
("pt", "Portuguese"),
|
||||||
|
("pa", "Punjabi"),
|
||||||
|
("ro", "Romanian"),
|
||||||
|
("ru", "Russian"),
|
||||||
|
("gd", "Scottish Gaelic"),
|
||||||
|
("sr", "Serbian"),
|
||||||
|
("sr-latn", "Serbian Latin"),
|
||||||
|
("zh-hans", "Simplified Chinese"),
|
||||||
|
("sk", "Slovak"),
|
||||||
|
("sl", "Slovenian"),
|
||||||
|
("es", "Spanish"),
|
||||||
|
("sw", "Swahili"),
|
||||||
|
("sv", "Swedish"),
|
||||||
|
("tg", "Tajik"),
|
||||||
|
("ta", "Tamil"),
|
||||||
|
("tt", "Tatar"),
|
||||||
|
("te", "Telugu"),
|
||||||
|
("th", "Thai"),
|
||||||
|
("zh-hant", "Traditional Chinese"),
|
||||||
|
("tr", "Turkish"),
|
||||||
|
("tk", "Turkmen"),
|
||||||
|
("udm", "Udmurt"),
|
||||||
|
("uk", "Ukrainian"),
|
||||||
|
("hsb", "Upper Sorbian"),
|
||||||
|
("ur", "Urdu"),
|
||||||
|
("ug", "Uyghur"),
|
||||||
|
("uz", "Uzbek"),
|
||||||
|
("es-ve", "Venezuelan Spanish"),
|
||||||
|
("vi", "Vietnamese"),
|
||||||
|
("cy", "Welsh"),
|
||||||
|
],
|
||||||
|
default="en",
|
||||||
|
max_length=7,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
ram/bookshelf/migrations/0027_magazine_website.py
Normal file
18
ram/bookshelf/migrations/0027_magazine_website.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0 on 2025-12-12 14:02
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookshelf", "0026_alter_basebook_language_alter_magazine_image_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="magazine",
|
||||||
|
name="website",
|
||||||
|
field=models.URLField(blank=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 6.0 on 2025-12-21 21:56
|
||||||
|
|
||||||
|
import django.db.models.functions.text
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookshelf", "0027_magazine_website"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="magazine",
|
||||||
|
options={"ordering": [django.db.models.functions.text.Lower("name")]},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="magazineissue",
|
||||||
|
options={
|
||||||
|
"ordering": [
|
||||||
|
"magazine",
|
||||||
|
"publication_year",
|
||||||
|
"publication_month",
|
||||||
|
"issue_number",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 6.0 on 2025-12-23 11:18
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookshelf", "0028_alter_magazine_options_alter_magazineissue_options"),
|
||||||
|
("metadata", "0025_alter_company_options_alter_manufacturer_options_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="catalog",
|
||||||
|
name="manufacturer",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="catalogs",
|
||||||
|
to="metadata.manufacturer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="catalog",
|
||||||
|
name="scales",
|
||||||
|
field=models.ManyToManyField(related_name="catalogs", to="metadata.scale"),
|
||||||
|
),
|
||||||
|
]
|
||||||
53
ram/bookshelf/migrations/0030_tocentry.py
Normal file
53
ram/bookshelf/migrations/0030_tocentry.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# Generated by Django 6.0 on 2025-12-29 11:02
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import tinymce.models
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookshelf", "0029_alter_catalog_manufacturer_alter_catalog_scales"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="TocEntry",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"uuid",
|
||||||
|
models.UUIDField(
|
||||||
|
default=uuid.uuid4,
|
||||||
|
editable=False,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("description", tinymce.models.HTMLField(blank=True)),
|
||||||
|
("notes", tinymce.models.HTMLField(blank=True)),
|
||||||
|
("creation_time", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("updated_time", models.DateTimeField(auto_now=True)),
|
||||||
|
("published", models.BooleanField(default=True)),
|
||||||
|
("title", models.CharField(max_length=200)),
|
||||||
|
("subtitle", models.CharField(blank=True, max_length=200)),
|
||||||
|
("authors", models.CharField(blank=True, max_length=256)),
|
||||||
|
("page", models.SmallIntegerField()),
|
||||||
|
("featured", models.BooleanField(default=False)),
|
||||||
|
(
|
||||||
|
"book",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="toc",
|
||||||
|
to="bookshelf.basebook",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Table of Contents Entry",
|
||||||
|
"verbose_name_plural": "Table of Contents Entries",
|
||||||
|
"ordering": ["page"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 6.0 on 2025-12-31 13:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("bookshelf", "0030_tocentry"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="tocentry",
|
||||||
|
name="authors",
|
||||||
|
field=models.CharField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="tocentry",
|
||||||
|
name="subtitle",
|
||||||
|
field=models.CharField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="tocentry",
|
||||||
|
name="title",
|
||||||
|
field=models.CharField(),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
from uuid import uuid4
|
from urllib.parse import urlparse
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.dates import MONTHS
|
||||||
|
from django.db.models.functions import Lower
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django_countries.fields import CountryField
|
from django_countries.fields import CountryField
|
||||||
|
|
||||||
from tinymce import models as tinymce
|
|
||||||
|
|
||||||
from metadata.models import Tag
|
|
||||||
from ram.utils import DeduplicatedStorage
|
from ram.utils import DeduplicatedStorage
|
||||||
from ram.models import Image, PropertyInstance
|
from ram.models import BaseModel, Image, PropertyInstance
|
||||||
|
from metadata.models import Scale, Manufacturer, Shop, Tag
|
||||||
|
|
||||||
|
|
||||||
class Publisher(models.Model):
|
class Publisher(models.Model):
|
||||||
@@ -35,65 +36,53 @@ class Author(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.last_name}, {self.first_name}"
|
return f"{self.last_name}, {self.first_name}"
|
||||||
|
|
||||||
|
@property
|
||||||
def short_name(self):
|
def short_name(self):
|
||||||
return f"{self.last_name} {self.first_name[0]}."
|
return f"{self.last_name} {self.first_name[0]}."
|
||||||
|
|
||||||
|
|
||||||
class Book(models.Model):
|
class BaseBook(BaseModel):
|
||||||
uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
|
||||||
title = models.CharField(max_length=200)
|
|
||||||
authors = models.ManyToManyField(Author)
|
|
||||||
publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE)
|
|
||||||
ISBN = models.CharField(max_length=17, blank=True) # 13 + dashes
|
ISBN = models.CharField(max_length=17, blank=True) # 13 + dashes
|
||||||
language = models.CharField(
|
language = models.CharField(
|
||||||
max_length=7,
|
max_length=7,
|
||||||
choices=settings.LANGUAGES,
|
choices=sorted(settings.LANGUAGES, key=lambda s: s[1]),
|
||||||
default='en'
|
default="en",
|
||||||
)
|
)
|
||||||
number_of_pages = models.SmallIntegerField(null=True, blank=True)
|
number_of_pages = models.SmallIntegerField(null=True, blank=True)
|
||||||
publication_year = models.SmallIntegerField(null=True, blank=True)
|
publication_year = models.SmallIntegerField(null=True, blank=True)
|
||||||
purchase_date = models.DateField(null=True, blank=True)
|
shop = models.ForeignKey(
|
||||||
tags = models.ManyToManyField(
|
Shop, on_delete=models.CASCADE, null=True, blank=True
|
||||||
Tag, related_name="bookshelf", blank=True
|
|
||||||
)
|
)
|
||||||
notes = tinymce.HTMLField(blank=True)
|
price = models.DecimalField(
|
||||||
creation_time = models.DateTimeField(auto_now_add=True)
|
max_digits=10,
|
||||||
updated_time = models.DateTimeField(auto_now=True)
|
decimal_places=2,
|
||||||
|
null=True,
|
||||||
class Meta:
|
blank=True,
|
||||||
ordering = ["title"]
|
)
|
||||||
|
purchase_date = models.DateField(null=True, blank=True)
|
||||||
def __str__(self):
|
tags = models.ManyToManyField(Tag, related_name="bookshelf", blank=True)
|
||||||
return self.title
|
|
||||||
|
|
||||||
def publisher_name(self):
|
|
||||||
return self.publisher.name
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
|
||||||
return reverse("book", kwargs={"uuid": self.uuid})
|
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
shutil.rmtree(
|
shutil.rmtree(
|
||||||
os.path.join(
|
os.path.join(
|
||||||
settings.MEDIA_ROOT, "images", "books", str(self.uuid)
|
settings.MEDIA_ROOT, "images", "books", str(self.uuid)
|
||||||
),
|
),
|
||||||
ignore_errors=True
|
ignore_errors=True,
|
||||||
)
|
)
|
||||||
super(Book, self).delete(*args, **kwargs)
|
super(BaseBook, self).delete(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def book_image_upload(instance, filename):
|
def book_image_upload(instance, filename):
|
||||||
return os.path.join(
|
return os.path.join("images", "books", str(instance.book.uuid), filename)
|
||||||
"images",
|
|
||||||
"books",
|
|
||||||
str(instance.book.uuid),
|
|
||||||
filename
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BookImage(Image):
|
def magazine_image_upload(instance, filename):
|
||||||
|
return os.path.join("images", "magazines", str(instance.uuid), filename)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseBookImage(Image):
|
||||||
book = models.ForeignKey(
|
book = models.ForeignKey(
|
||||||
Book, on_delete=models.CASCADE, related_name="image"
|
BaseBook, on_delete=models.CASCADE, related_name="image"
|
||||||
)
|
)
|
||||||
image = models.ImageField(
|
image = models.ImageField(
|
||||||
upload_to=book_image_upload,
|
upload_to=book_image_upload,
|
||||||
@@ -101,11 +90,199 @@ class BookImage(Image):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class BookProperty(PropertyInstance):
|
class BaseBookProperty(PropertyInstance):
|
||||||
book = models.ForeignKey(
|
book = models.ForeignKey(
|
||||||
Book,
|
BaseBook,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
null=False,
|
null=False,
|
||||||
blank=False,
|
blank=False,
|
||||||
related_name="property",
|
related_name="property",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Book(BaseBook):
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
authors = models.ManyToManyField(Author, blank=True)
|
||||||
|
publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["title"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
@property
|
||||||
|
def publisher_name(self):
|
||||||
|
return self.publisher.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def authors_list(self):
|
||||||
|
return ", ".join(a.short_name for a in self.authors.all())
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse(
|
||||||
|
"bookshelf_item", kwargs={"selector": "book", "uuid": self.uuid}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Catalog(BaseBook):
|
||||||
|
manufacturer = models.ForeignKey(
|
||||||
|
Manufacturer,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="catalogs",
|
||||||
|
)
|
||||||
|
years = models.CharField(max_length=12)
|
||||||
|
scales = models.ManyToManyField(Scale, related_name="catalogs")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["manufacturer", "publication_year"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
# if the object is new, return an empty string to avoid
|
||||||
|
# calling self.scales.all() which would raise a infinite recursion
|
||||||
|
if self.pk is None:
|
||||||
|
return str() # empty string
|
||||||
|
scales = self.get_scales()
|
||||||
|
return "%s %s %s" % (self.manufacturer.name, self.years, scales)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse(
|
||||||
|
"bookshelf_item", kwargs={"selector": "catalog", "uuid": self.uuid}
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_scales(self):
|
||||||
|
return "/".join([s.scale for s in self.scales.all()])
|
||||||
|
|
||||||
|
get_scales.short_description = "Scales"
|
||||||
|
|
||||||
|
|
||||||
|
class Magazine(BaseModel):
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE)
|
||||||
|
website = models.URLField(blank=True)
|
||||||
|
ISBN = models.CharField(max_length=17, blank=True) # 13 + dashes
|
||||||
|
image = models.ImageField(
|
||||||
|
blank=True,
|
||||||
|
upload_to=magazine_image_upload,
|
||||||
|
storage=DeduplicatedStorage,
|
||||||
|
)
|
||||||
|
language = models.CharField(
|
||||||
|
max_length=7,
|
||||||
|
choices=sorted(settings.LANGUAGES, key=lambda s: s[1]),
|
||||||
|
default="en",
|
||||||
|
)
|
||||||
|
tags = models.ManyToManyField(Tag, related_name="magazine", blank=True)
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
shutil.rmtree(
|
||||||
|
os.path.join(
|
||||||
|
settings.MEDIA_ROOT, "images", "magazines", str(self.uuid)
|
||||||
|
),
|
||||||
|
ignore_errors=True,
|
||||||
|
)
|
||||||
|
super(Magazine, self).delete(*args, **kwargs)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = [Lower("name")]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse("magazine", kwargs={"uuid": self.uuid})
|
||||||
|
|
||||||
|
def get_cover(self):
|
||||||
|
if self.image:
|
||||||
|
return self.image
|
||||||
|
else:
|
||||||
|
cover_issue = self.issue.filter(published=True).first()
|
||||||
|
if cover_issue and cover_issue.image.exists():
|
||||||
|
return cover_issue.image.first().image
|
||||||
|
return None
|
||||||
|
|
||||||
|
def website_short(self):
|
||||||
|
if self.website:
|
||||||
|
return urlparse(self.website).netloc.replace("www.", "")
|
||||||
|
|
||||||
|
|
||||||
|
class MagazineIssue(BaseBook):
|
||||||
|
magazine = models.ForeignKey(
|
||||||
|
Magazine, on_delete=models.CASCADE, related_name="issue"
|
||||||
|
)
|
||||||
|
issue_number = models.CharField(max_length=100)
|
||||||
|
publication_month = models.SmallIntegerField(
|
||||||
|
null=True, blank=True, choices=MONTHS.items()
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ("magazine", "issue_number")
|
||||||
|
ordering = [
|
||||||
|
"magazine",
|
||||||
|
"publication_year",
|
||||||
|
"publication_month",
|
||||||
|
"issue_number",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.magazine.name} - {self.issue_number}"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
if self.magazine.published is False and self.published is True:
|
||||||
|
raise ValidationError(
|
||||||
|
"Cannot set an issue as published if the magazine is not "
|
||||||
|
"published."
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def obj_label(self):
|
||||||
|
return "Magazine Issue"
|
||||||
|
|
||||||
|
def preview(self):
|
||||||
|
return self.image.first().image_thumbnail(100)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def publisher(self):
|
||||||
|
return self.magazine.publisher
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse(
|
||||||
|
"issue", kwargs={"uuid": self.uuid, "magazine": self.magazine.uuid}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TocEntry(BaseModel):
|
||||||
|
book = models.ForeignKey(
|
||||||
|
BaseBook, on_delete=models.CASCADE, related_name="toc"
|
||||||
|
)
|
||||||
|
title = models.CharField()
|
||||||
|
subtitle = models.CharField(blank=True)
|
||||||
|
authors = models.CharField(blank=True)
|
||||||
|
page = models.SmallIntegerField()
|
||||||
|
featured = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["page"]
|
||||||
|
verbose_name = "Table of Contents Entry"
|
||||||
|
verbose_name_plural = "Table of Contents Entries"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
if self.subtitle:
|
||||||
|
title = f"{self.title}: {self.subtitle}"
|
||||||
|
else:
|
||||||
|
title = self.title
|
||||||
|
return f"{title} (p. {self.page})"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
if self.page is None:
|
||||||
|
raise ValidationError("Page number is required.")
|
||||||
|
if self.page < 1:
|
||||||
|
raise ValidationError("Page number is invalid.")
|
||||||
|
try:
|
||||||
|
if self.page > self.book.number_of_pages:
|
||||||
|
raise ValidationError(
|
||||||
|
"Page number exceeds the publication's number of pages."
|
||||||
|
)
|
||||||
|
except TypeError:
|
||||||
|
pass # number_of_pages is None
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from bookshelf.models import Book, Author, Publisher
|
from bookshelf.models import Book, Catalog, Author, Publisher
|
||||||
from metadata.serializers import TagSerializer
|
from metadata.serializers import (
|
||||||
|
ScaleSerializer,
|
||||||
|
ManufacturerSerializer,
|
||||||
|
TagSerializer
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AuthorSerializer(serializers.ModelSerializer):
|
class AuthorSerializer(serializers.ModelSerializer):
|
||||||
@@ -22,5 +26,28 @@ class BookSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Book
|
model = Book
|
||||||
fields = "__all__"
|
exclude = (
|
||||||
|
"notes",
|
||||||
|
"shop",
|
||||||
|
"purchase_date",
|
||||||
|
"price",
|
||||||
|
)
|
||||||
read_only_fields = ("creation_time", "updated_time")
|
read_only_fields = ("creation_time", "updated_time")
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogSerializer(serializers.ModelSerializer):
|
||||||
|
scales = ScaleSerializer(many=True)
|
||||||
|
manufacturer = ManufacturerSerializer()
|
||||||
|
tags = TagSerializer(many=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Catalog
|
||||||
|
exclude = (
|
||||||
|
"notes",
|
||||||
|
"shop",
|
||||||
|
"purchase_date",
|
||||||
|
"price",
|
||||||
|
)
|
||||||
|
read_only_fields = ("creation_time", "updated_time")
|
||||||
|
|
||||||
|
# FIXME: add Magazine and MagazineIssue serializers
|
||||||
|
|||||||
16
ram/bookshelf/static/admin/js/magazine_issue_defaults.js
Normal file
16
ram/bookshelf/static/admin/js/magazine_issue_defaults.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
document.addEventListener('formset:added', function(event) {
|
||||||
|
const newForm = event.target; // the new inline form element
|
||||||
|
|
||||||
|
const defaultLanguage = document.querySelector('#id_language').value;
|
||||||
|
const defaultStatus = document.querySelector('#id_published').checked;
|
||||||
|
|
||||||
|
const languageInput = newForm.querySelector('select[name$="language"]');
|
||||||
|
const statusInput = newForm.querySelector('input[name$="published"]');
|
||||||
|
|
||||||
|
if (languageInput) {
|
||||||
|
languageInput.value = defaultLanguage;
|
||||||
|
}
|
||||||
|
if (statusInput) {
|
||||||
|
statusInput.checked = defaultStatus;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from bookshelf.views import BookList, BookGet
|
from bookshelf.views import BookList, BookGet, CatalogList, CatalogGet
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("book/list", BookList.as_view()),
|
path("book/list", BookList.as_view()),
|
||||||
path("book/get/<str:uuid>", BookGet.as_view()),
|
path("book/get/<uuid:uuid>", BookGet.as_view()),
|
||||||
|
path("catalog/list", CatalogList.as_view()),
|
||||||
|
path("catalog/get/<uuid:uuid>", CatalogGet.as_view()),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,18 +1,42 @@
|
|||||||
from rest_framework.generics import ListAPIView, RetrieveAPIView
|
from rest_framework.generics import ListAPIView, RetrieveAPIView
|
||||||
from rest_framework.schemas.openapi import AutoSchema
|
from rest_framework.schemas.openapi import AutoSchema
|
||||||
|
|
||||||
from bookshelf.models import Book
|
from ram.views import CustomLimitOffsetPagination
|
||||||
from bookshelf.serializers import BookSerializer
|
from bookshelf.models import Book, Catalog
|
||||||
|
from bookshelf.serializers import BookSerializer, CatalogSerializer
|
||||||
|
|
||||||
|
|
||||||
class BookList(ListAPIView):
|
class BookList(ListAPIView):
|
||||||
queryset = Book.objects.all()
|
|
||||||
serializer_class = BookSerializer
|
serializer_class = BookSerializer
|
||||||
|
pagination_class = CustomLimitOffsetPagination
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Book.objects.get_published(self.request.user)
|
||||||
|
|
||||||
|
|
||||||
class BookGet(RetrieveAPIView):
|
class BookGet(RetrieveAPIView):
|
||||||
queryset = Book.objects.all()
|
|
||||||
serializer_class = BookSerializer
|
serializer_class = BookSerializer
|
||||||
lookup_field = "uuid"
|
lookup_field = "uuid"
|
||||||
|
|
||||||
schema = AutoSchema(operation_id_base="retrieveBookByUUID")
|
schema = AutoSchema(operation_id_base="retrieveBookByUUID")
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Book.objects.get_published(self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogList(ListAPIView):
|
||||||
|
serializer_class = CatalogSerializer
|
||||||
|
pagination_class = CustomLimitOffsetPagination
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Catalog.objects.get_published(self.request.user)
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogGet(RetrieveAPIView):
|
||||||
|
serializer_class = CatalogSerializer
|
||||||
|
lookup_field = "uuid"
|
||||||
|
schema = AutoSchema(operation_id_base="retrieveCatalogByUUID")
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Book.objects.get_published(self.request.user)
|
||||||
|
|
||||||
|
# FIXME: add Magazine and MagazineIssue views
|
||||||
|
|||||||
@@ -1,14 +1,43 @@
|
|||||||
from django.contrib import admin
|
import html
|
||||||
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
|
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# from django.forms import BaseInlineFormSet # for future reference
|
||||||
|
from django.utils.html import format_html, strip_tags
|
||||||
|
from adminsortable2.admin import (
|
||||||
|
SortableAdminBase,
|
||||||
|
SortableInlineAdminMixin,
|
||||||
|
# CustomInlineFormSetMixin, # for future reference
|
||||||
|
)
|
||||||
|
|
||||||
|
from ram.admin import publish, unpublish
|
||||||
|
from ram.utils import generate_csv
|
||||||
from consist.models import Consist, ConsistItem
|
from consist.models import Consist, ConsistItem
|
||||||
|
|
||||||
|
|
||||||
|
# for future reference
|
||||||
|
# class ConsistItemInlineFormSet(CustomInlineFormSetMixin, BaseInlineFormSet):
|
||||||
|
# def clean(self):
|
||||||
|
# super().clean()
|
||||||
|
|
||||||
|
|
||||||
class ConsistItemInline(SortableInlineAdminMixin, admin.TabularInline):
|
class ConsistItemInline(SortableInlineAdminMixin, admin.TabularInline):
|
||||||
model = ConsistItem
|
model = ConsistItem
|
||||||
min_num = 1
|
min_num = 1
|
||||||
extra = 0
|
extra = 0
|
||||||
readonly_fields = ("address", "type", "company", "era")
|
autocomplete_fields = ("rolling_stock",)
|
||||||
|
readonly_fields = (
|
||||||
|
"preview",
|
||||||
|
"published",
|
||||||
|
"scale",
|
||||||
|
"manufacturer",
|
||||||
|
"item_number",
|
||||||
|
"company",
|
||||||
|
"type",
|
||||||
|
"era",
|
||||||
|
"address",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Consist)
|
@admin.register(Consist)
|
||||||
@@ -18,26 +47,45 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
|
|||||||
"creation_time",
|
"creation_time",
|
||||||
"updated_time",
|
"updated_time",
|
||||||
)
|
)
|
||||||
list_display = ("identifier", "company", "era")
|
list_filter = ("published", "company__name", "era", "scale__scale")
|
||||||
list_filter = list_display
|
list_display = (
|
||||||
search_fields = list_display
|
"__str__",
|
||||||
|
"company__name",
|
||||||
|
"era",
|
||||||
|
"scale",
|
||||||
|
"country_flag",
|
||||||
|
"published",
|
||||||
|
)
|
||||||
|
search_fields = ("identifier",) + list_filter
|
||||||
save_as = True
|
save_as = True
|
||||||
|
|
||||||
|
@admin.display(description="Country")
|
||||||
|
def country_flag(self, obj):
|
||||||
|
return format_html(
|
||||||
|
'<img src="{}" title="{}" />', obj.country.flag, obj.country.name
|
||||||
|
)
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": (
|
||||||
|
"published",
|
||||||
"identifier",
|
"identifier",
|
||||||
"consist_address",
|
|
||||||
"company",
|
"company",
|
||||||
|
"scale",
|
||||||
"era",
|
"era",
|
||||||
|
"consist_address",
|
||||||
|
"description",
|
||||||
"image",
|
"image",
|
||||||
"notes",
|
|
||||||
"tags",
|
"tags",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"Notes",
|
||||||
|
{"classes": ("collapse",), "fields": ("notes",)},
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"Audit",
|
"Audit",
|
||||||
{
|
{
|
||||||
@@ -49,3 +97,56 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def download_csv(modeladmin, request, queryset):
|
||||||
|
header = [
|
||||||
|
"ID",
|
||||||
|
"Name",
|
||||||
|
"Published",
|
||||||
|
"Company",
|
||||||
|
"Country",
|
||||||
|
"Address",
|
||||||
|
"Scale",
|
||||||
|
"Era",
|
||||||
|
"Description",
|
||||||
|
"Tags",
|
||||||
|
"Length",
|
||||||
|
"Composition",
|
||||||
|
"Item name",
|
||||||
|
"Item type",
|
||||||
|
"Item ID",
|
||||||
|
]
|
||||||
|
data = []
|
||||||
|
for obj in queryset:
|
||||||
|
for item in obj.consist_item.all():
|
||||||
|
types = " + ".join(
|
||||||
|
"{}x {}".format(t["count"], t["type"])
|
||||||
|
for t in obj.get_type_count()
|
||||||
|
)
|
||||||
|
data.append(
|
||||||
|
[
|
||||||
|
obj.uuid,
|
||||||
|
obj.__str__(),
|
||||||
|
"X" if obj.published else "",
|
||||||
|
obj.company.name,
|
||||||
|
obj.company.country,
|
||||||
|
obj.consist_address,
|
||||||
|
obj.scale.scale,
|
||||||
|
obj.era,
|
||||||
|
html.unescape(strip_tags(obj.description)),
|
||||||
|
settings.CSV_SEPARATOR_ALT.join(
|
||||||
|
t.name for t in obj.tags.all()
|
||||||
|
),
|
||||||
|
obj.length,
|
||||||
|
types,
|
||||||
|
item.rolling_stock.__str__(),
|
||||||
|
item.type,
|
||||||
|
item.rolling_stock.uuid,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return generate_csv(header, data, "consists.csv")
|
||||||
|
|
||||||
|
download_csv.short_description = "Download selected items as CSV"
|
||||||
|
|
||||||
|
actions = [publish, unpublish, download_csv]
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 5.0.4 on 2024-04-20 12:49
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("consist", "0010_alter_consist_notes"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="consist",
|
||||||
|
name="consist_address",
|
||||||
|
field=models.SmallIntegerField(
|
||||||
|
blank=True,
|
||||||
|
default=None,
|
||||||
|
help_text="DCC consist address if enabled",
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="consist",
|
||||||
|
name="era",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True, help_text="Era or epoch of the consist", max_length=32
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
ram/consist/migrations/0012_consist_published.py
Normal file
18
ram/consist/migrations/0012_consist_published.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.2 on 2024-11-04 12:37
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("consist", "0011_alter_consist_consist_address_alter_consist_era"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="consist",
|
||||||
|
name="published",
|
||||||
|
field=models.BooleanField(default=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-01-08 21:50
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("consist", "0012_consist_published"),
|
||||||
|
("roster", "0030_rollingstock_price"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="consistitem",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("consist", "rolling_stock"), name="one_stock_per_consist"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
ram/consist/migrations/0014_alter_consistitem_order.py
Normal file
18
ram/consist/migrations/0014_alter_consistitem_order.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-01-08 22:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("consist", "0013_consistitem_one_stock_per_consist"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="consistitem",
|
||||||
|
name="order",
|
||||||
|
field=models.PositiveIntegerField(default=1000),
|
||||||
|
),
|
||||||
|
]
|
||||||
19
ram/consist/migrations/0015_consist_description.py
Normal file
19
ram/consist/migrations/0015_consist_description.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-01-27 21:15
|
||||||
|
|
||||||
|
import tinymce.models
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("consist", "0014_alter_consistitem_order"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="consist",
|
||||||
|
name="description",
|
||||||
|
field=tinymce.models.HTMLField(blank=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
ram/consist/migrations/0016_alter_consistitem_order.py
Normal file
18
ram/consist/migrations/0016_alter_consistitem_order.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-04-27 19:53
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("consist", "0015_consist_description"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="consistitem",
|
||||||
|
name="order",
|
||||||
|
field=models.PositiveIntegerField(),
|
||||||
|
),
|
||||||
|
]
|
||||||
42
ram/consist/migrations/0017_consist_scale.py
Normal file
42
ram/consist/migrations/0017_consist_scale.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-05-01 09:51
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def set_scale(apps, schema_editor):
|
||||||
|
Consist = apps.get_model("consist", "Consist")
|
||||||
|
|
||||||
|
for consist in Consist.objects.all():
|
||||||
|
try:
|
||||||
|
consist.scale = consist.consist_item.first().rolling_stock.scale
|
||||||
|
consist.save()
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("consist", "0016_alter_consistitem_order"),
|
||||||
|
(
|
||||||
|
"metadata",
|
||||||
|
"0024_remove_genericdocument_tags_delete_decoderdocument_and_more",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="consist",
|
||||||
|
name="scale",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="metadata.scale",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
set_scale,
|
||||||
|
reverse_code=migrations.RunPython.noop
|
||||||
|
),
|
||||||
|
]
|
||||||
25
ram/consist/migrations/0018_alter_consist_scale.py
Normal file
25
ram/consist/migrations/0018_alter_consist_scale.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-05-02 11:33
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("consist", "0017_consist_scale"),
|
||||||
|
(
|
||||||
|
"metadata",
|
||||||
|
"0024_remove_genericdocument_tags_delete_decoderdocument_and_more",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="consist",
|
||||||
|
name="scale",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, to="metadata.scale"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
ram/consist/migrations/0019_consistitem_load.py
Normal file
18
ram/consist/migrations/0019_consistitem_load.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-01-03 12:31
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("consist", "0018_alter_consist_scale"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="consistitem",
|
||||||
|
name="load",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,34 +1,39 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from uuid import uuid4
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.text import Truncator
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from tinymce import models as tinymce
|
from ram.models import BaseModel
|
||||||
|
|
||||||
from ram.utils import DeduplicatedStorage
|
from ram.utils import DeduplicatedStorage
|
||||||
from metadata.models import Company, Tag
|
from metadata.models import Company, Scale, Tag
|
||||||
from roster.models import RollingStock
|
from roster.models import RollingStock
|
||||||
|
|
||||||
|
|
||||||
class Consist(models.Model):
|
class Consist(BaseModel):
|
||||||
uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
|
||||||
identifier = models.CharField(max_length=128, unique=False)
|
identifier = models.CharField(max_length=128, unique=False)
|
||||||
tags = models.ManyToManyField(Tag, related_name="consist", blank=True)
|
tags = models.ManyToManyField(Tag, related_name="consist", blank=True)
|
||||||
consist_address = models.SmallIntegerField(
|
consist_address = models.SmallIntegerField(
|
||||||
default=None, null=True, blank=True
|
default=None,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="DCC consist address if enabled",
|
||||||
)
|
)
|
||||||
company = models.ForeignKey(Company, on_delete=models.CASCADE)
|
company = models.ForeignKey(Company, on_delete=models.CASCADE)
|
||||||
era = models.CharField(max_length=32, blank=True)
|
era = models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
blank=True,
|
||||||
|
help_text="Era or epoch of the consist",
|
||||||
|
)
|
||||||
|
scale = models.ForeignKey(Scale, on_delete=models.CASCADE)
|
||||||
image = models.ImageField(
|
image = models.ImageField(
|
||||||
upload_to=os.path.join("images", "consists"),
|
upload_to=os.path.join("images", "consists"),
|
||||||
storage=DeduplicatedStorage,
|
storage=DeduplicatedStorage,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
notes = tinymce.HTMLField(blank=True)
|
|
||||||
creation_time = models.DateTimeField(auto_now_add=True)
|
|
||||||
updated_time = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "{0} {1}".format(self.company, self.identifier)
|
return "{0} {1}".format(self.company, self.identifier)
|
||||||
@@ -36,6 +41,34 @@ class Consist(models.Model):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("consist", kwargs={"uuid": self.uuid})
|
return reverse("consist", kwargs={"uuid": self.uuid})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def length(self):
|
||||||
|
return self.consist_item.filter(load=False).count()
|
||||||
|
|
||||||
|
def get_type_count(self):
|
||||||
|
return self.consist_item.filter(load=False).annotate(
|
||||||
|
type=models.F("rolling_stock__rolling_class__type__type")
|
||||||
|
).values(
|
||||||
|
"type"
|
||||||
|
).annotate(
|
||||||
|
count=models.Count("rolling_stock"),
|
||||||
|
category=models.F("rolling_stock__rolling_class__type__category"),
|
||||||
|
order=models.Max("order"),
|
||||||
|
).order_by("order")
|
||||||
|
|
||||||
|
def get_cover(self):
|
||||||
|
if self.image:
|
||||||
|
return self.image
|
||||||
|
else:
|
||||||
|
consist_item = self.consist_item.first()
|
||||||
|
if consist_item and consist_item.rolling_stock.image.exists():
|
||||||
|
return consist_item.rolling_stock.image.first().image
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def country(self):
|
||||||
|
return self.company.country
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["company", "-creation_time"]
|
ordering = ["company", "-creation_time"]
|
||||||
|
|
||||||
@@ -45,22 +78,87 @@ class ConsistItem(models.Model):
|
|||||||
Consist, on_delete=models.CASCADE, related_name="consist_item"
|
Consist, on_delete=models.CASCADE, related_name="consist_item"
|
||||||
)
|
)
|
||||||
rolling_stock = models.ForeignKey(RollingStock, on_delete=models.CASCADE)
|
rolling_stock = models.ForeignKey(RollingStock, on_delete=models.CASCADE)
|
||||||
order = models.PositiveIntegerField(default=0, blank=False, null=False)
|
load = models.BooleanField(default=False)
|
||||||
|
order = models.PositiveIntegerField(blank=False, null=False)
|
||||||
|
|
||||||
class Meta(object):
|
class Meta:
|
||||||
ordering = ["order"]
|
ordering = ["order"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["consist", "rolling_stock"],
|
||||||
|
name="one_stock_per_consist"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "{0}".format(self.rolling_stock)
|
return "{0}".format(self.rolling_stock)
|
||||||
|
|
||||||
def type(self):
|
def clean(self):
|
||||||
return self.rolling_stock.rolling_class.type
|
rolling_stock = getattr(self, "rolling_stock", False)
|
||||||
|
if not rolling_stock:
|
||||||
|
return # exit if no inline are present
|
||||||
|
|
||||||
|
# FIXME this does not work when creating a new consist,
|
||||||
|
# because the consist is not saved yet and it must be moved
|
||||||
|
# to the admin form validation via InlineFormSet.clean()
|
||||||
|
consist = self.consist
|
||||||
|
# Scale must match, but allow loads of any scale
|
||||||
|
if rolling_stock.scale != consist.scale and not self.load:
|
||||||
|
raise ValidationError(
|
||||||
|
"The rolling stock and consist must be of the same scale."
|
||||||
|
)
|
||||||
|
if self.load and rolling_stock.scale.ratio != consist.scale.ratio:
|
||||||
|
raise ValidationError(
|
||||||
|
"The load and consist must be of the same scale ratio."
|
||||||
|
)
|
||||||
|
if self.consist.published and not rolling_stock.published:
|
||||||
|
raise ValidationError(
|
||||||
|
"You must unpublish the the consist before using this item."
|
||||||
|
)
|
||||||
|
|
||||||
|
def published(self):
|
||||||
|
return self.rolling_stock.published
|
||||||
|
published.boolean = True
|
||||||
|
|
||||||
|
def preview(self):
|
||||||
|
return self.rolling_stock.image.first().image_thumbnail(100)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def manufacturer(self):
|
||||||
|
return Truncator(self.rolling_stock.manufacturer).chars(10)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def item_number(self):
|
||||||
|
return self.rolling_stock.item_number
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scale(self):
|
||||||
|
return self.rolling_stock.scale
|
||||||
|
|
||||||
|
@property
|
||||||
|
def type(self):
|
||||||
|
return self.rolling_stock.rolling_class.type.type
|
||||||
|
|
||||||
|
@property
|
||||||
def address(self):
|
def address(self):
|
||||||
return self.rolling_stock.address
|
return self.rolling_stock.address
|
||||||
|
|
||||||
|
@property
|
||||||
def company(self):
|
def company(self):
|
||||||
return self.rolling_stock.company()
|
return self.rolling_stock.company
|
||||||
|
|
||||||
|
@property
|
||||||
def era(self):
|
def era(self):
|
||||||
return self.rolling_stock.era
|
return self.rolling_stock.era
|
||||||
|
|
||||||
|
|
||||||
|
# Unpublish any consist that contains an unpublished rolling stock
|
||||||
|
# this signal is called after a rolling stock is saved
|
||||||
|
# it is hosted here to avoid circular imports
|
||||||
|
@receiver(models.signals.post_save, sender=RollingStock)
|
||||||
|
def post_save_unpublish_consist(sender, instance, *args, **kwargs):
|
||||||
|
if not instance.published:
|
||||||
|
consists = Consist.objects.filter(consist_item__rolling_stock=instance)
|
||||||
|
for consist in consists:
|
||||||
|
consist.published = False
|
||||||
|
consist.save()
|
||||||
|
|||||||
@@ -21,4 +21,5 @@ class ConsistSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Consist
|
model = Consist
|
||||||
fields = "__all__"
|
exclude = ("notes",)
|
||||||
|
read_only_fields = ("creation_time", "updated_time")
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
from rest_framework.generics import ListAPIView, RetrieveAPIView
|
from rest_framework.generics import ListAPIView, RetrieveAPIView
|
||||||
|
|
||||||
|
from ram.views import CustomLimitOffsetPagination
|
||||||
from consist.models import Consist
|
from consist.models import Consist
|
||||||
from consist.serializers import ConsistSerializer
|
from consist.serializers import ConsistSerializer
|
||||||
|
|
||||||
|
|
||||||
class ConsistList(ListAPIView):
|
class ConsistList(ListAPIView):
|
||||||
queryset = Consist.objects.all()
|
|
||||||
serializer_class = ConsistSerializer
|
serializer_class = ConsistSerializer
|
||||||
|
pagination_class = CustomLimitOffsetPagination
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Consist.objects.get_published(self.request.user)
|
||||||
|
|
||||||
|
|
||||||
class ConsistGet(RetrieveAPIView):
|
class ConsistGet(RetrieveAPIView):
|
||||||
queryset = Consist.objects.all()
|
|
||||||
serializer_class = ConsistSerializer
|
serializer_class = ConsistSerializer
|
||||||
lookup_field = "uuid"
|
lookup_field = "uuid"
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return Consist.objects.get_published(self.request.user)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.utils.html import format_html
|
||||||
from adminsortable2.admin import SortableAdminMixin
|
from adminsortable2.admin import SortableAdminMixin
|
||||||
|
|
||||||
|
from repository.models import DecoderDocument
|
||||||
from metadata.models import (
|
from metadata.models import (
|
||||||
Property,
|
Property,
|
||||||
Decoder,
|
Decoder,
|
||||||
DecoderDocument,
|
|
||||||
Scale,
|
Scale,
|
||||||
|
Shop,
|
||||||
Manufacturer,
|
Manufacturer,
|
||||||
Company,
|
Company,
|
||||||
Tag,
|
Tag,
|
||||||
@@ -22,7 +24,7 @@ class PropertyAdmin(admin.ModelAdmin):
|
|||||||
class DecoderDocInline(admin.TabularInline):
|
class DecoderDocInline(admin.TabularInline):
|
||||||
model = DecoderDocument
|
model = DecoderDocument
|
||||||
min_num = 0
|
min_num = 0
|
||||||
extra = 0
|
extra = 1
|
||||||
classes = ["collapse"]
|
classes = ["collapse"]
|
||||||
|
|
||||||
|
|
||||||
@@ -45,18 +47,30 @@ class ScaleAdmin(admin.ModelAdmin):
|
|||||||
@admin.register(Company)
|
@admin.register(Company)
|
||||||
class CompanyAdmin(admin.ModelAdmin):
|
class CompanyAdmin(admin.ModelAdmin):
|
||||||
readonly_fields = ("logo_thumbnail",)
|
readonly_fields = ("logo_thumbnail",)
|
||||||
list_display = ("name", "country")
|
list_display = ("name", "country_flag_name")
|
||||||
list_filter = list_display
|
list_filter = ("name", "country")
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
|
|
||||||
|
@admin.display(description="Country")
|
||||||
|
def country_flag_name(self, obj):
|
||||||
|
return format_html(
|
||||||
|
'<img src="{}" /> {}', obj.country.flag, obj.country.name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Manufacturer)
|
@admin.register(Manufacturer)
|
||||||
class ManufacturerAdmin(admin.ModelAdmin):
|
class ManufacturerAdmin(admin.ModelAdmin):
|
||||||
readonly_fields = ("logo_thumbnail",)
|
readonly_fields = ("logo_thumbnail",)
|
||||||
list_display = ("name", "category")
|
list_display = ("name", "category", "country_flag_name")
|
||||||
list_filter = ("category",)
|
list_filter = ("category",)
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
|
|
||||||
|
@admin.display(description="Country")
|
||||||
|
def country_flag_name(self, obj):
|
||||||
|
return format_html(
|
||||||
|
'<img src="{}" /> {}', obj.country.flag, obj.country.name
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Tag)
|
@admin.register(Tag)
|
||||||
class TagAdmin(admin.ModelAdmin):
|
class TagAdmin(admin.ModelAdmin):
|
||||||
@@ -70,3 +84,16 @@ class RollingStockTypeAdmin(SortableAdminMixin, admin.ModelAdmin):
|
|||||||
list_display = ("__str__",)
|
list_display = ("__str__",)
|
||||||
list_filter = ("type", "category")
|
list_filter = ("type", "category")
|
||||||
search_fields = ("type", "category")
|
search_fields = ("type", "category")
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Shop)
|
||||||
|
class ShopAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name", "on_line", "active", "country_flag_name")
|
||||||
|
list_filter = ("on_line", "active")
|
||||||
|
search_fields = ("name",)
|
||||||
|
|
||||||
|
@admin.display(description="Country")
|
||||||
|
def country_flag_name(self, obj):
|
||||||
|
return format_html(
|
||||||
|
'<img src="{}" /> {}', obj.country.flag, obj.country.name
|
||||||
|
)
|
||||||
|
|||||||
20
ram/metadata/migrations/0017_alter_property_private.py
Normal file
20
ram/metadata/migrations/0017_alter_property_private.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 5.0.4 on 2024-04-20 12:49
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("metadata", "0016_alter_decoderdocument_file"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="property",
|
||||||
|
name="private",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False, help_text="Property will be only visible to logged users"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Generated by Django 5.1.2 on 2024-11-04 21:17
|
||||||
|
|
||||||
|
import django.db.migrations.operations.special
|
||||||
|
import metadata.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def gen_ratio(apps, schema_editor):
|
||||||
|
Scale = apps.get_model('metadata', 'Scale')
|
||||||
|
for row in Scale.objects.all():
|
||||||
|
row.ratio_int = metadata.models.calculate_ratio(row.ratio)
|
||||||
|
row.save(update_fields=['ratio_int'])
|
||||||
|
|
||||||
|
|
||||||
|
def convert_tarcks(apps, schema_editor):
|
||||||
|
Scale = apps.get_model("metadata", "Scale")
|
||||||
|
for row in Scale.objects.all():
|
||||||
|
row.tracks = "".join(
|
||||||
|
filter(
|
||||||
|
lambda x: str.isdigit(x) or x == "." or x == ",",
|
||||||
|
row.tracks
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row.save(update_fields=["tracks"])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('metadata', '0017_alter_property_private'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='decoder',
|
||||||
|
options={'ordering': ['manufacturer__name', 'name']},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='scale',
|
||||||
|
options={'ordering': ['ratio_int', 'scale']},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='scale',
|
||||||
|
name='ratio_int',
|
||||||
|
field=models.SmallIntegerField(default=0, editable=False),
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
code=gen_ratio,
|
||||||
|
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='scale',
|
||||||
|
name='ratio',
|
||||||
|
field=models.CharField(max_length=16, validators=[metadata.models.calculate_ratio]),
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='scale',
|
||||||
|
options={'ordering': ['-ratio_int', '-tracks', 'scale']},
|
||||||
|
),
|
||||||
|
migrations.RunPython(
|
||||||
|
code=convert_tarcks,
|
||||||
|
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='scale',
|
||||||
|
name='tracks',
|
||||||
|
field=models.FloatField(help_text='Distance between model tracks in mm'),
|
||||||
|
),
|
||||||
|
]
|
||||||
22
ram/metadata/migrations/0019_alter_scale_gauge.py
Normal file
22
ram/metadata/migrations/0019_alter_scale_gauge.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 5.1.2 on 2024-11-04 21:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("metadata", "0018_alter_decoder_options_alter_scale_options_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="scale",
|
||||||
|
name="gauge",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Distance between real tracks. Please specify the unit (mm, in, ...)",
|
||||||
|
max_length=16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-01-08 22:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("metadata", "0019_alter_scale_gauge"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="decoderdocument",
|
||||||
|
unique_together=set(),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="rollingstocktype",
|
||||||
|
unique_together=set(),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="decoderdocument",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("decoder", "file"), name="unique_decoder_file"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="rollingstocktype",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("category", "type"), name="unique_category_type"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
40
ram/metadata/migrations/0021_genericdocument.py
Normal file
40
ram/metadata/migrations/0021_genericdocument.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-01-17 09:31
|
||||||
|
|
||||||
|
import ram.utils
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("metadata", "0020_alter_decoderdocument_unique_together_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="GenericDocument",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("description", models.CharField(blank=True, max_length=128)),
|
||||||
|
(
|
||||||
|
"file",
|
||||||
|
models.FileField(
|
||||||
|
storage=ram.utils.DeduplicatedStorage(), upload_to="files/"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("private", models.BooleanField(default=False)),
|
||||||
|
("tags", models.ManyToManyField(blank=True, to="metadata.tag")),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name_plural": "Generic Documents",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-01-18 11:20
|
||||||
|
|
||||||
|
import django.utils.timezone
|
||||||
|
import django_countries.fields
|
||||||
|
import tinymce.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("metadata", "0021_genericdocument"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="decoderdocument",
|
||||||
|
name="creation_time",
|
||||||
|
field=models.DateTimeField(
|
||||||
|
auto_now_add=True, default=django.utils.timezone.now
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="decoderdocument",
|
||||||
|
name="updated_time",
|
||||||
|
field=models.DateTimeField(auto_now=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="genericdocument",
|
||||||
|
name="creation_time",
|
||||||
|
field=models.DateTimeField(
|
||||||
|
auto_now_add=True, default=django.utils.timezone.now
|
||||||
|
),
|
||||||
|
preserve_default=False,
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="genericdocument",
|
||||||
|
name="notes",
|
||||||
|
field=tinymce.models.HTMLField(blank=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="genericdocument",
|
||||||
|
name="updated_time",
|
||||||
|
field=models.DateTimeField(auto_now=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="manufacturer",
|
||||||
|
name="country",
|
||||||
|
field=django_countries.fields.CountryField(blank=True, max_length=2),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="decoderdocument",
|
||||||
|
name="private",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False, help_text="Document will be visible only to logged users"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="genericdocument",
|
||||||
|
name="private",
|
||||||
|
field=models.BooleanField(
|
||||||
|
default=False, help_text="Document will be visible only to logged users"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
40
ram/metadata/migrations/0023_shop.py
Normal file
40
ram/metadata/migrations/0023_shop.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-01-26 14:27
|
||||||
|
|
||||||
|
import django_countries.fields
|
||||||
|
import django.db.models.functions.text
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("metadata", "0022_decoderdocument_creation_time_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Shop",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=128, unique=True)),
|
||||||
|
(
|
||||||
|
"country",
|
||||||
|
django_countries.fields.CountryField(blank=True, max_length=2),
|
||||||
|
),
|
||||||
|
("website", models.URLField(blank=True)),
|
||||||
|
("on_line", models.BooleanField(default=True)),
|
||||||
|
("active", models.BooleanField(default=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": [django.db.models.functions.text.Lower("name")],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-02-09 13:47
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("metadata", "0023_shop"),
|
||||||
|
("repository", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name="genericdocument",
|
||||||
|
name="tags",
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="DecoderDocument",
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="GenericDocument",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-05-04 20:45
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"metadata",
|
||||||
|
"0024_remove_genericdocument_tags_delete_decoderdocument_and_more",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="company",
|
||||||
|
options={"ordering": ["slug"], "verbose_name_plural": "Companies"},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="manufacturer",
|
||||||
|
options={"ordering": ["category", "slug"]},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="tag",
|
||||||
|
options={"ordering": ["slug"]},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-01-09 12:08
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("metadata", "0025_alter_company_options_alter_manufacturer_options_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="manufacturer",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(max_length=128),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="manufacturer",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("name", "category"), name="unique_name_category"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,17 +1,23 @@
|
|||||||
import os
|
import os
|
||||||
|
from urllib.parse import urlparse
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.dispatch.dispatcher import receiver
|
from django.dispatch.dispatcher import receiver
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django_countries.fields import CountryField
|
from django_countries.fields import CountryField
|
||||||
|
|
||||||
from ram.models import Document
|
from ram.models import SimpleBaseModel
|
||||||
from ram.utils import DeduplicatedStorage, get_image_preview, slugify
|
from ram.utils import DeduplicatedStorage, get_image_preview, slugify
|
||||||
|
from ram.managers import PublicManager
|
||||||
|
|
||||||
|
|
||||||
class Property(models.Model):
|
class Property(SimpleBaseModel):
|
||||||
name = models.CharField(max_length=128, unique=True)
|
name = models.CharField(max_length=128, unique=True)
|
||||||
private = models.BooleanField(default=False)
|
private = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Property will be only visible to logged users",
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name_plural = "Properties"
|
verbose_name_plural = "Properties"
|
||||||
@@ -20,13 +26,16 @@ class Property(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
objects = PublicManager()
|
||||||
|
|
||||||
class Manufacturer(models.Model):
|
|
||||||
name = models.CharField(max_length=128, unique=True)
|
class Manufacturer(SimpleBaseModel):
|
||||||
|
name = models.CharField(max_length=128)
|
||||||
slug = models.CharField(max_length=128, unique=True, editable=False)
|
slug = models.CharField(max_length=128, unique=True, editable=False)
|
||||||
category = models.CharField(
|
category = models.CharField(
|
||||||
max_length=64, choices=settings.MANUFACTURER_TYPES
|
max_length=64, choices=settings.MANUFACTURER_TYPES
|
||||||
)
|
)
|
||||||
|
country = CountryField(blank=True)
|
||||||
website = models.URLField(blank=True)
|
website = models.URLField(blank=True)
|
||||||
logo = models.ImageField(
|
logo = models.ImageField(
|
||||||
upload_to=os.path.join("images", "manufacturers"),
|
upload_to=os.path.join("images", "manufacturers"),
|
||||||
@@ -36,26 +45,37 @@ class Manufacturer(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["category", "name"]
|
ordering = ["category", "slug"]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["name", "category"],
|
||||||
|
name="unique_name_category"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse(
|
return reverse(
|
||||||
"filtered", kwargs={
|
"filtered",
|
||||||
|
kwargs={
|
||||||
"_filter": "manufacturer",
|
"_filter": "manufacturer",
|
||||||
"search": self.slug,
|
"search": self.slug,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def website_short(self):
|
||||||
|
if self.website:
|
||||||
|
return urlparse(self.website).netloc.replace("www.", "")
|
||||||
|
|
||||||
def logo_thumbnail(self):
|
def logo_thumbnail(self):
|
||||||
return get_image_preview(self.logo.url)
|
return get_image_preview(self.logo.url)
|
||||||
|
|
||||||
logo_thumbnail.short_description = "Preview"
|
logo_thumbnail.short_description = "Preview"
|
||||||
|
|
||||||
|
|
||||||
class Company(models.Model):
|
class Company(SimpleBaseModel):
|
||||||
name = models.CharField(max_length=64, unique=True)
|
name = models.CharField(max_length=64, unique=True)
|
||||||
slug = models.CharField(max_length=64, unique=True, editable=False)
|
slug = models.CharField(max_length=64, unique=True, editable=False)
|
||||||
extended_name = models.CharField(max_length=128, blank=True)
|
extended_name = models.CharField(max_length=128, blank=True)
|
||||||
@@ -70,17 +90,18 @@ class Company(models.Model):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name_plural = "Companies"
|
verbose_name_plural = "Companies"
|
||||||
ordering = ["name"]
|
ordering = ["slug"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse(
|
return reverse(
|
||||||
"filtered", kwargs={
|
"filtered",
|
||||||
|
kwargs={
|
||||||
"_filter": "company",
|
"_filter": "company",
|
||||||
"search": self.slug,
|
"search": self.slug,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def extended_name_pp(self):
|
def extended_name_pp(self):
|
||||||
@@ -92,7 +113,7 @@ class Company(models.Model):
|
|||||||
logo_thumbnail.short_description = "Preview"
|
logo_thumbnail.short_description = "Preview"
|
||||||
|
|
||||||
|
|
||||||
class Decoder(models.Model):
|
class Decoder(SimpleBaseModel):
|
||||||
name = models.CharField(max_length=128, unique=True)
|
name = models.CharField(max_length=128, unique=True)
|
||||||
manufacturer = models.ForeignKey(
|
manufacturer = models.ForeignKey(
|
||||||
Manufacturer,
|
Manufacturer,
|
||||||
@@ -108,8 +129,8 @@ class Decoder(models.Model):
|
|||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(object):
|
class Meta:
|
||||||
ordering = ["manufacturer", "name"]
|
ordering = ["manufacturer__name", "name"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "{0} - {1}".format(self.manufacturer, self.name)
|
return "{0} - {1}".format(self.manufacturer, self.name)
|
||||||
@@ -120,38 +141,50 @@ class Decoder(models.Model):
|
|||||||
image_thumbnail.short_description = "Preview"
|
image_thumbnail.short_description = "Preview"
|
||||||
|
|
||||||
|
|
||||||
class DecoderDocument(Document):
|
def calculate_ratio(ratio):
|
||||||
decoder = models.ForeignKey(
|
try:
|
||||||
Decoder, on_delete=models.CASCADE, related_name="document"
|
num, den = ratio.split(":")
|
||||||
|
return int(num) / float(den) * 10000
|
||||||
|
except (ValueError, ZeroDivisionError):
|
||||||
|
raise ValidationError("Invalid ratio format")
|
||||||
|
|
||||||
|
|
||||||
|
class Scale(SimpleBaseModel):
|
||||||
|
scale = models.CharField(max_length=32, unique=True)
|
||||||
|
slug = models.CharField(max_length=32, unique=True, editable=False)
|
||||||
|
ratio = models.CharField(max_length=16, validators=[calculate_ratio])
|
||||||
|
ratio_int = models.SmallIntegerField(editable=False, default=0)
|
||||||
|
tracks = models.FloatField(
|
||||||
|
help_text="Distance between model tracks in mm",
|
||||||
|
)
|
||||||
|
gauge = models.CharField(
|
||||||
|
max_length=16,
|
||||||
|
blank=True,
|
||||||
|
help_text="Distance between real tracks. Please specify the unit (mm, in, ...)", # noqa: E501
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ("decoder", "file")
|
ordering = ["-ratio_int", "-tracks", "scale"]
|
||||||
|
|
||||||
|
|
||||||
class Scale(models.Model):
|
|
||||||
scale = models.CharField(max_length=32, unique=True)
|
|
||||||
slug = models.CharField(max_length=32, unique=True, editable=False)
|
|
||||||
ratio = models.CharField(max_length=16, blank=True)
|
|
||||||
gauge = models.CharField(max_length=16, blank=True)
|
|
||||||
tracks = models.CharField(max_length=16, blank=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["scale"]
|
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse(
|
return reverse(
|
||||||
"filtered", kwargs={
|
"filtered",
|
||||||
|
kwargs={
|
||||||
"_filter": "scale",
|
"_filter": "scale",
|
||||||
"search": self.slug,
|
"search": self.slug,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return str(self.scale)
|
return str(self.scale)
|
||||||
|
|
||||||
|
|
||||||
class RollingStockType(models.Model):
|
@receiver(models.signals.pre_save, sender=Scale)
|
||||||
|
def scale_save(sender, instance, **kwargs):
|
||||||
|
instance.ratio_int = calculate_ratio(instance.ratio)
|
||||||
|
|
||||||
|
|
||||||
|
class RollingStockType(SimpleBaseModel):
|
||||||
type = models.CharField(max_length=64)
|
type = models.CharField(max_length=64)
|
||||||
order = models.PositiveSmallIntegerField()
|
order = models.PositiveSmallIntegerField()
|
||||||
category = models.CharField(
|
category = models.CharField(
|
||||||
@@ -159,41 +192,62 @@ class RollingStockType(models.Model):
|
|||||||
)
|
)
|
||||||
slug = models.CharField(max_length=128, unique=True, editable=False)
|
slug = models.CharField(max_length=128, unique=True, editable=False)
|
||||||
|
|
||||||
class Meta(object):
|
class Meta:
|
||||||
unique_together = ("category", "type")
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["category", "type"],
|
||||||
|
name="unique_category_type"
|
||||||
|
)
|
||||||
|
]
|
||||||
ordering = ["order"]
|
ordering = ["order"]
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse(
|
return reverse(
|
||||||
"filtered", kwargs={
|
"filtered",
|
||||||
|
kwargs={
|
||||||
"_filter": "type",
|
"_filter": "type",
|
||||||
"search": self.slug,
|
"search": self.slug,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "{0} {1}".format(self.type, self.category)
|
return "{0} {1}".format(self.type, self.category)
|
||||||
|
|
||||||
|
|
||||||
class Tag(models.Model):
|
class Tag(SimpleBaseModel):
|
||||||
name = models.CharField(max_length=128, unique=True)
|
name = models.CharField(max_length=128, unique=True)
|
||||||
slug = models.CharField(max_length=128, unique=True)
|
slug = models.CharField(max_length=128, unique=True)
|
||||||
|
|
||||||
class Meta(object):
|
class Meta:
|
||||||
ordering = ["name"]
|
ordering = ["slug"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse(
|
return reverse(
|
||||||
"filtered", kwargs={
|
"filtered",
|
||||||
|
kwargs={
|
||||||
"_filter": "tag",
|
"_filter": "tag",
|
||||||
"search": self.slug,
|
"search": self.slug,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Shop(SimpleBaseModel):
|
||||||
|
name = models.CharField(max_length=128, unique=True)
|
||||||
|
country = CountryField(blank=True)
|
||||||
|
website = models.URLField(blank=True)
|
||||||
|
on_line = models.BooleanField(default=True)
|
||||||
|
active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = [models.functions.Lower("name"),]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
@receiver(models.signals.pre_save, sender=Manufacturer)
|
@receiver(models.signals.pre_save, sender=Manufacturer)
|
||||||
@receiver(models.signals.pre_save, sender=Company)
|
@receiver(models.signals.pre_save, sender=Company)
|
||||||
@receiver(models.signals.pre_save, sender=Scale)
|
@receiver(models.signals.pre_save, sender=Scale)
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
from django.conf import settings
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from solo.admin import SingletonModelAdmin
|
from solo.admin import SingletonModelAdmin
|
||||||
|
from tinymce.widgets import TinyMCE
|
||||||
|
|
||||||
|
from ram.admin import publish, unpublish
|
||||||
from portal.models import SiteConfiguration, Flatpage
|
from portal.models import SiteConfiguration, Flatpage
|
||||||
|
|
||||||
|
|
||||||
@admin.register(SiteConfiguration)
|
@admin.register(SiteConfiguration)
|
||||||
class SiteConfigurationAdmin(SingletonModelAdmin):
|
class SiteConfigurationAdmin(SingletonModelAdmin):
|
||||||
readonly_fields = ("site_name",)
|
readonly_fields = ("site_name", "rest_api", "version")
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
@@ -17,8 +20,11 @@ class SiteConfigurationAdmin(SingletonModelAdmin):
|
|||||||
"about",
|
"about",
|
||||||
"items_per_page",
|
"items_per_page",
|
||||||
"items_ordering",
|
"items_ordering",
|
||||||
|
"featured_items_ordering",
|
||||||
|
"currency",
|
||||||
"footer",
|
"footer",
|
||||||
"footer_extended",
|
"footer_extended",
|
||||||
|
"disclaimer",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -29,12 +35,32 @@ class SiteConfigurationAdmin(SingletonModelAdmin):
|
|||||||
"fields": (
|
"fields": (
|
||||||
"show_version",
|
"show_version",
|
||||||
"use_cdn",
|
"use_cdn",
|
||||||
"extra_head",
|
"extra_html",
|
||||||
|
"extra_js",
|
||||||
|
"rest_api",
|
||||||
|
"version",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@admin.display(description="REST API enabled", boolean=True)
|
||||||
|
def rest_api(self, obj):
|
||||||
|
return settings.REST_ENABLED
|
||||||
|
|
||||||
|
@admin.display()
|
||||||
|
def version(self, obj):
|
||||||
|
return "{} (Django {})".format(obj.version, obj.django_version)
|
||||||
|
|
||||||
|
def formfield_for_dbfield(self, db_field, **kwargs):
|
||||||
|
if db_field.name in ("footer", "footer_extended", "disclaimer"):
|
||||||
|
return db_field.formfield(
|
||||||
|
widget=TinyMCE(
|
||||||
|
mce_attrs={"height": "200"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return super().formfield_for_dbfield(db_field, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Flatpage)
|
@admin.register(Flatpage)
|
||||||
class FlatpageAdmin(admin.ModelAdmin):
|
class FlatpageAdmin(admin.ModelAdmin):
|
||||||
@@ -66,3 +92,4 @@ class FlatpageAdmin(admin.ModelAdmin):
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
actions = [publish, unpublish]
|
||||||
|
|||||||
21
ram/portal/migrations/0018_siteconfiguration_currency.py
Normal file
21
ram/portal/migrations/0018_siteconfiguration_currency.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2024-12-29 15:18
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
(
|
||||||
|
"portal",
|
||||||
|
"0017_alter_flatpage_content_alter_siteconfiguration_about_and_more",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="siteconfiguration",
|
||||||
|
name="currency",
|
||||||
|
field=models.CharField(default="EUR", max_length=3),
|
||||||
|
),
|
||||||
|
]
|
||||||
19
ram/portal/migrations/0019_siteconfiguration_disclaimer.py
Normal file
19
ram/portal/migrations/0019_siteconfiguration_disclaimer.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-01-30 16:39
|
||||||
|
|
||||||
|
import tinymce.models
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("portal", "0018_siteconfiguration_currency"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="siteconfiguration",
|
||||||
|
name="disclaimer",
|
||||||
|
field=tinymce.models.HTMLField(blank=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
17
ram/portal/migrations/0020_alter_flatpage_options.py
Normal file
17
ram/portal/migrations/0020_alter_flatpage_options.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.1.4 on 2025-02-01 23:26
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("portal", "0019_siteconfiguration_disclaimer"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="flatpage",
|
||||||
|
options={"verbose_name": "page", "verbose_name_plural": "pages"},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-01-02 23:41
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("portal", "0020_alter_flatpage_options"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="siteconfiguration",
|
||||||
|
name="featured_items_ordering",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("type", "By rolling stock type and company"),
|
||||||
|
("class", "By rolling stock type and class"),
|
||||||
|
("company", "By company and type"),
|
||||||
|
("country", "By country and type"),
|
||||||
|
("cou+com", "By country and company"),
|
||||||
|
],
|
||||||
|
default="type",
|
||||||
|
max_length=11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="siteconfiguration",
|
||||||
|
name="items_ordering",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("type", "By rolling stock type and company"),
|
||||||
|
("class", "By rolling stock type and class"),
|
||||||
|
("company", "By company and type"),
|
||||||
|
("country", "By country and type"),
|
||||||
|
("cou+com", "By country and company"),
|
||||||
|
],
|
||||||
|
default="type",
|
||||||
|
max_length=11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-01-15 11:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("portal", "0021_siteconfiguration_featured_items_ordering_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name="siteconfiguration",
|
||||||
|
old_name="extra_head",
|
||||||
|
new_name="extra_html",
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="siteconfiguration",
|
||||||
|
name="extra_html",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Extra HTML to be dinamically loaded into the site.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="siteconfiguration",
|
||||||
|
name="extra_js",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Extra JS to be dinamically loaded into the site."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -9,6 +9,7 @@ from solo.models import SingletonModel
|
|||||||
from tinymce import models as tinymce
|
from tinymce import models as tinymce
|
||||||
|
|
||||||
from ram import __version__ as app_version
|
from ram import __version__ as app_version
|
||||||
|
from ram.managers import PublicManager
|
||||||
from ram.utils import slugify
|
from ram.utils import slugify
|
||||||
|
|
||||||
|
|
||||||
@@ -21,19 +22,31 @@ class SiteConfiguration(SingletonModel):
|
|||||||
default="6",
|
default="6",
|
||||||
)
|
)
|
||||||
items_ordering = models.CharField(
|
items_ordering = models.CharField(
|
||||||
max_length=10,
|
max_length=11,
|
||||||
choices=[
|
choices=[
|
||||||
("type", "By rolling stock type"),
|
("type", "By rolling stock type and company"),
|
||||||
("company", "By company name"),
|
("class", "By rolling stock type and class"),
|
||||||
("identifier", "By rolling stock class"),
|
("company", "By company and type"),
|
||||||
|
("country", "By country and type"),
|
||||||
|
("cou+com", "By country and company"),
|
||||||
],
|
],
|
||||||
default="type",
|
default="type",
|
||||||
)
|
)
|
||||||
|
featured_items_ordering = items_ordering.clone()
|
||||||
|
currency = models.CharField(max_length=3, default="EUR")
|
||||||
footer = tinymce.HTMLField(blank=True)
|
footer = tinymce.HTMLField(blank=True)
|
||||||
footer_extended = tinymce.HTMLField(blank=True)
|
footer_extended = tinymce.HTMLField(blank=True)
|
||||||
|
disclaimer = tinymce.HTMLField(blank=True)
|
||||||
show_version = models.BooleanField(default=True)
|
show_version = models.BooleanField(default=True)
|
||||||
use_cdn = models.BooleanField(default=True)
|
use_cdn = models.BooleanField(default=True)
|
||||||
extra_head = models.TextField(blank=True)
|
extra_html = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Extra HTML to be dinamically loaded into the site.",
|
||||||
|
)
|
||||||
|
extra_js = models.TextField(
|
||||||
|
blank=True,
|
||||||
|
help_text="Extra JS to be dinamically loaded into the site.",
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Site Configuration"
|
verbose_name = "Site Configuration"
|
||||||
@@ -44,9 +57,11 @@ class SiteConfiguration(SingletonModel):
|
|||||||
def site_name(self):
|
def site_name(self):
|
||||||
return settings.SITE_NAME
|
return settings.SITE_NAME
|
||||||
|
|
||||||
|
@property
|
||||||
def version(self):
|
def version(self):
|
||||||
return app_version
|
return app_version
|
||||||
|
|
||||||
|
@property
|
||||||
def django_version(self):
|
def django_version(self):
|
||||||
return django.get_version()
|
return django.get_version()
|
||||||
|
|
||||||
@@ -59,6 +74,10 @@ class Flatpage(models.Model):
|
|||||||
creation_time = models.DateTimeField(auto_now_add=True)
|
creation_time = models.DateTimeField(auto_now_add=True)
|
||||||
updated_time = models.DateTimeField(auto_now=True)
|
updated_time = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "page"
|
||||||
|
verbose_name_plural = "pages"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
@@ -72,6 +91,8 @@ class Flatpage(models.Model):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
objects = PublicManager()
|
||||||
|
|
||||||
|
|
||||||
@receiver(models.signals.pre_save, sender=Flatpage)
|
@receiver(models.signals.pre_save, sender=Flatpage)
|
||||||
def tag_pre_save(sender, instance, **kwargs):
|
def tag_pre_save(sender, instance, **kwargs):
|
||||||
|
|||||||
Binary file not shown.
@@ -1,14 +1,14 @@
|
|||||||
/*!
|
/*!
|
||||||
* Bootstrap Icons v1.11.1 (https://icons.getbootstrap.com/)
|
* Bootstrap Icons v1.13.1 (https://icons.getbootstrap.com/)
|
||||||
* Copyright 2019-2023 The Bootstrap Authors
|
* Copyright 2019-2024 The Bootstrap Authors
|
||||||
* Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE)
|
* Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-display: block;
|
font-display: block;
|
||||||
font-family: "bootstrap-icons";
|
font-family: "bootstrap-icons";
|
||||||
src: url("./fonts/bootstrap-icons.woff2?2820a3852bdb9a5832199cc61cec4e65") format("woff2"),
|
src: url("./fonts/bootstrap-icons.woff2?e34853135f9e39acf64315236852cd5a") format("woff2"),
|
||||||
url("./fonts/bootstrap-icons.woff?2820a3852bdb9a5832199cc61cec4e65") format("woff");
|
url("./fonts/bootstrap-icons.woff?e34853135f9e39acf64315236852cd5a") format("woff");
|
||||||
}
|
}
|
||||||
|
|
||||||
.bi::before,
|
.bi::before,
|
||||||
@@ -2076,3 +2076,31 @@ url("./fonts/bootstrap-icons.woff?2820a3852bdb9a5832199cc61cec4e65") format("wof
|
|||||||
.bi-suitcase2-fill::before { content: "\f901"; }
|
.bi-suitcase2-fill::before { content: "\f901"; }
|
||||||
.bi-suitcase2::before { content: "\f902"; }
|
.bi-suitcase2::before { content: "\f902"; }
|
||||||
.bi-vignette::before { content: "\f903"; }
|
.bi-vignette::before { content: "\f903"; }
|
||||||
|
.bi-bluesky::before { content: "\f7f9"; }
|
||||||
|
.bi-tux::before { content: "\f904"; }
|
||||||
|
.bi-beaker-fill::before { content: "\f905"; }
|
||||||
|
.bi-beaker::before { content: "\f906"; }
|
||||||
|
.bi-flask-fill::before { content: "\f907"; }
|
||||||
|
.bi-flask-florence-fill::before { content: "\f908"; }
|
||||||
|
.bi-flask-florence::before { content: "\f909"; }
|
||||||
|
.bi-flask::before { content: "\f90a"; }
|
||||||
|
.bi-leaf-fill::before { content: "\f90b"; }
|
||||||
|
.bi-leaf::before { content: "\f90c"; }
|
||||||
|
.bi-measuring-cup-fill::before { content: "\f90d"; }
|
||||||
|
.bi-measuring-cup::before { content: "\f90e"; }
|
||||||
|
.bi-unlock2-fill::before { content: "\f90f"; }
|
||||||
|
.bi-unlock2::before { content: "\f910"; }
|
||||||
|
.bi-battery-low::before { content: "\f911"; }
|
||||||
|
.bi-anthropic::before { content: "\f912"; }
|
||||||
|
.bi-apple-music::before { content: "\f913"; }
|
||||||
|
.bi-claude::before { content: "\f914"; }
|
||||||
|
.bi-openai::before { content: "\f915"; }
|
||||||
|
.bi-perplexity::before { content: "\f916"; }
|
||||||
|
.bi-css::before { content: "\f917"; }
|
||||||
|
.bi-javascript::before { content: "\f918"; }
|
||||||
|
.bi-typescript::before { content: "\f919"; }
|
||||||
|
.bi-fork-knife::before { content: "\f91a"; }
|
||||||
|
.bi-globe-americas-fill::before { content: "\f91b"; }
|
||||||
|
.bi-globe-asia-australia-fill::before { content: "\f91c"; }
|
||||||
|
.bi-globe-central-south-asia-fill::before { content: "\f91d"; }
|
||||||
|
.bi-globe-europe-africa-fill::before { content: "\f91e"; }
|
||||||
Binary file not shown.
Binary file not shown.
6
ram/portal/static/bootstrap@5.3.8/dist/css/bootstrap.min.css
vendored
Normal file
6
ram/portal/static/bootstrap@5.3.8/dist/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
ram/portal/static/bootstrap@5.3.8/dist/css/bootstrap.min.css.map
vendored
Normal file
1
ram/portal/static/bootstrap@5.3.8/dist/css/bootstrap.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
7
ram/portal/static/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js
vendored
Normal file
7
ram/portal/static/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
ram/portal/static/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js.map
vendored
Normal file
1
ram/portal/static/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
ram/portal/static/css/main.min.css
vendored
Normal file
1
ram/portal/static/css/main.min.css
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
html[data-bs-theme=dark] .navbar svg{fill:#fff}.card>a>img{width:100%}td>img.logo{max-width:200px;max-height:48px}td>img.logo-xl{max-width:400px;max-height:96px}td>p:last-child{margin-bottom:0}.btn>span{display:inline-block}a.badge,a.badge:hover{text-decoration:none;color:#fff}.img-thumbnail{padding:0}.w-33{width:33%!important}.table-group-divider{border-top:calc(var(--bs-border-width) * 3) solid var(--bs-border-color)}#nav-journal ol,#nav-journal ul{padding-left:1rem}#nav-journal ol:last-child,#nav-journal p:last-child,#nav-journal ul:last-child{margin-bottom:0}#footer>p{display:inline}
|
||||||
7
ram/portal/static/css/src/README.md
Normal file
7
ram/portal/static/css/src/README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Compile main.min.css
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install clean-css-cli
|
||||||
|
$ npx cleancss -o ../main.min.css main.css
|
||||||
|
```
|
||||||
|
|
||||||
@@ -17,6 +17,11 @@ td > img.logo-xl {
|
|||||||
max-height: 96px;
|
max-height: 96px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Disable margin on last <p> in a <td> */
|
||||||
|
td > p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.btn > span {
|
.btn > span {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
@@ -38,17 +43,15 @@ a.badge, a.badge:hover {
|
|||||||
border-top: calc(var(--bs-border-width) * 3) solid var(--bs-border-color);
|
border-top: calc(var(--bs-border-width) * 3) solid var(--bs-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
#nav-notes > p {
|
#nav-journal ul,
|
||||||
padding: .5rem;
|
#nav-journal ol {
|
||||||
}
|
|
||||||
|
|
||||||
#nav-journal ul, #nav-journal ol {
|
|
||||||
margin: 0;
|
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#nav-journal p {
|
#nav-journal p:last-child,
|
||||||
margin: 0;
|
#nav-journal ul:last-child,
|
||||||
|
#nav-journal ol:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
#footer > p {
|
#footer > p {
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 5.2 KiB |
@@ -1,9 +1,7 @@
|
|||||||
<svg width="26" height="16" enable-background="new 0 0 26 26" version="1" viewBox="0 0 26 16" xmlns="http://www.w3.org/2000/svg">
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<path d="m2.8125 0.0010991a1.0001 1.0001 0 0 0-0.8125 1c0 0.55455-0.44545 1-1 1a1.0001 1.0001 0 0 0-1 1v10a1.0001 1.0001 0 0 0 1 1c0.55455 0 1 0.44546 1 1a1.0001 1.0001 0 0 0 1 1h20a1.0001 1.0001 0 0 0 1-1c0-0.55454 0.44546-1 1-1a1.0001 1.0001 0 0 0 1-1v-10a1.0001 1.0001 0 0 0-1-1c-0.55454 0-1-0.44545-1-1a1.0001 1.0001 0 0 0-1-1h-20a1.0001 1.0001 0 0 0-0.09375 0 1.0001 1.0001 0 0 0-0.09375 0zm0.78125 2h14.406v1h2v-1h2.4062c0.30628 0.76906 0.82469 1.2875 1.5938 1.5938v8.8125c-0.76906 0.30628-1.2875 0.82469-1.5938 1.5938h-2.4062v-1h-2v1h-14.406c-0.30628-0.76906-0.82469-1.2875-1.5938-1.5938v-8.8125c0.76906-0.30628 1.2875-0.82469 1.5938-1.5938zm14.406 2v2h2v-2zm0 3v2h2v-2zm0 3v2h2v-2z" enable-background="accumulate" overflow="visible" stroke-width="2" />
|
<svg width="32" height="16" preserveAspectRatio="xMidYMid" version="1.0" viewBox="0 0 24 12" xmlns="http://www.w3.org/2000/svg">
|
||||||
<style>
|
<metadata>Created by potrace 1.15, written by Peter Selinger 2001-2017</metadata>
|
||||||
path {
|
<g transform="matrix(.0039261 0 0 -.0039261 -1.4249 18.53)">
|
||||||
text-indent:0;
|
<path d="m813 4723-103-4v-309h-355l14-330h369l6-42c39-273 39-1414 0-1659l-7-39h-368l-14-330h355v-318l41-7c23-4 126-7 229-7s206 3 229 7l41 7v318h670v-318l41-7c23-4 126-7 229-7s206 3 229 7l41 7v318h670v-318l37-7c48-9 432-9 472 0l31 7v318h680v-318l31-7c39-9 423-9 469 0l35 6v314l338 3 337 2v-318l38-7c48-9 416-9 465 0l37 7v318h335v2400h-335v307l-135 6c-74 3-196 3-270 0l-135-6v-307l-337 2-338 3v302l-132 6c-73 3-194 3-268 0l-135-6v-307h-680v307l-135 6c-74 3-196 3-270 0l-135-6v-307h-670v307l-135 6c-74 3-196 3-270 0l-135-6v-307h-670v310h-63c-35 0-111 2-168 4s-150 1-206-1zm1141-666c3-12 11-97 18-187 24-309 11-1402-18-1507l-6-23h-725l-7 32c-39 197-39 1454 0 1676l6 32h726zm1218-42c20-182 30-569 25-940-6-371-21-707-33-727-3-4-169-8-368-8h-363l-7 48c-38 277-38 1365 1 1647l6 45 366-2 366-3zm1203 53c39-103 45-1264 9-1660l-7-68h-735l-6 68c-35 381-35 1263 0 1610l6 62h364c283 0 366-3 369-12zm1219-42c37-316 37-1287 0-1628l-7-58h-734l-6 73c-37 424-31 1544 8 1655 3 9 86 12 368 12h364zm841-1686c-336 0-363 1-370 18-3 9-13 152-22 317-21 431-7 1292 23 1388 5 16 31 17 369 17z"/>
|
||||||
text-transform:none;
|
</g>
|
||||||
}
|
</svg>
|
||||||
</style>
|
|
||||||
</svg>
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.4 KiB |
6
ram/portal/static/js/main.min.js
vendored
Normal file
6
ram/portal/static/js/main.min.js
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/*!
|
||||||
|
* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
|
||||||
|
* Copyright 2011-2023 The Bootstrap Authors
|
||||||
|
* Licensed under the Creative Commons Attribution 3.0 Unported License.
|
||||||
|
*/
|
||||||
|
(()=>{"use strict";const e=()=>localStorage.getItem("theme"),t=()=>{const t=e();return t||(window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light")},a=e=>{"auto"===e&&window.matchMedia("(prefers-color-scheme: dark)").matches?document.documentElement.setAttribute("data-bs-theme","dark"):document.documentElement.setAttribute("data-bs-theme",e)};a(t());const r=(e,t=!1)=>{const a=document.querySelector("#bd-theme");if(!a)return;const r=document.querySelector(".theme-icon-active i"),o=document.querySelector(`[data-bs-theme-value="${e}"]`),s=o.querySelector(".theme-icon i").getAttribute("class");document.querySelectorAll("[data-bs-theme-value]").forEach(e=>{e.classList.remove("active"),e.setAttribute("aria-pressed","false")}),o.classList.add("active"),o.setAttribute("aria-pressed","true"),r.setAttribute("class",s),t&&a.focus()};window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{const r=e();"light"!==r&&"dark"!==r&&a(t())}),window.addEventListener("DOMContentLoaded",()=>{r(t()),document.querySelectorAll("[data-bs-theme-value]").forEach(e=>{e.addEventListener("click",()=>{const t=e.getAttribute("data-bs-theme-value");(e=>{localStorage.setItem("theme",e)})(t),a(t),r(t,!0)})})})})(),document.addEventListener("DOMContentLoaded",function(){"use strict";const e=document.getElementById("tabSelector"),t=window.location.hash.substring(1);if(t){const a=`#nav-${t}`,r=document.querySelector(`[data-bs-target="${a}"]`);r&&(bootstrap.Tab.getOrCreateInstance(r).show(),e.value=a)}document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(e=>{e.addEventListener("shown.bs.tab",e=>{const t=e.target.getAttribute("data-bs-target").replace("nav-","");history.replaceState(null,null,t)})}),e&&(e.addEventListener("change",function(){const e=this.value,t=document.querySelector(`[data-bs-target="${e}"]`);if(t){bootstrap.Tab.getOrCreateInstance(t).show()}}),document.querySelectorAll('[data-bs-toggle="tab"]').forEach(t=>{t.addEventListener("shown.bs.tab",t=>{const a=t.target.getAttribute("data-bs-target");e.value=a})}))}),document.addEventListener("DOMContentLoaded",function(){"use strict";const e=document.querySelectorAll(".needs-validation");Array.from(e).forEach(e=>{e.addEventListener("submit",t=>{e.checkValidity()||(t.preventDefault(),t.stopPropagation()),e.classList.add("was-validated")},!1)})});
|
||||||
7
ram/portal/static/js/src/README.md
Normal file
7
ram/portal/static/js/src/README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Compile main.min.js
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ npm install terser
|
||||||
|
$ npx terser theme_selector.js tabs_selector.js -c -m -o ../main.min.js
|
||||||
|
```
|
||||||
|
|
||||||
43
ram/portal/static/js/src/tabs_selector.js
Normal file
43
ram/portal/static/js/src/tabs_selector.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// use Bootstrap 5's Tab component to manage tab navigation and synchronize with URL hash
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const selectElement = document.getElementById('tabSelector');
|
||||||
|
// code to handle tab selection and URL hash synchronization
|
||||||
|
const hash = window.location.hash.substring(1) // remove the '#' prefix
|
||||||
|
if (hash) {
|
||||||
|
const target = `#nav-${hash}`;
|
||||||
|
const trigger = document.querySelector(`[data-bs-target="${target}"]`);
|
||||||
|
if (trigger) {
|
||||||
|
bootstrap.Tab.getOrCreateInstance(trigger).show();
|
||||||
|
selectElement.value = target; // keep the dropdown in sync
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the URL hash when a tab is shown
|
||||||
|
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(btn => {
|
||||||
|
btn.addEventListener('shown.bs.tab', event => {
|
||||||
|
const newHash = event.target.getAttribute('data-bs-target').replace('nav-', '');
|
||||||
|
history.replaceState(null, null, newHash);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// allow tab selection via a dropdown on small screens
|
||||||
|
if (!selectElement) return;
|
||||||
|
selectElement.addEventListener('change', function () {
|
||||||
|
const target = this.value;
|
||||||
|
const trigger = document.querySelector(`[data-bs-target="${target}"]`);
|
||||||
|
if (trigger) {
|
||||||
|
const tabInstance = bootstrap.Tab.getOrCreateInstance(trigger);
|
||||||
|
tabInstance.show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// keep the dropdown in sync if the user clicks a tab button
|
||||||
|
document.querySelectorAll('[data-bs-toggle="tab"]').forEach(btn => {
|
||||||
|
btn.addEventListener('shown.bs.tab', event => {
|
||||||
|
const target = event.target.getAttribute('data-bs-target');
|
||||||
|
selectElement.value = target;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
76
ram/portal/static/js/src/theme_selector.js
Normal file
76
ram/portal/static/js/src/theme_selector.js
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/*!
|
||||||
|
* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
|
||||||
|
* Copyright 2011-2023 The Bootstrap Authors
|
||||||
|
* Licensed under the Creative Commons Attribution 3.0 Unported License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
(() => {
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const getStoredTheme = () => localStorage.getItem('theme')
|
||||||
|
const setStoredTheme = theme => localStorage.setItem('theme', theme)
|
||||||
|
|
||||||
|
const getPreferredTheme = () => {
|
||||||
|
const storedTheme = getStoredTheme()
|
||||||
|
if (storedTheme) {
|
||||||
|
return storedTheme
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
const setTheme = theme => {
|
||||||
|
if (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
|
document.documentElement.setAttribute('data-bs-theme', 'dark')
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-bs-theme', theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTheme(getPreferredTheme())
|
||||||
|
|
||||||
|
const showActiveTheme = (theme, focus = false) => {
|
||||||
|
const themeSwitcher = document.querySelector('#bd-theme')
|
||||||
|
|
||||||
|
if (!themeSwitcher) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeThemeIcon = document.querySelector('.theme-icon-active i')
|
||||||
|
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`)
|
||||||
|
const biOfActiveBtn = btnToActive.querySelector('.theme-icon i').getAttribute('class')
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
|
||||||
|
element.classList.remove('active')
|
||||||
|
element.setAttribute('aria-pressed', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
btnToActive.classList.add('active')
|
||||||
|
btnToActive.setAttribute('aria-pressed', 'true')
|
||||||
|
activeThemeIcon.setAttribute('class', biOfActiveBtn)
|
||||||
|
|
||||||
|
if (focus) {
|
||||||
|
themeSwitcher.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||||
|
const storedTheme = getStoredTheme()
|
||||||
|
if (storedTheme !== 'light' && storedTheme !== 'dark') {
|
||||||
|
setTheme(getPreferredTheme())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
showActiveTheme(getPreferredTheme())
|
||||||
|
document.querySelectorAll('[data-bs-theme-value]')
|
||||||
|
.forEach(toggle => {
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
const theme = toggle.getAttribute('data-bs-theme-value')
|
||||||
|
setStoredTheme(theme)
|
||||||
|
setTheme(theme)
|
||||||
|
showActiveTheme(theme, true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})()
|
||||||
15
ram/portal/static/js/src/validators.js
Normal file
15
ram/portal/static/js/src/validators.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
'use strict'
|
||||||
|
|
||||||
|
const forms = document.querySelectorAll('.needs-validation')
|
||||||
|
Array.from(forms).forEach(form => {
|
||||||
|
form.addEventListener('submit', event => {
|
||||||
|
if (!form.checkValidity()) {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
form.classList.add('was-validated')
|
||||||
|
}, false)
|
||||||
|
})
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user