mirror of
https://github.com/daniviga/django-ram.git
synced 2025-08-07 14:47:49 +02:00
Compare commits
71 Commits
Author | SHA1 | Date | |
---|---|---|---|
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 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -131,3 +131,4 @@ dmypy.json
|
|||||||
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.10+
|
- 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: fe2f705fa9...3b15491608
Submodule arduino/WebThrottle-EX updated: d82dcc9ecf...eb43d7906f
Submodule arduino/arduino-cli updated: a0d912da18...fa6eafcbbe
Submodule arduino/dcc-ex.github.io updated: cb286e9d76...a0f886b69f
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
|
|
||||||
```
|
|
@@ -1,10 +1,17 @@
|
|||||||
|
import html
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.utils.html import format_html, strip_tags
|
||||||
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
|
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
|
||||||
|
|
||||||
|
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
|
||||||
from bookshelf.models import (
|
from bookshelf.models import (
|
||||||
BaseBookProperty,
|
BaseBookProperty,
|
||||||
BaseBookImage,
|
BaseBookImage,
|
||||||
BaseBookDocument,
|
|
||||||
Book,
|
Book,
|
||||||
Author,
|
Author,
|
||||||
Publisher,
|
Publisher,
|
||||||
@@ -21,13 +28,6 @@ class BookImageInline(SortableInlineAdminMixin, admin.TabularInline):
|
|||||||
verbose_name = "Image"
|
verbose_name = "Image"
|
||||||
|
|
||||||
|
|
||||||
class BookDocInline(admin.TabularInline):
|
|
||||||
model = BaseBookDocument
|
|
||||||
min_num = 0
|
|
||||||
extra = 0
|
|
||||||
classes = ["collapse"]
|
|
||||||
|
|
||||||
|
|
||||||
class BookPropertyInline(admin.TabularInline):
|
class BookPropertyInline(admin.TabularInline):
|
||||||
model = BaseBookProperty
|
model = BaseBookProperty
|
||||||
min_num = 0
|
min_num = 0
|
||||||
@@ -37,6 +37,17 @@ class BookPropertyInline(admin.TabularInline):
|
|||||||
verbose_name_plural = "Properties"
|
verbose_name_plural = "Properties"
|
||||||
|
|
||||||
|
|
||||||
|
class BookDocInline(admin.TabularInline):
|
||||||
|
model = BookDocument
|
||||||
|
min_num = 0
|
||||||
|
extra = 0
|
||||||
|
classes = ["collapse"]
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogDocInline(BookDocInline):
|
||||||
|
model = CatalogDocument
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Book)
|
@admin.register(Book)
|
||||||
class BookAdmin(SortableAdminBase, admin.ModelAdmin):
|
class BookAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||||
inlines = (
|
inlines = (
|
||||||
@@ -52,8 +63,8 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
|
|||||||
"number_of_pages",
|
"number_of_pages",
|
||||||
"published",
|
"published",
|
||||||
)
|
)
|
||||||
autocomplete_fields = ("authors", "publisher")
|
autocomplete_fields = ("authors", "publisher", "shop")
|
||||||
readonly_fields = ("creation_time", "updated_time")
|
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")
|
||||||
|
|
||||||
@@ -71,12 +82,25 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
|
|||||||
"number_of_pages",
|
"number_of_pages",
|
||||||
"publication_year",
|
"publication_year",
|
||||||
"description",
|
"description",
|
||||||
"purchase_date",
|
|
||||||
"notes",
|
|
||||||
"tags",
|
"tags",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"Purchase data",
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"shop",
|
||||||
|
"purchase_date",
|
||||||
|
"price",
|
||||||
|
"invoices",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Notes",
|
||||||
|
{"classes": ("collapse",), "fields": ("notes",)},
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"Audit",
|
"Audit",
|
||||||
{
|
{
|
||||||
@@ -89,13 +113,81 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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 = "<br>".join(
|
||||||
|
"<a href=\"{}\" target=\"_blank\">{}</a>".format(
|
||||||
|
i.file.url, i
|
||||||
|
) for i in obj.invoice.all())
|
||||||
|
else:
|
||||||
|
html = "-"
|
||||||
|
return format_html(html)
|
||||||
|
|
||||||
@admin.display(description="Publisher")
|
@admin.display(description="Publisher")
|
||||||
def get_publisher(self, obj):
|
def get_publisher(self, obj):
|
||||||
return obj.publisher.name
|
return obj.publisher.name
|
||||||
|
|
||||||
@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)
|
||||||
@@ -109,25 +201,32 @@ class AuthorAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(Publisher)
|
@admin.register(Publisher)
|
||||||
class PublisherAdmin(admin.ModelAdmin):
|
class PublisherAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "country")
|
list_display = ("name", "country_flag")
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
|
|
||||||
|
@admin.display(description="Country")
|
||||||
|
def country_flag(self, obj):
|
||||||
|
return format_html(
|
||||||
|
'<img src="{}" /> {}'.format(obj.country.flag, obj.country.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Catalog)
|
@admin.register(Catalog)
|
||||||
class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
|
class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||||
inlines = (
|
inlines = (
|
||||||
BookPropertyInline,
|
BookPropertyInline,
|
||||||
BookImageInline,
|
BookImageInline,
|
||||||
BookDocInline,
|
CatalogDocInline,
|
||||||
)
|
)
|
||||||
list_display = (
|
list_display = (
|
||||||
|
"__str__",
|
||||||
"manufacturer",
|
"manufacturer",
|
||||||
"years",
|
"years",
|
||||||
"get_scales",
|
"get_scales",
|
||||||
"published",
|
"published",
|
||||||
)
|
)
|
||||||
autocomplete_fields = ("manufacturer",)
|
autocomplete_fields = ("manufacturer",)
|
||||||
readonly_fields = ("creation_time", "updated_time")
|
readonly_fields = ("invoices", "creation_time", "updated_time")
|
||||||
search_fields = ("manufacturer__name", "years", "scales__scale")
|
search_fields = ("manufacturer__name", "years", "scales__scale")
|
||||||
list_filter = ("manufacturer__name", "publication_year", "scales__scale")
|
list_filter = ("manufacturer__name", "publication_year", "scales__scale")
|
||||||
|
|
||||||
@@ -145,12 +244,25 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
|
|||||||
"number_of_pages",
|
"number_of_pages",
|
||||||
"publication_year",
|
"publication_year",
|
||||||
"description",
|
"description",
|
||||||
"purchase_date",
|
|
||||||
"notes",
|
|
||||||
"tags",
|
"tags",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"Purchase data",
|
||||||
|
{
|
||||||
|
"fields": (
|
||||||
|
"shop",
|
||||||
|
"purchase_date",
|
||||||
|
"price",
|
||||||
|
"invoices",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Notes",
|
||||||
|
{"classes": ("collapse",), "fields": ("notes",)},
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"Audit",
|
"Audit",
|
||||||
{
|
{
|
||||||
@@ -163,6 +275,72 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@admin.display(description="Scales")
|
def get_form(self, request, obj=None, **kwargs):
|
||||||
def get_scales(self, obj):
|
form = super().get_form(request, obj, **kwargs)
|
||||||
return "/".join(s.scale for s in obj.scales.all())
|
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 = "<br>".join(
|
||||||
|
"<a href=\"{}\" target=\"_blank\">{}</a>".format(
|
||||||
|
i.file.url, i
|
||||||
|
) for i in obj.invoice.all())
|
||||||
|
else:
|
||||||
|
html = "-"
|
||||||
|
return format_html(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]
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
]
|
@@ -5,12 +5,9 @@ from django.conf import settings
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
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 BaseModel, Image, Document, PropertyInstance
|
from ram.models import BaseModel, Image, PropertyInstance
|
||||||
from metadata.models import Scale, Manufacturer
|
from metadata.models import Scale, Manufacturer, Shop, Tag
|
||||||
|
|
||||||
|
|
||||||
class Publisher(models.Model):
|
class Publisher(models.Model):
|
||||||
@@ -35,6 +32,7 @@ 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]}."
|
||||||
|
|
||||||
@@ -48,7 +46,15 @@ class BaseBook(BaseModel):
|
|||||||
)
|
)
|
||||||
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)
|
||||||
description = tinymce.HTMLField(blank=True)
|
shop = models.ForeignKey(
|
||||||
|
Shop, on_delete=models.CASCADE, null=True, blank=True
|
||||||
|
)
|
||||||
|
price = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
purchase_date = models.DateField(null=True, blank=True)
|
purchase_date = models.DateField(null=True, blank=True)
|
||||||
tags = models.ManyToManyField(
|
tags = models.ManyToManyField(
|
||||||
Tag, related_name="bookshelf", blank=True
|
Tag, related_name="bookshelf", blank=True
|
||||||
@@ -83,16 +89,6 @@ class BaseBookImage(Image):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class BaseBookDocument(Document):
|
|
||||||
book = models.ForeignKey(
|
|
||||||
BaseBook, on_delete=models.CASCADE, related_name="document"
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
verbose_name_plural = "Documents"
|
|
||||||
unique_together = ("book", "file")
|
|
||||||
|
|
||||||
|
|
||||||
class BaseBookProperty(PropertyInstance):
|
class BaseBookProperty(PropertyInstance):
|
||||||
book = models.ForeignKey(
|
book = models.ForeignKey(
|
||||||
BaseBook,
|
BaseBook,
|
||||||
@@ -114,9 +110,14 @@ class Book(BaseBook):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
|
@property
|
||||||
def publisher_name(self):
|
def publisher_name(self):
|
||||||
return self.publisher.name
|
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):
|
def get_absolute_url(self):
|
||||||
return reverse(
|
return reverse(
|
||||||
"bookshelf_item",
|
"bookshelf_item",
|
||||||
@@ -136,7 +137,11 @@ class Catalog(BaseBook):
|
|||||||
ordering = ["manufacturer", "publication_year"]
|
ordering = ["manufacturer", "publication_year"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
scales = self.get_scales
|
# 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)
|
return "%s %s %s" % (self.manufacturer.name, self.years, scales)
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
@@ -145,6 +150,6 @@ class Catalog(BaseBook):
|
|||||||
kwargs={"selector": "catalog", "uuid": self.uuid}
|
kwargs={"selector": "catalog", "uuid": self.uuid}
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
|
||||||
def get_scales(self):
|
def get_scales(self):
|
||||||
return "/".join([s.scale for s in self.scales.all()])
|
return "/".join([s.scale for s in self.scales.all()])
|
||||||
|
get_scales.short_description = "Scales"
|
||||||
|
@@ -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,26 @@ 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")
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
read_only_fields = ("creation_time", "updated_time")
|
||||||
|
@@ -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/<uuid: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,12 +1,14 @@
|
|||||||
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):
|
||||||
serializer_class = BookSerializer
|
serializer_class = BookSerializer
|
||||||
|
pagination_class = CustomLimitOffsetPagination
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Book.objects.get_published(self.request.user)
|
return Book.objects.get_published(self.request.user)
|
||||||
@@ -19,3 +21,20 @@ class BookGet(RetrieveAPIView):
|
|||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Book.objects.get_published(self.request.user)
|
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)
|
||||||
|
@@ -1,15 +1,42 @@
|
|||||||
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
|
||||||
autocomplete_fields = ("rolling_stock",)
|
autocomplete_fields = ("rolling_stock",)
|
||||||
readonly_fields = ("preview", "published", "address", "type", "company", "era")
|
readonly_fields = (
|
||||||
|
"preview",
|
||||||
|
"published",
|
||||||
|
"scale",
|
||||||
|
"manufacturer",
|
||||||
|
"item_number",
|
||||||
|
"company",
|
||||||
|
"type",
|
||||||
|
"era",
|
||||||
|
"address",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Consist)
|
@admin.register(Consist)
|
||||||
@@ -19,11 +46,17 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
|
|||||||
"creation_time",
|
"creation_time",
|
||||||
"updated_time",
|
"updated_time",
|
||||||
)
|
)
|
||||||
list_display = ("identifier", "published", "company", "era")
|
list_filter = ("company__name", "era", "scale", "published")
|
||||||
list_filter = list_display
|
list_display = ("__str__",) + list_filter + ("country_flag",)
|
||||||
search_fields = list_display
|
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="{}" /> {}'.format(obj.country.flag, obj.country)
|
||||||
|
)
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
@@ -31,15 +64,20 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
|
|||||||
"fields": (
|
"fields": (
|
||||||
"published",
|
"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",
|
||||||
{
|
{
|
||||||
@@ -51,3 +89,55 @@ 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,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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@@ -2,12 +2,13 @@ import os
|
|||||||
|
|
||||||
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.dispatch import receiver
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from ram.models import BaseModel
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ class Consist(BaseModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
help_text="Era or epoch of the consist",
|
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,
|
||||||
@@ -39,11 +41,24 @@ class Consist(BaseModel):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("consist", kwargs={"uuid": self.uuid})
|
return reverse("consist", kwargs={"uuid": self.uuid})
|
||||||
|
|
||||||
def clean(self):
|
@property
|
||||||
if self.consist_item.filter(rolling_stock__published=False).exists():
|
def length(self):
|
||||||
raise ValidationError(
|
return self.consist_item.count()
|
||||||
"You must publish all items in the consist before publishing the consist." # noqa: E501
|
|
||||||
)
|
def get_type_count(self):
|
||||||
|
return self.consist_item.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")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def country(self):
|
||||||
|
return self.company.country
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["company", "-creation_time"]
|
ordering = ["company", "-creation_time"]
|
||||||
@@ -54,14 +69,38 @@ 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)
|
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 clean(self):
|
||||||
|
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
|
||||||
|
if rolling_stock.scale != consist.scale:
|
||||||
|
raise ValidationError(
|
||||||
|
"The rolling stock and consist must be of the same scale."
|
||||||
|
)
|
||||||
|
if self.consist.published and not rolling_stock.published:
|
||||||
|
raise ValidationError(
|
||||||
|
"You must unpublish the the consist before using this item."
|
||||||
|
)
|
||||||
|
|
||||||
def published(self):
|
def published(self):
|
||||||
return self.rolling_stock.published
|
return self.rolling_stock.published
|
||||||
published.boolean = True
|
published.boolean = True
|
||||||
@@ -69,15 +108,31 @@ class ConsistItem(models.Model):
|
|||||||
def preview(self):
|
def preview(self):
|
||||||
return self.rolling_stock.image.first().image_thumbnail(100)
|
return self.rolling_stock.image.first().image_thumbnail(100)
|
||||||
|
|
||||||
def type(self):
|
@property
|
||||||
return self.rolling_stock.rolling_class.type
|
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
|
||||||
|
|
||||||
@@ -87,6 +142,7 @@ class ConsistItem(models.Model):
|
|||||||
# it is hosted here to avoid circular imports
|
# it is hosted here to avoid circular imports
|
||||||
@receiver(models.signals.post_save, sender=RollingStock)
|
@receiver(models.signals.post_save, sender=RollingStock)
|
||||||
def post_save_unpublish_consist(sender, instance, *args, **kwargs):
|
def post_save_unpublish_consist(sender, instance, *args, **kwargs):
|
||||||
|
if not instance.published:
|
||||||
consists = Consist.objects.filter(consist_item__rolling_stock=instance)
|
consists = Consist.objects.filter(consist_item__rolling_stock=instance)
|
||||||
for consist in consists:
|
for consist in consists:
|
||||||
consist.published = False
|
consist.published = False
|
||||||
|
@@ -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,11 +1,13 @@
|
|||||||
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):
|
||||||
serializer_class = ConsistSerializer
|
serializer_class = ConsistSerializer
|
||||||
|
pagination_class = CustomLimitOffsetPagination
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Consist.objects.get_published(self.request.user)
|
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,
|
||||||
@@ -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")
|
||||||
list_filter = list_display
|
list_filter = ("name", "country")
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
|
|
||||||
|
@admin.display(description="Country")
|
||||||
|
def country_flag(self, obj):
|
||||||
|
return format_html(
|
||||||
|
'<img src="{}" /> {}'.format(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")
|
||||||
list_filter = ("category",)
|
list_filter = ("category",)
|
||||||
search_fields = ("name",)
|
search_fields = ("name",)
|
||||||
|
|
||||||
|
@admin.display(description="Country")
|
||||||
|
def country_flag(self, obj):
|
||||||
|
return format_html(
|
||||||
|
'<img src="{}" /> {}'.format(obj.country.flag, obj.country.name)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Tag)
|
@admin.register(Tag)
|
||||||
class TagAdmin(admin.ModelAdmin):
|
class TagAdmin(admin.ModelAdmin):
|
||||||
@@ -70,3 +84,10 @@ 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")
|
||||||
|
list_filter = ("on_line", "active")
|
||||||
|
search_fields = ("name",)
|
||||||
|
@@ -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"]},
|
||||||
|
),
|
||||||
|
]
|
@@ -6,7 +6,6 @@ from django.dispatch.dispatcher import receiver
|
|||||||
from django.core.exceptions import ValidationError
|
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.utils import DeduplicatedStorage, get_image_preview, slugify
|
from ram.utils import DeduplicatedStorage, get_image_preview, slugify
|
||||||
from ram.managers import PublicManager
|
from ram.managers import PublicManager
|
||||||
|
|
||||||
@@ -34,6 +33,7 @@ class Manufacturer(models.Model):
|
|||||||
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"),
|
||||||
@@ -43,7 +43,7 @@ class Manufacturer(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["category", "name"]
|
ordering = ["category", "slug"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
@@ -78,7 +78,7 @@ 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
|
||||||
@@ -117,7 +117,7 @@ class Decoder(models.Model):
|
|||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(object):
|
class Meta:
|
||||||
ordering = ["manufacturer__name", "name"]
|
ordering = ["manufacturer__name", "name"]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -129,15 +129,6 @@ class Decoder(models.Model):
|
|||||||
image_thumbnail.short_description = "Preview"
|
image_thumbnail.short_description = "Preview"
|
||||||
|
|
||||||
|
|
||||||
class DecoderDocument(Document):
|
|
||||||
decoder = models.ForeignKey(
|
|
||||||
Decoder, on_delete=models.CASCADE, related_name="document"
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
unique_together = ("decoder", "file")
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_ratio(ratio):
|
def calculate_ratio(ratio):
|
||||||
try:
|
try:
|
||||||
num, den = ratio.split(":")
|
num, den = ratio.split(":")
|
||||||
@@ -189,8 +180,13 @@ 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):
|
||||||
@@ -210,8 +206,8 @@ class Tag(models.Model):
|
|||||||
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
|
||||||
@@ -226,6 +222,20 @@ class Tag(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Shop(models.Model):
|
||||||
|
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,10 @@ class SiteConfigurationAdmin(SingletonModelAdmin):
|
|||||||
"about",
|
"about",
|
||||||
"items_per_page",
|
"items_per_page",
|
||||||
"items_ordering",
|
"items_ordering",
|
||||||
|
"currency",
|
||||||
"footer",
|
"footer",
|
||||||
"footer_extended",
|
"footer_extended",
|
||||||
|
"disclaimer",
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -30,11 +35,30 @@ class SiteConfigurationAdmin(SingletonModelAdmin):
|
|||||||
"show_version",
|
"show_version",
|
||||||
"use_cdn",
|
"use_cdn",
|
||||||
"extra_head",
|
"extra_head",
|
||||||
|
"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 +90,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"},
|
||||||
|
),
|
||||||
|
]
|
@@ -30,8 +30,10 @@ class SiteConfiguration(SingletonModel):
|
|||||||
],
|
],
|
||||||
default="type",
|
default="type",
|
||||||
)
|
)
|
||||||
|
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_head = models.TextField(blank=True)
|
||||||
@@ -45,9 +47,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()
|
||||||
|
|
||||||
@@ -60,6 +64,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
|
||||||
|
|
||||||
|
Binary file not shown.
@@ -1,5 +1,5 @@
|
|||||||
/*!
|
/*!
|
||||||
* Bootstrap Icons v1.11.3 (https://icons.getbootstrap.com/)
|
* Bootstrap Icons v1.13.1 (https://icons.getbootstrap.com/)
|
||||||
* Copyright 2019-2024 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)
|
||||||
*/
|
*/
|
||||||
@@ -7,8 +7,8 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-display: block;
|
font-display: block;
|
||||||
font-family: "bootstrap-icons";
|
font-family: "bootstrap-icons";
|
||||||
src: url("./fonts/bootstrap-icons.woff2?dd67030699838ea613ee6dbda90effa6") format("woff2"),
|
src: url("./fonts/bootstrap-icons.woff2?e34853135f9e39acf64315236852cd5a") format("woff2"),
|
||||||
url("./fonts/bootstrap-icons.woff?dd67030699838ea613ee6dbda90effa6") format("woff");
|
url("./fonts/bootstrap-icons.woff?e34853135f9e39acf64315236852cd5a") format("woff");
|
||||||
}
|
}
|
||||||
|
|
||||||
.bi::before,
|
.bi::before,
|
||||||
@@ -2076,3 +2076,31 @@ url("./fonts/bootstrap-icons.woff?dd67030699838ea613ee6dbda90effa6") 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.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
6
ram/portal/static/bootstrap@5.3.6/dist/css/bootstrap.min.css
vendored
Normal file
6
ram/portal/static/bootstrap@5.3.6/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.6/dist/css/bootstrap.min.css.map
vendored
Normal file
1
ram/portal/static/bootstrap@5.3.6/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.6/dist/js/bootstrap.bundle.min.js
vendored
Normal file
7
ram/portal/static/bootstrap@5.3.6/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.6/dist/js/bootstrap.bundle.min.js.map
vendored
Normal file
1
ram/portal/static/bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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>
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -16,11 +16,11 @@
|
|||||||
<link rel="icon" href="{% static "favicon.png" %}" sizes="any">
|
<link rel="icon" href="{% static "favicon.png" %}" sizes="any">
|
||||||
<link rel="icon" href="{% static "favicon.svg" %}" type="image/svg+xml">
|
<link rel="icon" href="{% static "favicon.svg" %}" type="image/svg+xml">
|
||||||
{% if site_conf.use_cdn %}
|
{% if site_conf.use_cdn %}
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.css" rel="stylesheet">
|
||||||
{% else %}
|
{% else %}
|
||||||
<link href="{% static "bootstrap@5.3.3/dist/css/bootstrap.min.css" %}" rel="stylesheet">
|
<link href="{% static "bootstrap@5.3.6/dist/css/bootstrap.min.css" %}" rel="stylesheet">
|
||||||
<link href="{% static "bootstrap-icons@1.11.3/font/bootstrap-icons.css" %}" rel="stylesheet">
|
<link href="{% static "bootstrap-icons@1.13.1/font/bootstrap-icons.css" %}" rel="stylesheet">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<link href="{% static "css/main.css" %}?v={{ site_conf.version }}" rel="stylesheet">
|
<link href="{% static "css/main.css" %}?v={{ site_conf.version }}" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
@@ -118,6 +118,7 @@
|
|||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
var selectElement = document.getElementById('tabSelector');
|
var selectElement = document.getElementById('tabSelector');
|
||||||
|
try {
|
||||||
selectElement.addEventListener('change', function () {
|
selectElement.addEventListener('change', function () {
|
||||||
var selectedTabId = this.value;
|
var selectedTabId = this.value;
|
||||||
var tabs = document.querySelectorAll('.tab-pane');
|
var tabs = document.querySelectorAll('.tab-pane');
|
||||||
@@ -126,6 +127,7 @@
|
|||||||
});
|
});
|
||||||
document.getElementById(selectedTabId).classList.add('show', 'active');
|
document.getElementById(selectedTabId).classList.add('show', 'active');
|
||||||
});
|
});
|
||||||
|
} catch (TypeError) { /* pass */ }
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
@@ -138,14 +140,10 @@
|
|||||||
<div class="container d-flex">
|
<div class="container d-flex">
|
||||||
<div class="me-auto">
|
<div class="me-auto">
|
||||||
<a href="{% url 'index' %}" class="navbar-brand d-flex align-items-center">
|
<a href="{% url 'index' %}" class="navbar-brand d-flex align-items-center">
|
||||||
<svg class="me-2" width="26" height="16" viewBox="0 0 26 16" xmlns="http://www.w3.org/2000/svg">
|
<svg class="me-2" width="32" height="16" version="1.0" viewBox="0 0 24 12" xmlns="http://www.w3.org/2000/svg">
|
||||||
<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" stroke-width="2" />
|
<g transform="matrix(.0039261 0 0 -.0039261 -1.4249 18.53)">
|
||||||
<style>
|
<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"/>
|
||||||
path {
|
</g>
|
||||||
text-indent:0;
|
|
||||||
text-transform:none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</svg>
|
</svg>
|
||||||
<strong>{{ site_conf.site_name }}</strong>
|
<strong>{{ site_conf.site_name }}</strong>
|
||||||
</a>
|
</a>
|
||||||
@@ -213,12 +211,13 @@
|
|||||||
<div class="container">{% block pagination %}{% endblock %}</div>
|
<div class="container">{% block pagination %}{% endblock %}</div>
|
||||||
</div>
|
</div>
|
||||||
{% block extra_content %}{% endblock %}
|
{% block extra_content %}{% endblock %}
|
||||||
|
{% include 'includes/symbols.html' %}
|
||||||
</main>
|
</main>
|
||||||
{% include 'includes/footer.html' %}
|
{% include 'includes/footer.html' %}
|
||||||
{% if site_conf.use_cdn %}
|
{% if site_conf.use_cdn %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
{% else %}
|
{% else %}
|
||||||
<script src="{% static "bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" %}"></script>
|
<script src="{% static "bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js" %}"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@@ -9,7 +9,10 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<small class="text-muted">Updated {{ book.updated_time | date:"M d, Y H:i" }}</small>
|
{% if not book.published %}
|
||||||
|
<span class="badge text-bg-warning">Unpublished</span> |
|
||||||
|
{% endif %}
|
||||||
|
<small class="text-body-secondary">Updated {{ book.updated_time | date:"M d, Y H:i" }}</small>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block carousel %}
|
{% block carousel %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -46,23 +49,21 @@
|
|||||||
<div class="mx-auto">
|
<div class="mx-auto">
|
||||||
<nav class="nav nav-tabs d-none d-lg-flex flex-row mb-2" id="nav-tab" role="tablist">
|
<nav class="nav nav-tabs d-none d-lg-flex flex-row mb-2" id="nav-tab" role="tablist">
|
||||||
<button class="nav-link active" id="nav-summary-tab" data-bs-toggle="tab" data-bs-target="#nav-summary" type="button" role="tab" aria-controls="nav-summary" aria-selected="true">Summary</button>
|
<button class="nav-link active" id="nav-summary-tab" data-bs-toggle="tab" data-bs-target="#nav-summary" type="button" role="tab" aria-controls="nav-summary" aria-selected="true">Summary</button>
|
||||||
{% if book.notes %}<button class="nav-link" id="nav-notes-tab" data-bs-toggle="tab" data-bs-target="#nav-notes" type="button" role="tab" aria-controls="nav-notes" aria-selected="false">Notes</button>{% endif %}
|
{% if documents %}<button class="nav-link" id="nav-documents-tab" data-bs-toggle="tab" data-bs-target="#nav-documents" type="button" role="tab" aria-controls="nav-documents" aria-selected="false">Documents</button>{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
|
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
|
||||||
<option value="nav-summary" selected>Summary</option>
|
<option value="nav-summary" selected>Summary</option>
|
||||||
{% if book.notes %}<option value="nav-notes">Notes</option>{% endif %}
|
{% if documents %}<option value="nav-documents">Documents</option>{% endif %}
|
||||||
</select>
|
</select>
|
||||||
<div class="tab-content" id="nav-tabContent">
|
<div class="tab-content" id="nav-tabContent">
|
||||||
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
|
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
|
||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
{{ book.description | safe }}
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{% if type == "catalog" %}
|
<th colspan="2" scope="row">
|
||||||
<th colspan="2" scope="row">Catalog</th>
|
{% if type == "catalog" %}Catalog
|
||||||
{% elif type == "book" %}
|
{% elif type == "book" %}Book{% endif %}
|
||||||
<th colspan="2" scope="row">Book</th>
|
</th>
|
||||||
{% endif %}
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="table-group-divider">
|
<tbody class="table-group-divider">
|
||||||
@@ -107,13 +108,41 @@
|
|||||||
<th scope="row">Publication year</th>
|
<th scope="row">Publication year</th>
|
||||||
<td>{{ book.publication_year|default:"-" }}</td>
|
<td>{{ book.publication_year|default:"-" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% if book.description %}
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Purchase date</th>
|
<th class="w-33" scope="row">Description</th>
|
||||||
|
<td>{{ book.description | safe }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if request.user.is_staff %}
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th colspan="2" scope="row">Purchase</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="table-group-divider">
|
||||||
|
<tr>
|
||||||
|
<th class="w-33" scope="row">Shop</th>
|
||||||
|
<td>
|
||||||
|
{{ book.shop|default:"-" }}
|
||||||
|
{% if book.shop.website %} <a href="{{ book.shop.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="w-33" scope="row">Purchase date</th>
|
||||||
<td>{{ book.purchase_date|default:"-" }}</td>
|
<td>{{ book.purchase_date|default:"-" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Price ({{ site_conf.currency }})</th>
|
||||||
|
<td>{{ book.price|default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% if book_properties %}
|
{% endif %}
|
||||||
|
{% if properties %}
|
||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -121,7 +150,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="table-group-divider">
|
<tbody class="table-group-divider">
|
||||||
{% for p in book_properties %}
|
{% for p in properties %}
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-33" scope="row">{{ p.property }}</th>
|
<th class="w-33" scope="row">{{ p.property }}</th>
|
||||||
<td>{{ p.value }}</td>
|
<td>{{ p.value }}</td>
|
||||||
@@ -131,8 +160,23 @@
|
|||||||
</table>
|
</table>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-pane" id="nav-notes" role="tabpanel" aria-labelledby="nav-notes-tab">
|
<div class="tab-pane" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
|
||||||
{{ book.notes | safe }}
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th colspan="3" scope="row">Documents</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="table-group-divider">
|
||||||
|
{% for d in documents.all %}
|
||||||
|
<tr>
|
||||||
|
<td class="w-33">{{ d.description }}</td>
|
||||||
|
<td><a href="{{ d.file.url }}" target="_blank">{{ d.filename }}</a></td>
|
||||||
|
<td class="text-end">{{ d.file.size | filesizeformat }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block header %}
|
{% block header %}
|
||||||
<p class="lead text-muted">Results found: {{ matches }}</p>
|
<p class="lead text-body-secondary">Results found: {{ matches }}</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block cards_layout %}
|
{% block cards_layout %}
|
||||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
|
||||||
|
@@ -1,8 +1,12 @@
|
|||||||
|
{% load static %}
|
||||||
{% load dynamic_url %}
|
{% load dynamic_url %}
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
{% if d.item.image.exists %}
|
{% if d.item.image.exists %}
|
||||||
<a href="{{d.item.get_absolute_url}}"><img class="card-img-top" src="{{ d.item.image.first.image.url }}" alt="{{ d.item }}"></a>
|
<a href="{{d.item.get_absolute_url}}"><img class="card-img-top" src="{{ d.item.image.first.image.url }}" alt="{{ d.item }}"></a>
|
||||||
|
{% else %}
|
||||||
|
<!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
|
||||||
|
<a href="{{d.item.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d.item }}"></a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<p class="card-text" style="position: relative;">
|
<p class="card-text" style="position: relative;">
|
||||||
@@ -19,11 +23,15 @@
|
|||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
{% if d.type == "catalog" %}
|
<th colspan="2" scope="row">
|
||||||
<th colspan="2" scope="row">Catalog</th>
|
{% if d.type == "catalog" %}Catalog
|
||||||
{% elif d.type == "book" %}
|
{% elif d.type == "book" %}Book{% endif %}
|
||||||
<th colspan="2" scope="row">Book</th>
|
<div class="float-end">
|
||||||
|
{% if not d.item.published %}
|
||||||
|
<span class="badge text-bg-warning">Unpublished</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="table-group-divider">
|
<tbody class="table-group-divider">
|
||||||
|
@@ -7,7 +7,14 @@
|
|||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="2" scope="row">Company</th>
|
<th colspan="2" scope="row">
|
||||||
|
Company
|
||||||
|
<div class="float-end">
|
||||||
|
{% if d.item.freelance %}
|
||||||
|
<span class="badge text-bg-secondary">Freelance</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="table-group-divider">
|
<tbody class="table-group-divider">
|
||||||
@@ -27,19 +34,15 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-33" scope="row">Country</th>
|
<th class="w-33" scope="row">Country</th>
|
||||||
<td>{{ d.item.country.name }} <img src="{{ d.item.country.flag }}" alt="{{ d.item.country }}">
|
<td><img src="{{ d.item.country.flag }}" alt="{{ d.item.country }}"> {{ d.item.country.name }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if d.item.freelance %}
|
|
||||||
<tr>
|
|
||||||
<th class="w-33" scope="row">Notes</th>
|
|
||||||
<td>A <em>freelance</em> company</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="d-grid gap-2 mb-1 d-md-block">
|
<div class="d-grid gap-2 mb-1 d-md-block">
|
||||||
<a class="btn btn-sm btn-outline-primary" href="{% url 'filtered' _filter="company" search=d.item.slug %}">Show all rolling stock</a>
|
{% with items=d.item.num_items %}
|
||||||
|
<a class="btn btn-sm btn-outline-primary{% if items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="company" search=d.item.slug %}">Show {{ items }} item{{ items | pluralize}}</a>
|
||||||
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_company_change' d.item.pk %}">Edit</a>{% endif %}
|
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_company_change' d.item.pk %}">Edit</a>{% endif %}
|
||||||
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -24,7 +24,17 @@
|
|||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="2" scope="row">Consist</th>
|
<th colspan="2" scope="row">
|
||||||
|
Consist
|
||||||
|
<div class="float-end">
|
||||||
|
{% if not d.item.published %}
|
||||||
|
<span class="badge text-bg-warning">Unpublished</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if d.item.company.freelance %}
|
||||||
|
<span class="badge text-bg-secondary">Freelance</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="table-group-divider">
|
<tbody class="table-group-divider">
|
||||||
@@ -36,7 +46,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-33" scope="row">Company</th>
|
<th class="w-33" scope="row">Company</th>
|
||||||
<td><abbr title="{{ d.item.company.extended_name }}">{{ d.item.company }}</abbr></td>
|
<td>
|
||||||
|
<img src="{{ d.item.company.country.flag }}" alt="{{ d.item.company.country }}">
|
||||||
|
<abbr title="{{ d.item.company.extended_name }}">{{ d.item.company }}</abbr>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Era</th>
|
<th scope="row">Era</th>
|
||||||
@@ -44,7 +57,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Length</th>
|
<th scope="row">Length</th>
|
||||||
<td>{{ d.item.consist_item.count }}</td>
|
<td>{{ d.item.length }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
@@ -30,8 +30,10 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="d-grid gap-2 mb-1 d-md-block">
|
<div class="d-grid gap-2 mb-1 d-md-block">
|
||||||
<a class="btn btn-sm btn-outline-primary" href="{% url 'filtered' _filter="manufacturer" search=d.item.slug %}">Show all rolling stock</a>
|
{% with items=d.item.num_items %}
|
||||||
|
<a class="btn btn-sm btn-outline-primary{% if items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="manufacturer" search=d.item.slug %}">Show {{ items }} item{{ items | pluralize}}</a>
|
||||||
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_manufacturer_change' d.item.pk %}">Edit</a>{% endif %}
|
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_manufacturer_change' d.item.pk %}">Edit</a>{% endif %}
|
||||||
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -20,8 +20,10 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="d-grid gap-2 mb-1 d-md-block">
|
<div class="d-grid gap-2 mb-1 d-md-block">
|
||||||
<a class="btn btn-sm btn-outline-primary" href="{% url 'filtered' _filter="type" search=d.item.slug %}">Show all rolling stock</a>
|
{% with items=d.item.num_items %}
|
||||||
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_scale_change' d.item.pk %}">Edit</a>{% endif %}
|
<a class="btn btn-sm btn-outline-primary{% if items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="type" search=d.item.slug %}">Show {{ items }} item{{ items | pluralize}}</a>
|
||||||
|
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_rollingstocktype_change' d.item.pk %}">Edit</a>{% endif %}
|
||||||
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,4 +1,6 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
|
{% load dcc %}
|
||||||
|
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
{% if d.item.image.exists %}
|
{% if d.item.image.exists %}
|
||||||
@@ -22,7 +24,17 @@
|
|||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="2" scope="row">Rolling stock</th>
|
<th colspan="2" scope="row">
|
||||||
|
Rolling stock
|
||||||
|
<div class="float-end">
|
||||||
|
{% if not d.item.published %}
|
||||||
|
<span class="badge text-bg-warning">Unpublished</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if d.item.company.freelance %}
|
||||||
|
<span class="badge text-bg-secondary">Freelance</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="table-group-divider">
|
<tbody class="table-group-divider">
|
||||||
@@ -33,7 +45,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Company</th>
|
<th scope="row">Company</th>
|
||||||
<td>
|
<td>
|
||||||
<a href="{% url 'filtered' _filter="company" search=d.item.rolling_class.company.slug %}"><abbr title="{{ d.item.rolling_class.company.extended_name }}">{{ d.item.rolling_class.company }}</abbr></a>
|
<img src="{{ d.item.company.country.flag }}" alt="{{ d.item.company.country }}">
|
||||||
|
<a href="{% url 'filtered' _filter="company" search=d.item.company.slug %}"><abbr title="{{ d.item.company.extended_name }}">{{ d.item.company }}</abbr></a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -62,27 +75,12 @@
|
|||||||
<th scope="row">Item number</th>
|
<th scope="row">Item number</th>
|
||||||
<td>{{ d.item.item_number }}{%if d.item.set %} | <a class="badge text-bg-primary" href="{% url 'manufacturer' manufacturer=d.item.manufacturer.slug search=d.item.item_number_slug %}">SET</a>{% endif %}</td>
|
<td>{{ d.item.item_number }}{%if d.item.set %} | <a class="badge text-bg-primary" href="{% url 'manufacturer' manufacturer=d.item.manufacturer.slug search=d.item.item_number_slug %}">SET</a>{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% if d.item.decoder %}
|
|
||||||
<table class="table table-striped">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="2" scope="row">DCC data</th>
|
<th scope="row">DCC</th>
|
||||||
</tr>
|
<td><a class="text-reset text-decoration-none" title="Symbols" href="" data-bs-toggle="modal" data-bs-target="#symbolsModal">{% dcc d.item %}</a></td>
|
||||||
</thead>
|
|
||||||
<tbody class="table-group-divider">
|
|
||||||
<tr>
|
|
||||||
<th class="w-33" scope="row">Decoder</th>
|
|
||||||
<td>{{ d.item.decoder }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">Address</th>
|
|
||||||
<td>{{ d.item.address }}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% endif %}
|
|
||||||
<div class="d-grid gap-2 mb-1 d-md-block">
|
<div class="d-grid gap-2 mb-1 d-md-block">
|
||||||
<a class="btn btn-sm btn-outline-primary" href="{{d.item.get_absolute_url}}">Show all data</a>
|
<a class="btn btn-sm btn-outline-primary" href="{{d.item.get_absolute_url}}">Show all data</a>
|
||||||
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:roster_rollingstock_change' d.item.pk %}">Edit</a>{% endif %}
|
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:roster_rollingstock_change' d.item.pk %}">Edit</a>{% endif %}
|
||||||
|
@@ -28,8 +28,10 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="d-grid gap-2 mb-1 d-md-block">
|
<div class="d-grid gap-2 mb-1 d-md-block">
|
||||||
<a class="btn btn-sm btn-outline-primary" href="{% url 'filtered' _filter="scale" search=d.item.slug %}">Show all rolling stock</a>
|
{% with items=d.item.num_items %}
|
||||||
|
<a class="btn btn-sm btn-outline-primary{% if items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="scale" search=d.item.slug %}">Show {{ items }} item{{ items | pluralize}}</a>
|
||||||
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_scale_change' d.item.pk %}">Edit</a>{% endif %}
|
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_scale_change' d.item.pk %}">Edit</a>{% endif %}
|
||||||
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -7,7 +7,10 @@
|
|||||||
{{ t.name }}</a>{# new line is required #}
|
{{ t.name }}</a>{# new line is required #}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</p>
|
</p>
|
||||||
<small class="text-muted">Updated {{ consist.updated_time | date:"M d, Y H:i" }}</small>
|
{% if not consist.published %}
|
||||||
|
<span class="badge text-bg-warning">Unpublished</span> |
|
||||||
|
{% endif %}
|
||||||
|
<small class="text-body-secondary">Updated {{ consist.updated_time | date:"M d, Y H:i" }}</small>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block carousel %}
|
{% block carousel %}
|
||||||
@@ -25,7 +28,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block pagination %}
|
{% block pagination %}
|
||||||
{% if data.has_other_pages %}
|
{% if data.has_other_pages %}
|
||||||
<nav aria-label="Page navigation example">
|
<nav aria-label="Page navigation">
|
||||||
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
|
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
|
||||||
{% if data.has_previous %}
|
{% if data.has_previous %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
@@ -68,18 +71,23 @@
|
|||||||
<div class="mx-auto">
|
<div class="mx-auto">
|
||||||
<nav class="nav nav-tabs d-none d-lg-flex flex-row mb-2" id="nav-tab" role="tablist">
|
<nav class="nav nav-tabs d-none d-lg-flex flex-row mb-2" id="nav-tab" role="tablist">
|
||||||
<button class="nav-link active" id="nav-summary-tab" data-bs-toggle="tab" data-bs-target="#nav-summary" type="button" role="tab" aria-controls="nav-summary" aria-selected="true">Summary</button>
|
<button class="nav-link active" id="nav-summary-tab" data-bs-toggle="tab" data-bs-target="#nav-summary" type="button" role="tab" aria-controls="nav-summary" aria-selected="true">Summary</button>
|
||||||
{% if consist.notes %}<button class="nav-link" id="nav-notes-tab" data-bs-toggle="tab" data-bs-target="#nav-notes" type="button" role="tab" aria-controls="nav-notes" aria-selected="false">Notes</button>{% endif %}
|
|
||||||
</nav>
|
</nav>
|
||||||
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
|
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
|
||||||
<option value="nav-summary" selected>Summary</option>
|
<option value="nav-summary" selected>Summary</option>
|
||||||
{% if consist.notes %}<option value="nav-notes">Notes</option>{% endif %}
|
|
||||||
</select>
|
</select>
|
||||||
<div class="tab-content" id="nav-tabContent">
|
<div class="tab-content" id="nav-tabContent">
|
||||||
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
|
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
|
||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="2" scope="row">Data</th>
|
<th colspan="2" scope="row">
|
||||||
|
Consist
|
||||||
|
<div class="float-end">
|
||||||
|
{% if consist.company.freelance %}
|
||||||
|
<span class="badge text-bg-secondary">Freelance</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="table-group-divider">
|
<tbody class="table-group-divider">
|
||||||
@@ -93,23 +101,19 @@
|
|||||||
<th scope="row">Era</th>
|
<th scope="row">Era</th>
|
||||||
<td>{{ consist.era }}</td>
|
<td>{{ consist.era }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% if consist.description %}
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Description</th>
|
||||||
|
<td>{{ consist.description | safe }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Length</th>
|
<th scope="row">Length</th>
|
||||||
<td>{{ data | length }}</td>
|
<td>{{ consist.length }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="tab-pane" id="nav-notes" role="tabpanel" aria-labelledby="nav-notes-tab">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Notes</th>
|
<th scope="row">Composition</th>
|
||||||
</tr>
|
<td>{% for t in consist.get_type_count %}{{ t.count }}x {{ t.type }} {{t.category }}{% if not forloop.last %} » {% endif %}{% endfor %}</td>
|
||||||
</thead>
|
|
||||||
<tbody class="table-group-divider">
|
|
||||||
<tr>
|
|
||||||
<td>{{ consist.notes | safe }}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
{% extends "cards.html" %}
|
{% extends "cards.html" %}
|
||||||
{% block pagination %}
|
{% block pagination %}
|
||||||
{% if data.has_other_pages %}
|
{% if data.has_other_pages %}
|
||||||
<nav aria-label="Page navigation example">
|
<nav aria-label="Page navigation">
|
||||||
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
|
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
|
||||||
{% if data.has_previous %}
|
{% if data.has_previous %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
|
@@ -1,7 +1,12 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
<small class="text-muted">Updated {{ flatpage.updated_time | date:"M d, Y H:i" }}</small>
|
{% if not flatpage.published %}
|
||||||
|
<span class="badge text-bg-warning">Unpublished</span> |
|
||||||
|
{% endif %}
|
||||||
|
<small class="text-body-secondary">Updated {{ flatpage.updated_time | date:"M d, Y H:i" }}</small>
|
||||||
|
{% endblock %}
|
||||||
|
{% block carousel %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block extra_content %}
|
{% block extra_content %}
|
||||||
<section class="py-4 text-start container">
|
<section class="py-4 text-start container">
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
{% if flatpages_menu %}
|
{% if flatpages_menu %}
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
<a class="nav-link dropdown-toggle" href="#" id="flatpageDropdownMenuLink" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
<a class="nav-link dropdown-toggle" href="#" id="flatpageDropdownMenuLink" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
Articles
|
Pages
|
||||||
</a>
|
</a>
|
||||||
<ul class="dropdown-menu" aria-labelledby="flatpageDropdownMenuLink">
|
<ul class="dropdown-menu" aria-labelledby="flatpageDropdownMenuLink">
|
||||||
{% for m in flatpages_menu %}
|
{% for m in flatpages_menu %}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
{% extends "pagination.html" %}
|
{% extends "pagination.html" %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
<div class="text-muted">{{ site_conf.about | safe }}</div>
|
<div class="text-body-secondary">{{ site_conf.about | safe }}</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@@ -1,18 +1,36 @@
|
|||||||
<footer class="text-muted py-4">
|
<footer class="text-body-secondary py-4">
|
||||||
<div class="container">
|
<div class="container d-lg-flex justify-content-between">
|
||||||
<p class="float-end mb-1">
|
|
||||||
<a href="#">Back to top</a>
|
|
||||||
</p>
|
|
||||||
<div id="footer" class="mb-1">
|
<div id="footer" class="mb-1">
|
||||||
<p>© {% now "Y" %}</p> {{ site_conf.footer | safe }}
|
<p>© {% now "Y" %}</p> {{ site_conf.footer | safe }}
|
||||||
</div>
|
</div>
|
||||||
<div id="footer_extended" class="mb-0">
|
</div>
|
||||||
|
<div class="container">
|
||||||
|
<div id="footer_extended">
|
||||||
{{ site_conf.footer_extended | safe }}
|
{{ site_conf.footer_extended | safe }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="container d-flex text-body-secondary">
|
||||||
<div class="container">
|
<p class="flex-fill small">Made with ❤️ for 🚂 and <i class="bi bi-github"></i> <a href="https://github.com/daniviga/django-ram">django-ram</a>
|
||||||
<p class="small text-muted">Made with ❤️ for 🚂 and <a href="https://github.com/daniviga/django-ram">django-ram</a>
|
{% if site_conf.show_version %}<br>Version {{ site_conf.version }}{% endif %}</p>
|
||||||
{% if site_conf.show_version %}<br>Version {{ site_conf.version }}{% endif %}
|
<p class="text-end">
|
||||||
|
{% if site_conf.disclaimer %}
|
||||||
|
<a title="Disclaimer" href="" data-bs-toggle="modal" data-bs-target="#disclaimerModal"><i class="text-muted d-lg-none fs-5 bi bi-info-square-fill"></i><span class="d-none d-lg-inline small">Disclaimer</span></a><span class="d-none d-lg-inline small"> | </span>
|
||||||
|
{% endif %}
|
||||||
|
<a title="Back to top" href="#"><i class="text-muted d-lg-none fs-5 bi bi-arrow-up-left-square-fill"></i><span class="d-none d-lg-inline small">Back to top</span></a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- Modal -->
|
||||||
|
<div class="modal fade" id="disclaimerModal" tabindex="-1" aria-labelledby="disclaimerLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-scrollable modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5" id="disclaimerLabel">Disclaimer</h1>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
{{ site_conf.disclaimer | safe }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
39
ram/portal/templates/includes/symbols.html
Normal file
39
ram/portal/templates/includes/symbols.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<!-- Modal -->
|
||||||
|
<div class="modal fade" id="symbolsModal" tabindex="-1" aria-labelledby="symbolsLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-scrollable modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5" id="symbolsLabel">Symbols</h1>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th colspan="2" scope="row">DCC symbols</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="table-group-divider">
|
||||||
|
<tr>
|
||||||
|
<th scope="row" class="text-center"><i class="bi bi-ban small"></i></th>
|
||||||
|
<td>No socket</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row" class="text-center"><i class="bi bi-dice-6 small"></i></th>
|
||||||
|
<td>Socket available</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row" class="text-center"><i class="bi bi-arrow-bar-left"></i><i class="bi bi-cpu-fill small"></i></th>
|
||||||
|
<td>Decoder installed</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row" class="text-center"><i class="bi bi-arrow-bar-left"></i><i class="bi bi-volume-up-fill small"></i></th>
|
||||||
|
<td>Sound decoder installed</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@@ -1,7 +1,7 @@
|
|||||||
{% extends "cards.html" %}
|
{% extends "cards.html" %}
|
||||||
{% block pagination %}
|
{% block pagination %}
|
||||||
{% if data.has_other_pages %}
|
{% if data.has_other_pages %}
|
||||||
<nav aria-label="Page navigation example">
|
<nav aria-label="Page navigation">
|
||||||
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
|
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
|
||||||
{% if data.has_previous %}
|
{% if data.has_previous %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
|
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
{% block pagination %}
|
{% block pagination %}
|
||||||
{% if data.has_other_pages %}
|
{% if data.has_other_pages %}
|
||||||
<nav aria-label="Page navigation example">
|
<nav aria-label="Page navigation">
|
||||||
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
|
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
|
||||||
{% if data.has_previous %}
|
{% if data.has_previous %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
{% block pagination %}
|
{% block pagination %}
|
||||||
{% if data.has_other_pages %}
|
{% if data.has_other_pages %}
|
||||||
{% with data.0.item.category as c %}
|
{% with data.0.item.category as c %}
|
||||||
<nav aria-label="Page navigation example">
|
<nav aria-label="Page navigation">
|
||||||
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
|
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
|
||||||
{% if data.has_previous %}
|
{% if data.has_previous %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
|
{% load dcc %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
{% if rolling_stock.tags.all %}
|
{% if rolling_stock.tags.all %}
|
||||||
@@ -8,7 +9,10 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<small class="text-muted">Updated {{ rolling_stock.updated_time | date:"M d, Y H:i" }}</small>
|
{% if not rolling_stock.published %}
|
||||||
|
<span class="badge text-bg-warning">Unpublished</span> |
|
||||||
|
{% endif %}
|
||||||
|
<small class="text-body-secondary">Updated {{ rolling_stock.updated_time | date:"M d, Y H:i" }}</small>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block carousel %}
|
{% block carousel %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -48,10 +52,9 @@
|
|||||||
<button class="nav-link" id="nav-model-tab" data-bs-toggle="tab" data-bs-target="#nav-model" type="button" role="tab" aria-controls="nav-model" aria-selected="false">Model</button>
|
<button class="nav-link" id="nav-model-tab" data-bs-toggle="tab" data-bs-target="#nav-model" type="button" role="tab" aria-controls="nav-model" aria-selected="false">Model</button>
|
||||||
<button class="nav-link" id="nav-class-tab" data-bs-toggle="tab" data-bs-target="#nav-class" type="button" role="tab" aria-controls="nav-class" aria-selected="false">Class</button>
|
<button class="nav-link" id="nav-class-tab" data-bs-toggle="tab" data-bs-target="#nav-class" type="button" role="tab" aria-controls="nav-class" aria-selected="false">Class</button>
|
||||||
<button class="nav-link" id="nav-company-tab" data-bs-toggle="tab" data-bs-target="#nav-company" type="button" role="tab" aria-controls="nav-company" aria-selected="false">Company</button>
|
<button class="nav-link" id="nav-company-tab" data-bs-toggle="tab" data-bs-target="#nav-company" type="button" role="tab" aria-controls="nav-company" aria-selected="false">Company</button>
|
||||||
{% if rolling_stock.decoder %}<button class="nav-link" id="nav-dcc-tab" data-bs-toggle="tab" data-bs-target="#nav-dcc" type="button" role="tab" aria-controls="nav-dcc" aria-selected="false">DCC</button>{% endif %}
|
{% if rolling_stock.decoder or rolling_stock.decoder_interface %}<button class="nav-link" id="nav-dcc-tab" data-bs-toggle="tab" data-bs-target="#nav-dcc" type="button" role="tab" aria-controls="nav-dcc" aria-selected="false">DCC</button>{% endif %}
|
||||||
{% if documents or decoder_documents %}<button class="nav-link" id="nav-documents-tab" data-bs-toggle="tab" data-bs-target="#nav-documents" type="button" role="tab" aria-controls="nav-documents" aria-selected="false">Documents</button>{% endif %}
|
{% if documents or decoder_documents %}<button class="nav-link" id="nav-documents-tab" data-bs-toggle="tab" data-bs-target="#nav-documents" type="button" role="tab" aria-controls="nav-documents" aria-selected="false">Documents</button>{% endif %}
|
||||||
{% if journal %}<button class="nav-link" id="nav-journal-tab" data-bs-toggle="tab" data-bs-target="#nav-journal" type="button" role="tab" aria-controls="nav-journal" aria-selected="false">Journal</button>{% endif %}
|
{% if journal %}<button class="nav-link" id="nav-journal-tab" data-bs-toggle="tab" data-bs-target="#nav-journal" type="button" role="tab" aria-controls="nav-journal" aria-selected="false">Journal</button>{% endif %}
|
||||||
{% if rolling_stock.notes %}<button class="nav-link" id="nav-notes-tab" data-bs-toggle="tab" data-bs-target="#nav-notes" type="button" role="tab" aria-controls="nav-notes" aria-selected="false">Notes</button>{% endif %}
|
|
||||||
{% if set %}<button class="nav-link" id="nav-set-tab" data-bs-toggle="tab" data-bs-target="#nav-set" type="button" role="tab" aria-controls="nav-set" aria-selected="false">Set</button>{% endif %}
|
{% if set %}<button class="nav-link" id="nav-set-tab" data-bs-toggle="tab" data-bs-target="#nav-set" type="button" role="tab" aria-controls="nav-set" aria-selected="false">Set</button>{% endif %}
|
||||||
{% if consists %}<button class="nav-link" id="nav-consists-tab" data-bs-toggle="tab" data-bs-target="#nav-consists" type="button" role="tab" aria-controls="nav-consists" aria-selected="false">Consists</button>{% endif %}
|
{% if consists %}<button class="nav-link" id="nav-consists-tab" data-bs-toggle="tab" data-bs-target="#nav-consists" type="button" role="tab" aria-controls="nav-consists" aria-selected="false">Consists</button>{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -60,10 +63,9 @@
|
|||||||
<option value="nav-model">Model</option>
|
<option value="nav-model">Model</option>
|
||||||
<option value="nav-class">Class</option>
|
<option value="nav-class">Class</option>
|
||||||
<option value="nav-company">Company</option>
|
<option value="nav-company">Company</option>
|
||||||
{% if rolling_stock.decoder %}<option value="nav-dcc">DCC</option>{% endif %}
|
{% if rolling_stock.decoder or rolling_stock.decoder_interface %}<option value="nav-dcc">DCC</option>{% endif %}
|
||||||
{% if documents or decoder_documents %}<option value="nav-documents">Documents</option>{% endif %}
|
{% if documents or decoder_documents %}<option value="nav-documents">Documents</option>{% endif %}
|
||||||
{% if journal %}<option value="nav-journal">Journal</option>{% endif %}
|
{% if journal %}<option value="nav-journal">Journal</option>{% endif %}
|
||||||
{% if rolling_stock.notes %}<option value="nav-notes">Notes</option>{% endif %}
|
|
||||||
{% if set %}<option value="nav-set">Set</option>{% endif %}
|
{% if set %}<option value="nav-set">Set</option>{% endif %}
|
||||||
{% if consists %}<option value="nav-consists">Consists</option>{% endif %}
|
{% if consists %}<option value="nav-consists">Consists</option>{% endif %}
|
||||||
</select>
|
</select>
|
||||||
@@ -73,7 +75,14 @@
|
|||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="2" scope="row">Data</th>
|
<th colspan="2" scope="row">
|
||||||
|
Rolling stock
|
||||||
|
<div class="mt-1 float-end">
|
||||||
|
{% if company.freelance %}
|
||||||
|
<span class="badge text-bg-secondary">Freelance</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="table-group-divider">
|
<tbody class="table-group-divider">
|
||||||
@@ -87,6 +96,12 @@
|
|||||||
<a href="{% url 'filtered' _filter="company" search=company.slug %}">{{ company }}</a> {{ company.extended_name_pp }}
|
<a href="{% url 'filtered' _filter="company" search=company.slug %}">{{ company }}</a> {{ company.extended_name_pp }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Country</th>
|
||||||
|
<td>
|
||||||
|
<img src="{{ company.country.flag }}" alt="{{ company.country }}"> {{ company.country.name }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Class</th>
|
<th scope="row">Class</th>
|
||||||
<td>{{ class.identifier }}</td>
|
<td>{{ class.identifier }}</td>
|
||||||
@@ -128,19 +143,31 @@
|
|||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="2" scope="row">DCC data</th>
|
<th colspan="2" scope="row">DCC data
|
||||||
|
<a class="mt-1 float-end text-reset text-decoration-none" title="Symbols" href="" data-bs-toggle="modal" data-bs-target="#symbolsModal">{% dcc rolling_stock %}</a>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="table-group-divider">
|
<tbody class="table-group-divider">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-33" scope="row">Interface</th>
|
<th class="w-33" scope="row">Interface</th>
|
||||||
<td>{{ rolling_stock.get_decoder_interface_display }}</td>
|
<td>{{ rolling_stock.get_decoder_interface }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if rolling_stock.decoder %}
|
{% if rolling_stock.decoder %}
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Decoder</th>
|
<th scope="row">Decoder</th>
|
||||||
<td>{{ rolling_stock.decoder }}</td>
|
<td>{{ rolling_stock.decoder }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Sound</th>
|
||||||
|
<td>
|
||||||
|
{% if rolling_stock.decoder.sound %}
|
||||||
|
<i class="bi bi-check-circle-fill text-success"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-x-circle-fill text-secondary"></i>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Address</th>
|
<th scope="row">Address</th>
|
||||||
<td>{{ rolling_stock.address }}</td>
|
<td>{{ rolling_stock.address }}</td>
|
||||||
@@ -151,7 +178,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-pane" id="nav-model" role="tabpanel" aria-labelledby="nav-model-tab">
|
<div class="tab-pane" id="nav-model" role="tabpanel" aria-labelledby="nav-model-tab">
|
||||||
{{ rolling_stock.description | safe }}
|
|
||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -183,12 +209,40 @@
|
|||||||
<th scope="row">Production year</th>
|
<th scope="row">Production year</th>
|
||||||
<td>{{ rolling_stock.production_year | default:"-" }}</td>
|
<td>{{ rolling_stock.production_year | default:"-" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% if rolling_stock.description %}
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Purchase date</th>
|
<th class="w-33" scope="row">Description</th>
|
||||||
|
<td>{{ rolling_stock.description | safe }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% if request.user.is_staff %}
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th colspan="2" scope="row">Purchase</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="table-group-divider">
|
||||||
|
<tr>
|
||||||
|
<th class="w-33" scope="row">Shop</th>
|
||||||
|
<td>
|
||||||
|
{{ rolling_stock.shop | default:"-" }}
|
||||||
|
{% if rolling_stock.shop.website %} <a href="{{ rolling_stock.shop.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th class="w-33" scope="row">Purchase date</th>
|
||||||
<td>{{ rolling_stock.purchase_date | default:"-" }}</td>
|
<td>{{ rolling_stock.purchase_date | default:"-" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">Price ({{ site_conf.currency }})</th>
|
||||||
|
<td>{{ rolling_stock.price | default:"-" }}</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{% endif %}
|
||||||
{% if properties %}
|
{% if properties %}
|
||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -208,7 +262,6 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-pane" id="nav-class" role="tabpanel" aria-labelledby="nav-class-tab">
|
<div class="tab-pane" id="nav-class" role="tabpanel" aria-labelledby="nav-class-tab">
|
||||||
{{ class.description | safe }}
|
|
||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -227,11 +280,20 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Manufacturer</th>
|
<th scope="row">Manufacturer</th>
|
||||||
<td>
|
<td>
|
||||||
{%if class.manufacturer %}
|
{% for m in class.manufacturer.all %}
|
||||||
<a href="{% url 'filtered' _filter="manufacturer" search=class.manufacturer.slug %}">{{ class.manufacturer }}</a>{% if class.manufacturer.website %} <a href="{{ class.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
|
{% if not forloop.first %} / {% endif %}
|
||||||
{% else %}-{% endif %}
|
<a href="{% url 'filtered' _filter="manufacturer" search=m.slug %}">{{ m }}</a>{% if m.website %} <a href="{{ m.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
|
||||||
|
{% empty %}
|
||||||
|
-
|
||||||
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% if class.description %}
|
||||||
|
<tr>
|
||||||
|
<th class="w-33" scope="row">Description</th>
|
||||||
|
<td>{{ class.description | safe }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{% if class_properties %}
|
{% if class_properties %}
|
||||||
@@ -256,7 +318,12 @@
|
|||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="2" scope="row">Company data</th>
|
<th colspan="2" scope="row">
|
||||||
|
Company data
|
||||||
|
{% if company.freelance %}
|
||||||
|
<span class="mt-1 float-end badge text-bg-secondary">Freelance</span>
|
||||||
|
{% endif %}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="table-group-divider">
|
<tbody class="table-group-divider">
|
||||||
@@ -268,18 +335,16 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-33" scope="row">Name</th>
|
<th class="w-33" scope="row">Name</th>
|
||||||
<td><a href="{% url 'filtered' _filter="company" search=company.slug %}">{{ company.name }}</a> {{ company.extended_name_pp }}</td>
|
<td>
|
||||||
|
<a href="{% url 'filtered' _filter="company" search=company.slug %}">{{ company.name }}</a> {{ company.extended_name_pp }}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-33" scope="row">Country</th>
|
<th class="w-33" scope="row">Country</th>
|
||||||
<td>{{ company.country.name }} <img src="{{ company.country.flag }}" alt="{{ company.country }}">
|
<td>
|
||||||
|
<img src="{{ company.country.flag }}" alt="{{ company.country }}"> {{ company.country.name }}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if company.freelance %}
|
|
||||||
<tr>
|
|
||||||
<th class="w-33" scope="row">Notes</th>
|
|
||||||
<td>A <em>freelance</em> company</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,36 +352,55 @@
|
|||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th colspan="2" scope="row">Decoder data</th>
|
<th colspan="2" scope="row">Decoder data
|
||||||
|
<a class="mt-1 float-end text-reset text-decoration-none" title="Symbols" href="" data-bs-toggle="modal" data-bs-target="#symbolsModal">{% dcc rolling_stock %}</a>
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="table-group-divider">
|
<tbody class="table-group-divider">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Interface</th>
|
<th class="w-33" scope="row">Interface</th>
|
||||||
<td>{{ rolling_stock.get_decoder_interface_display }}</td>
|
<td>{{ rolling_stock.get_decoder_interface }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% if rolling_stock.decoder %}
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-33" scope="row">Address</th>
|
<th scope="row">Manufacturer</th>
|
||||||
<td>{{ rolling_stock.address }}</td>
|
<td>{{ rolling_stock.decoder.manufacturer | default:"-" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Name</th>
|
<th scope="row">Name</th>
|
||||||
<td>{{ rolling_stock.decoder.name }}</td>
|
<td>{{ rolling_stock.decoder.name }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<th scope="row">Manufacturer</th>
|
|
||||||
<td>{{ rolling_stock.decoder.manufacturer|default:"-" }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Version</th>
|
<th scope="row">Version</th>
|
||||||
<td>{{ rolling_stock.decoder.version }}</td>
|
<td>{{ rolling_stock.decoder.version | default:"-"}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Sound</th>
|
<th scope="row">Sound</th>
|
||||||
<td>{{ rolling_stock.decoder.sound | yesno:"Yes,No" }}</td>
|
<td>
|
||||||
|
{% if rolling_stock.decoder.sound %}
|
||||||
|
<i class="bi bi-check-circle-fill text-success"></i>
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-x-circle-fill text-secondary"></i>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th colspan="2" scope="row">Decoder configuration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="table-group-divider">
|
||||||
|
<tr>
|
||||||
|
<th class="w-33" scope="row">Address</th>
|
||||||
|
<td>{{ rolling_stock.address }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-pane" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
|
<div class="tab-pane" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
|
||||||
{% if documents %}
|
{% if documents %}
|
||||||
@@ -373,9 +457,6 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-pane" id="nav-notes" role="tabpanel" aria-labelledby="nav-notes-tab">
|
|
||||||
{{ rolling_stock.notes | safe }}
|
|
||||||
</div>
|
|
||||||
<div class="tab-pane" id="nav-set" role="tabpanel" aria-labelledby="nav-set-tab">
|
<div class="tab-pane" id="nav-set" role="tabpanel" aria-labelledby="nav-set-tab">
|
||||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3 mb-3">
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3 mb-3">
|
||||||
{% for d in set %}
|
{% for d in set %}
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
{% block pagination %}
|
{% block pagination %}
|
||||||
{% if data.has_other_pages %}
|
{% if data.has_other_pages %}
|
||||||
<nav aria-label="Page navigation example">
|
<nav aria-label="Page navigation">
|
||||||
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
|
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
|
||||||
{% if data.has_previous %}
|
{% if data.has_previous %}
|
||||||
<li class="page-item">
|
<li class="page-item">
|
||||||
|
36
ram/portal/templatetags/dcc.py
Normal file
36
ram/portal/templatetags/dcc.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from django import template
|
||||||
|
from django.utils.html import format_html
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def dcc(object):
|
||||||
|
socket = (
|
||||||
|
'<i class="bi bi-ban small"></i>'
|
||||||
|
)
|
||||||
|
decoder = ''
|
||||||
|
if object.decoder_interface is not None:
|
||||||
|
socket = (
|
||||||
|
f'<abbr title="{object.get_decoder_interface()}">'
|
||||||
|
f'<i class="bi bi-dice-6"></i></abbr>'
|
||||||
|
)
|
||||||
|
if object.decoder:
|
||||||
|
if object.decoder.sound:
|
||||||
|
decoder = (
|
||||||
|
f'<abbr title="{object.decoder}">'
|
||||||
|
'<i class="bi bi-volume-up-fill"></i></abbr>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
decoder = (
|
||||||
|
f'<abbr title="{object.decoder}'
|
||||||
|
f'({object.get_decoder_interface()})">'
|
||||||
|
'<i class="bi bi-cpu-fill"></i></abbr>'
|
||||||
|
)
|
||||||
|
|
||||||
|
if decoder:
|
||||||
|
return format_html(
|
||||||
|
f'{socket} <i class="bi bi-arrow-bar-left"></i>{decoder}'
|
||||||
|
)
|
||||||
|
|
||||||
|
return format_html(socket)
|
@@ -7,7 +7,7 @@ from urllib.parse import unquote
|
|||||||
from django.views import View
|
from django.views import View
|
||||||
from django.http import Http404, HttpResponseBadRequest
|
from django.http import Http404, HttpResponseBadRequest
|
||||||
from django.db.utils import OperationalError, ProgrammingError
|
from django.db.utils import OperationalError, ProgrammingError
|
||||||
from django.db.models import Q
|
from django.db.models import F, Q, Count
|
||||||
from django.shortcuts import render, get_object_or_404, get_list_or_404
|
from django.shortcuts import render, get_object_or_404, get_list_or_404
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
@@ -259,16 +259,17 @@ class GetManufacturerItem(View):
|
|||||||
manufacturer,
|
manufacturer,
|
||||||
# all returned records must have the same `item_number``;
|
# all returned records must have the same `item_number``;
|
||||||
# just pick it up the first result, otherwise `search`
|
# just pick it up the first result, otherwise `search`
|
||||||
roster.first.item_number if roster else search,
|
roster[0].item_number if roster else search,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
roster = (
|
roster = (
|
||||||
RollingStock.objects.get_published(request.user)
|
RollingStock.objects.get_published(request.user)
|
||||||
.order_by(*get_order_by_field())
|
|
||||||
.filter(
|
.filter(
|
||||||
Q(manufacturer=manufacturer)
|
Q(manufacturer=manufacturer)
|
||||||
| Q(rolling_class__manufacturer=manufacturer)
|
| Q(rolling_class__manufacturer=manufacturer)
|
||||||
)
|
)
|
||||||
|
.distinct()
|
||||||
|
.order_by(*get_order_by_field())
|
||||||
)
|
)
|
||||||
title = "Manufacturer: {0}".format(manufacturer)
|
title = "Manufacturer: {0}".format(manufacturer)
|
||||||
|
|
||||||
@@ -344,6 +345,13 @@ class GetObjectsFiltered(View):
|
|||||||
)
|
)
|
||||||
for item in books:
|
for item in books:
|
||||||
data.append({"type": "book", "item": item})
|
data.append({"type": "book", "item": item})
|
||||||
|
catalogs = (
|
||||||
|
Catalog.objects.get_published(request.user)
|
||||||
|
.filter(query_2nd)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
for item in catalogs:
|
||||||
|
data.append({"type": "catalog", "item": item})
|
||||||
except NameError:
|
except NameError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -482,7 +490,40 @@ class Manufacturers(GetData):
|
|||||||
item_type = "manufacturer"
|
item_type = "manufacturer"
|
||||||
|
|
||||||
def get_data(self, request):
|
def get_data(self, request):
|
||||||
return Manufacturer.objects.filter(self.filter)
|
return (
|
||||||
|
Manufacturer.objects.filter(self.filter).annotate(
|
||||||
|
num_rollingstock=(
|
||||||
|
Count(
|
||||||
|
"rollingstock",
|
||||||
|
filter=Q(
|
||||||
|
rollingstock__in=(
|
||||||
|
RollingStock.objects.get_published(
|
||||||
|
request.user
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
distinct=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
num_rollingclass=(
|
||||||
|
Count(
|
||||||
|
"rollingclass__rolling_stock",
|
||||||
|
filter=Q(
|
||||||
|
rollingclass__rolling_stock__in=(
|
||||||
|
RollingStock.objects.get_published(
|
||||||
|
request.user
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
distinct=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(num_items=F("num_rollingstock") + F("num_rollingclass"))
|
||||||
|
.order_by("name")
|
||||||
|
)
|
||||||
|
|
||||||
# overload get method to filter by category
|
# overload get method to filter by category
|
||||||
def get(self, request, category, page=1):
|
def get(self, request, category, page=1):
|
||||||
@@ -498,16 +539,69 @@ class Companies(GetData):
|
|||||||
item_type = "company"
|
item_type = "company"
|
||||||
|
|
||||||
def get_data(self, request):
|
def get_data(self, request):
|
||||||
return Company.objects.all()
|
return (
|
||||||
|
Company.objects.annotate(
|
||||||
|
num_rollingstock=(
|
||||||
|
Count(
|
||||||
|
"rollingclass__rolling_stock",
|
||||||
|
filter=Q(
|
||||||
|
rollingclass__rolling_stock__in=(
|
||||||
|
RollingStock.objects.get_published(
|
||||||
|
request.user
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
distinct=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
num_consists=(
|
||||||
|
Count(
|
||||||
|
"consist",
|
||||||
|
filter=Q(
|
||||||
|
consist__in=(
|
||||||
|
Consist.objects.get_published(request.user)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
distinct=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(num_items=F("num_rollingstock") + F("num_consists"))
|
||||||
|
.order_by("name")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Scales(GetData):
|
class Scales(GetData):
|
||||||
title = "Scales"
|
title = "Scales"
|
||||||
item_type = "scale"
|
item_type = "scale"
|
||||||
queryset = Scale.objects.all()
|
|
||||||
|
|
||||||
def get_data(self, request):
|
def get_data(self, request):
|
||||||
return Scale.objects.all()
|
return (
|
||||||
|
Scale.objects.annotate(
|
||||||
|
num_rollingstock=Count(
|
||||||
|
"rollingstock",
|
||||||
|
filter=Q(
|
||||||
|
rollingstock__in=RollingStock.objects.get_published(
|
||||||
|
request.user
|
||||||
|
)
|
||||||
|
),
|
||||||
|
distinct=True,
|
||||||
|
),
|
||||||
|
num_consists=Count(
|
||||||
|
"consist",
|
||||||
|
filter=Q(
|
||||||
|
consist__in=Consist.objects.get_published(
|
||||||
|
request.user
|
||||||
|
)
|
||||||
|
),
|
||||||
|
distinct=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.annotate(num_items=F("num_rollingstock") + F("num_consists"))
|
||||||
|
.order_by("-ratio_int", "-tracks", "scale")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Types(GetData):
|
class Types(GetData):
|
||||||
@@ -515,7 +609,16 @@ class Types(GetData):
|
|||||||
item_type = "rolling_stock_type"
|
item_type = "rolling_stock_type"
|
||||||
|
|
||||||
def get_data(self, request):
|
def get_data(self, request):
|
||||||
return RollingStockType.objects.all()
|
return RollingStockType.objects.annotate(
|
||||||
|
num_items=Count(
|
||||||
|
"rollingclass__rolling_stock",
|
||||||
|
filter=Q(
|
||||||
|
rollingclass__rolling_stock__in=(
|
||||||
|
RollingStock.objects.get_published(request.user)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).order_by("order")
|
||||||
|
|
||||||
|
|
||||||
class Books(GetData):
|
class Books(GetData):
|
||||||
@@ -549,15 +652,17 @@ class GetBookCatalog(View):
|
|||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
book_properties = book.property.get_public(request.user)
|
properties = book.property.get_public(request.user)
|
||||||
|
documents = book.document.get_public(request.user)
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"bookshelf/book.html",
|
"bookshelf/book.html",
|
||||||
{
|
{
|
||||||
"title": book,
|
"title": book,
|
||||||
"book_properties": book_properties,
|
|
||||||
"book": book,
|
"book": book,
|
||||||
"type": selector
|
"documents": documents,
|
||||||
|
"properties": properties,
|
||||||
|
"type": selector,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
from ram.utils import git_suffix
|
from ram.utils import git_suffix
|
||||||
|
|
||||||
__version__ = "0.14.0"
|
__version__ = "0.17.13"
|
||||||
__version__ += git_suffix(__file__)
|
__version__ += git_suffix(__file__)
|
||||||
|
@@ -2,3 +2,21 @@ from django.contrib import admin
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
admin.site.site_header = settings.SITE_NAME
|
admin.site.site_header = settings.SITE_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def publish(modeladmin, request, queryset):
|
||||||
|
for obj in queryset:
|
||||||
|
obj.published = True
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
|
||||||
|
publish.short_description = "Publish selected items"
|
||||||
|
|
||||||
|
|
||||||
|
def unpublish(modeladmin, request, queryset):
|
||||||
|
for obj in queryset:
|
||||||
|
obj.published = False
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
|
||||||
|
unpublish.short_description = "Unpublish selected items"
|
||||||
|
@@ -15,6 +15,7 @@ DEBUG = False
|
|||||||
|
|
||||||
# SECURITY WARNING: cache middlewares must be loaded before cookies one
|
# SECURITY WARNING: cache middlewares must be loaded before cookies one
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
"ram.middleware.DisableClientSideCachingMiddleware",
|
||||||
"django.middleware.cache.UpdateCacheMiddleware",
|
"django.middleware.cache.UpdateCacheMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.cache.FetchFromCacheMiddleware",
|
"django.middleware.cache.FetchFromCacheMiddleware",
|
||||||
@@ -22,8 +23,8 @@ MIDDLEWARE = [
|
|||||||
|
|
||||||
CACHES = {
|
CACHES = {
|
||||||
"default": {
|
"default": {
|
||||||
"BACKEND": "django.core.cache.backends.redis.RedisCache",
|
"BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
|
||||||
"LOCATION": "redis://127.0.0.1:6379",
|
"LOCATION": settings.STORAGE_DIR / "cache",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
20
ram/ram/middleware.py
Normal file
20
ram/ram/middleware.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from django.core.cache import cache
|
||||||
|
from django.utils.cache import add_never_cache_headers, get_cache_key
|
||||||
|
|
||||||
|
|
||||||
|
class DisableClientSideCachingMiddleware:
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
response = self.get_response(request)
|
||||||
|
|
||||||
|
# Check if the cache key exists for this request
|
||||||
|
cache_key = get_cache_key(request)
|
||||||
|
cache_hit = "MISS"
|
||||||
|
if cache_key and cache.get(cache_key):
|
||||||
|
cache_hit = "HIT"
|
||||||
|
response['X-Cache-Hit'] = cache_hit
|
||||||
|
|
||||||
|
add_never_cache_headers(response)
|
||||||
|
return response
|
@@ -11,6 +11,7 @@ from ram.managers import PublicManager
|
|||||||
|
|
||||||
class BaseModel(models.Model):
|
class BaseModel(models.Model):
|
||||||
uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||||
|
description = tinymce.HTMLField(blank=True)
|
||||||
notes = tinymce.HTMLField(blank=True)
|
notes = tinymce.HTMLField(blank=True)
|
||||||
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)
|
||||||
@@ -26,9 +27,9 @@ class Document(models.Model):
|
|||||||
description = models.CharField(max_length=128, blank=True)
|
description = models.CharField(max_length=128, blank=True)
|
||||||
file = models.FileField(
|
file = models.FileField(
|
||||||
upload_to="files/",
|
upload_to="files/",
|
||||||
storage=DeduplicatedStorage(),
|
|
||||||
)
|
)
|
||||||
private = models.BooleanField(default=False)
|
creation_time = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_time = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
@@ -37,16 +38,35 @@ class Document(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return "{0}".format(os.path.basename(self.file.name))
|
return "{0}".format(os.path.basename(self.file.name))
|
||||||
|
|
||||||
|
@property
|
||||||
def filename(self):
|
def filename(self):
|
||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size(self):
|
||||||
|
kb = self.file.size / 1024.0
|
||||||
|
if kb < 1024:
|
||||||
|
size = "{0} KB".format(round(kb))
|
||||||
|
else:
|
||||||
|
size = "{0} MB".format(round(kb / 1024.0))
|
||||||
|
return size
|
||||||
|
|
||||||
def download(self):
|
def download(self):
|
||||||
return mark_safe(
|
return mark_safe(
|
||||||
'<a href="{0}" target="_blank">Link</a>'.format(self.file.url)
|
'<a href="{0}" target="_blank">Link</a>'.format(self.file.url)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PrivateDocument(Document):
|
||||||
|
private = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="Document will be visible only to logged users",
|
||||||
|
)
|
||||||
objects = PublicManager()
|
objects = PublicManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
class Image(models.Model):
|
class Image(models.Model):
|
||||||
order = models.PositiveIntegerField(default=0, blank=False, null=False)
|
order = models.PositiveIntegerField(default=0, blank=False, null=False)
|
||||||
|
@@ -50,6 +50,7 @@ INSTALLED_APPS = [
|
|||||||
"portal",
|
"portal",
|
||||||
# "driver", # uncomment this to enable the "driver" API
|
# "driver", # uncomment this to enable the "driver" API
|
||||||
"metadata",
|
"metadata",
|
||||||
|
"repository",
|
||||||
"roster",
|
"roster",
|
||||||
"consist",
|
"consist",
|
||||||
"bookshelf",
|
"bookshelf",
|
||||||
@@ -142,6 +143,12 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
|||||||
MEDIA_URL = "media/"
|
MEDIA_URL = "media/"
|
||||||
MEDIA_ROOT = STORAGE_DIR / "media"
|
MEDIA_ROOT = STORAGE_DIR / "media"
|
||||||
|
|
||||||
|
REST_ENABLED = False # Set to True to enable the REST API
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
|
||||||
|
"PAGE_SIZE": 5,
|
||||||
|
}
|
||||||
|
|
||||||
TINYMCE_DEFAULT_CONFIG = {
|
TINYMCE_DEFAULT_CONFIG = {
|
||||||
"height": "500px",
|
"height": "500px",
|
||||||
"menubar": False,
|
"menubar": False,
|
||||||
@@ -170,6 +177,9 @@ SITE_NAME = "Railroad Assets Manger"
|
|||||||
# The file must be placed in the root of the 'static' folder
|
# The file must be placed in the root of the 'static' folder
|
||||||
DEFAULT_CARD_IMAGE = "coming_soon.svg"
|
DEFAULT_CARD_IMAGE = "coming_soon.svg"
|
||||||
|
|
||||||
|
# Second level ALT separator for CSV files (e.g. for properties)
|
||||||
|
CSV_SEPARATOR_ALT = ";"
|
||||||
|
|
||||||
DECODER_INTERFACES = [
|
DECODER_INTERFACES = [
|
||||||
(0, "Built-in"),
|
(0, "Built-in"),
|
||||||
(1, "NEM651"),
|
(1, "NEM651"),
|
||||||
|
@@ -32,9 +32,6 @@ urlpatterns = [
|
|||||||
path("tinymce/upload_image", UploadImage.as_view(), name="upload_image"),
|
path("tinymce/upload_image", UploadImage.as_view(), name="upload_image"),
|
||||||
path("portal/", include("portal.urls")),
|
path("portal/", include("portal.urls")),
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("api/v1/consist/", include("consist.urls")),
|
|
||||||
path("api/v1/roster/", include("roster.urls")),
|
|
||||||
path("api/v1/bookshelf/", include("bookshelf.urls")),
|
|
||||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
||||||
# Enable the "/dcc" routing only if the "driver" app is active
|
# Enable the "/dcc" routing only if the "driver" app is active
|
||||||
@@ -43,10 +40,21 @@ if apps.is_installed("driver"):
|
|||||||
path("api/v1/dcc/", include("driver.urls")),
|
path("api/v1/dcc/", include("driver.urls")),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if settings.REST_ENABLED:
|
||||||
|
urlpatterns += [
|
||||||
|
path("api/v1/consist/", include("consist.urls")),
|
||||||
|
path("api/v1/roster/", include("roster.urls")),
|
||||||
|
path("api/v1/bookshelf/", include("bookshelf.urls")),
|
||||||
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
if apps.is_installed("debug_toolbar"):
|
||||||
|
urlpatterns += [
|
||||||
|
path("__debug__/", include("debug_toolbar.urls")),
|
||||||
|
]
|
||||||
|
if settings.REST_ENABLED:
|
||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
from rest_framework.schemas import get_schema_view
|
from rest_framework.schemas import get_schema_view
|
||||||
|
|
||||||
urlpatterns += [
|
urlpatterns += [
|
||||||
path(
|
path(
|
||||||
"swagger/",
|
"swagger/",
|
||||||
@@ -66,7 +74,3 @@ if settings.DEBUG:
|
|||||||
name="openapi-schema",
|
name="openapi-schema",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
if apps.is_installed("debug_toolbar"):
|
|
||||||
urlpatterns += [
|
|
||||||
path("__debug__/", include("debug_toolbar.urls")),
|
|
||||||
]
|
|
||||||
|
@@ -1,7 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
|
import csv
|
||||||
import hashlib
|
import hashlib
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
from django.http import HttpResponse
|
||||||
from django.utils.html import format_html
|
from django.utils.html import format_html
|
||||||
from django.utils.text import slugify as django_slugify
|
from django.utils.text import slugify as django_slugify
|
||||||
from django.core.files.storage import FileSystemStorage
|
from django.core.files.storage import FileSystemStorage
|
||||||
@@ -57,3 +59,15 @@ def slugify(string, custom_separator=None):
|
|||||||
if custom_separator is not None:
|
if custom_separator is not None:
|
||||||
string = string.replace("-", custom_separator)
|
string = string.replace("-", custom_separator)
|
||||||
return string
|
return string
|
||||||
|
|
||||||
|
|
||||||
|
def generate_csv(header, data, filename):
|
||||||
|
response = HttpResponse(content_type="text/csv")
|
||||||
|
response["Content-Disposition"] = 'attachment; filename="{}"'.format(
|
||||||
|
filename
|
||||||
|
)
|
||||||
|
writer = csv.writer(response)
|
||||||
|
writer.writerow(header)
|
||||||
|
for row in data:
|
||||||
|
writer.writerow(row)
|
||||||
|
return response
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user