mirror of
https://github.com/daniviga/django-ram.git
synced 2025-08-06 14:17:49 +02:00
Compare commits
99 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 | |||
f4af44c41c | |||
e3ae18a4bd | |||
2695358d9b | |||
3fbae0417e | |||
7a51ab9095 | |||
dad40b3ee7 | |||
d55bce6e78 | |||
cbf6c942b9 | |||
64f616d89f | |||
f8246c31d3 | |||
005ea11011 | |||
83444266cb | |||
1a3b30ace3 | |||
21c99f73c3 | |||
b5b88f7714 | |||
119d25ede6 | |||
41d9338459 | |||
32785f321a | |||
5b975355a1 | |||
7d8c539e47 | |||
9a832bca82 | |||
54254bda7d | |||
1c07c6a7a9 | |||
61b6d7a84e | |||
d0854a4cff | |||
456272b93a
|
|||
35905bafdf | |||
6a9f37ca05 |
2
.github/workflows/django.yml
vendored
2
.github/workflows/django.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
strategy:
|
||||
max-parallel: 2
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12']
|
||||
python-version: ['3.12', '3.13']
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -131,3 +131,4 @@ dmypy.json
|
||||
ram/storage/
|
||||
!ram/storage/.gitignore
|
||||
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.
|
||||
|
||||
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).
|
||||
|
||||
@@ -40,23 +41,49 @@ Project is based on the following technologies and components:
|
||||
|
||||
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?
|
||||
- [vim-arduino](https://github.com/stevearc/vim-arduino): another IDE? No thanks
|
||||
- [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
|
||||
|
||||
- Python 3.10+
|
||||
- Python 3.11+
|
||||
- A USB port when running Arduino hardware (and adaptors if you have a Mac)
|
||||
|
||||
## Web portal installation
|
||||
|
||||
### Using containers
|
||||
|
||||
coming soon
|
||||
Do it yourself, otherwise, raise a request :)
|
||||
|
||||
### Manual installation
|
||||
|
||||
@@ -83,6 +110,8 @@ $ python manage.py migrate
|
||||
$ python manage.py createsuperuser
|
||||
```
|
||||
|
||||
To load some sample metadata, see the [sample_data folder instructions](./sample_data/README.md).
|
||||
|
||||
Run Django
|
||||
|
||||
```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,
|
||||
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
|
||||
using an ESP8266 module or a [Mega+WiFi board](https://dcc-ex.com/advanced-setup/supported-microcontrollers/wifi-mega.html)).
|
||||
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, 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`.
|
||||
Settings may need to be customized based on your setup.
|
||||
You'll need [namp-ncat](https://nmap.org/ncat/) , and `stty` to setup the serial port.
|
||||
|
||||
> [!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
|
||||
|
||||
```bash
|
||||
$ cd daemons
|
||||
$ podman build -t dcc/net-to-serial .
|
||||
$ podman run --group-add keep-groups --device /dev/ttyACM0 -p 2560:2560 dcc/net-to-serial
|
||||
```
|
||||
|
||||
### Manual setup
|
||||
|
||||
```bash
|
||||
$ cd daemons
|
||||
$ pip install -r requirements.txt
|
||||
$ python ./net-to-serial.py
|
||||
$ cd connector
|
||||
$ podman build -t dcc/connector .
|
||||
$ podman run -d --group-add keep-groups --device /dev/ttyACM0:/dev/arduino -p 2560:2560 dcc/connector
|
||||
```
|
||||
|
||||
### 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`
|
||||
daemon into a container. To run it:
|
||||
A [QEMU AVR based simulator](daemons/simulator/README.md) running DCC++ EX is bundled togheter with the connector
|
||||
into a container. To run it:
|
||||
|
||||
```bash
|
||||
$ cd daemons/simulator
|
||||
$ podman build -t dcc/net-to-serial:sim .
|
||||
$ podman run --init --cpus 0.1 -d -p 2560:2560 dcc/net-to-serial:sim
|
||||
$ cd connector/simulator
|
||||
$ podman build -t dcc/connector: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
|
||||
|
||||
@@ -146,15 +184,12 @@ To be continued ...
|
||||

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

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

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

|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
Submodule arduino/CommandStation-EX updated: 87073b0d36...3b15491608
Submodule arduino/WebThrottle-EX updated: c67e4080d0...eb43d7906f
Submodule arduino/arduino-cli updated: 048415c5e6...fa6eafcbbe
Submodule arduino/dcc-ex.github.io updated: 9acc446358...a0f886b69f
Submodule arduino/vim-arduino updated: 111db616db...2ded67cdf0
9
connector/Dockerfile
Normal file
9
connector/Dockerfile
Normal file
@@ -0,0 +1,9 @@
|
||||
FROM alpine:edge
|
||||
|
||||
RUN apk add --no-cache coreutils nmap-ncat
|
||||
|
||||
EXPOSE 2560/tcp
|
||||
|
||||
SHELL ["/bin/ash", "-c"]
|
||||
CMD stty -F /dev/arduino -echo 115200 && \
|
||||
ncat -n -k -l 2560 </dev/arduino >/dev/arduino
|
19
connector/README.md
Normal file
19
connector/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Use a container to implement a serial to net bridge
|
||||
|
||||
This uses `ncat` from [nmap](https://nmap.org/ncat/) to bridge a serial port to a network port. The serial port is passed to the Podman command (eg. `/dev/ttyACM0`) and the network port is `2560`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Other variants of `nc` or `ncat` may not work as expected.
|
||||
|
||||
## Build and run the container
|
||||
|
||||
```bash
|
||||
$ podman buil -t dcc/bridge .
|
||||
$ podman run -d --group-add keep-groups --device=/dev/ttyACM0:/dev/arduino -p 2560:2560 --name dcc-bridge dcc/bridge
|
||||
```
|
||||
|
||||
It can be tested with `telnet`:
|
||||
|
||||
```bash
|
||||
$ telnet localhost 2560
|
||||
```
|
BIN
connector/simulator/CommandStation-EX-uno-13488e1.elf
Executable file
BIN
connector/simulator/CommandStation-EX-uno-13488e1.elf
Executable file
Binary file not shown.
8
connector/simulator/Dockerfile
Normal file
8
connector/simulator/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM dcc/bridge
|
||||
|
||||
RUN apk update && apk add --no-cache qemu-system-avr \
|
||||
&& mkdir /io
|
||||
ADD start.sh /usr/local/bin
|
||||
ADD CommandStation-EX*.elf /io
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/start.sh"]
|
13
connector/simulator/README.md
Normal file
13
connector/simulator/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Connector and AVR simulator
|
||||
|
||||
> [!WARNING]
|
||||
> The simulator is intended for light development and testing purposes only and far from being a complete replacement for a real hardware.
|
||||
|
||||
`qemu-system-avr` tries to use all the CPU cycles (leaving a CPU core stuck at 100%; limit CPU core usage to 10% via `--cpus 0.1`. It can be adjusted on slower machines.
|
||||
|
||||
```bash
|
||||
$ podman build -t dcc/connector:sim .
|
||||
$ podman run --init --cpus 0.1 -d -p 2560:2560 dcc/connector:sim
|
||||
```
|
||||
|
||||
All traffic will be collected on the container's `stderr` for debugging purposes.
|
@@ -7,7 +7,5 @@ if [ -c /dev/pts/0 ]; then
|
||||
PTY=1
|
||||
fi
|
||||
|
||||
sed -i "s/ttyACM0/pts\/${PTY}/" /opt/dcc/config.ini
|
||||
|
||||
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,52 +1,346 @@
|
||||
import html
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html, strip_tags
|
||||
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
|
||||
|
||||
from bookshelf.models import BookProperty, BookImage, Book, Author, Publisher
|
||||
from ram.admin import publish, unpublish
|
||||
from ram.utils import generate_csv
|
||||
from portal.utils import get_site_conf
|
||||
from repository.models import BookDocument, CatalogDocument
|
||||
from bookshelf.models import (
|
||||
BaseBookProperty,
|
||||
BaseBookImage,
|
||||
Book,
|
||||
Author,
|
||||
Publisher,
|
||||
Catalog,
|
||||
)
|
||||
|
||||
|
||||
class BookImageInline(SortableInlineAdminMixin, admin.TabularInline):
|
||||
model = BookImage
|
||||
model = BaseBookImage
|
||||
min_num = 0
|
||||
extra = 0
|
||||
readonly_fields = ("image_thumbnail",)
|
||||
classes = ["collapse"]
|
||||
verbose_name = "Image"
|
||||
|
||||
|
||||
class BookPropertyInline(admin.TabularInline):
|
||||
model = BookProperty
|
||||
model = BaseBookProperty
|
||||
min_num = 0
|
||||
extra = 0
|
||||
autocomplete_fields = ("property",)
|
||||
verbose_name = "Property"
|
||||
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)
|
||||
class BookAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
inlines = (BookImageInline, BookPropertyInline,)
|
||||
inlines = (
|
||||
BookPropertyInline,
|
||||
BookImageInline,
|
||||
BookDocInline,
|
||||
)
|
||||
list_display = (
|
||||
"title",
|
||||
"get_authors",
|
||||
"get_publisher",
|
||||
"publication_year",
|
||||
"number_of_pages"
|
||||
"number_of_pages",
|
||||
"published",
|
||||
)
|
||||
autocomplete_fields = ("authors", "publisher", "shop")
|
||||
readonly_fields = ("invoices", "creation_time", "updated_time")
|
||||
search_fields = ("title", "publisher__name", "authors__last_name")
|
||||
list_filter = ("publisher__name", "authors")
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"published",
|
||||
"title",
|
||||
"authors",
|
||||
"publisher",
|
||||
"ISBN",
|
||||
"language",
|
||||
"number_of_pages",
|
||||
"publication_year",
|
||||
"description",
|
||||
"tags",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Purchase data",
|
||||
{
|
||||
"fields": (
|
||||
"shop",
|
||||
"purchase_date",
|
||||
"price",
|
||||
"invoices",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Notes",
|
||||
{"classes": ("collapse",), "fields": ("notes",)},
|
||||
),
|
||||
(
|
||||
"Audit",
|
||||
{
|
||||
"classes": ("collapse",),
|
||||
"fields": (
|
||||
"creation_time",
|
||||
"updated_time",
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
form = super().get_form(request, obj, **kwargs)
|
||||
form.base_fields["price"].label = "Price ({})".format(
|
||||
get_site_conf().currency
|
||||
)
|
||||
return form
|
||||
|
||||
@admin.display(description="Invoices")
|
||||
def invoices(self, obj):
|
||||
if obj.invoice.exists():
|
||||
html = "<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")
|
||||
def get_publisher(self, obj):
|
||||
return obj.publisher.name
|
||||
|
||||
@admin.display(description="Authors")
|
||||
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)
|
||||
class AuthorAdmin(admin.ModelAdmin):
|
||||
search_fields = ("first_name", "last_name",)
|
||||
search_fields = (
|
||||
"first_name",
|
||||
"last_name",
|
||||
)
|
||||
list_filter = ("last_name",)
|
||||
|
||||
|
||||
@admin.register(Publisher)
|
||||
class PublisherAdmin(admin.ModelAdmin):
|
||||
list_display = ("name", "country")
|
||||
list_display = ("name", "country_flag")
|
||||
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)
|
||||
class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
inlines = (
|
||||
BookPropertyInline,
|
||||
BookImageInline,
|
||||
CatalogDocInline,
|
||||
)
|
||||
list_display = (
|
||||
"__str__",
|
||||
"manufacturer",
|
||||
"years",
|
||||
"get_scales",
|
||||
"published",
|
||||
)
|
||||
autocomplete_fields = ("manufacturer",)
|
||||
readonly_fields = ("invoices", "creation_time", "updated_time")
|
||||
search_fields = ("manufacturer__name", "years", "scales__scale")
|
||||
list_filter = ("manufacturer__name", "publication_year", "scales__scale")
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"published",
|
||||
"manufacturer",
|
||||
"years",
|
||||
"scales",
|
||||
"ISBN",
|
||||
"language",
|
||||
"number_of_pages",
|
||||
"publication_year",
|
||||
"description",
|
||||
"tags",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Purchase data",
|
||||
{
|
||||
"fields": (
|
||||
"shop",
|
||||
"purchase_date",
|
||||
"price",
|
||||
"invoices",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Notes",
|
||||
{"classes": ("collapse",), "fields": ("notes",)},
|
||||
),
|
||||
(
|
||||
"Audit",
|
||||
{
|
||||
"classes": ("collapse",),
|
||||
"fields": (
|
||||
"creation_time",
|
||||
"updated_time",
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
form = super().get_form(request, obj, **kwargs)
|
||||
form.base_fields["price"].label = "Price ({})".format(
|
||||
get_site_conf().currency
|
||||
)
|
||||
return form
|
||||
|
||||
@admin.display(description="Invoices")
|
||||
def invoices(self, obj):
|
||||
if obj.invoice.exists():
|
||||
html = "<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]
|
||||
|
@@ -12,7 +12,7 @@ from django.conf import settings
|
||||
|
||||
def move_images(apps, schema_editor):
|
||||
sys.stdout.write("\n Processing files. Please await...")
|
||||
for r in bookshelf.models.BookImage.objects.all():
|
||||
for r in bookshelf.models.BaseBookImage.objects.all():
|
||||
fname = os.path.basename(r.image.path)
|
||||
new_image = bookshelf.models.book_image_upload(r, fname)
|
||||
new_path = os.path.join(settings.MEDIA_ROOT, new_image)
|
||||
@@ -31,19 +31,21 @@ class Migration(migrations.Migration):
|
||||
("bookshelf", "0008_alter_author_options_alter_publisher_options"),
|
||||
]
|
||||
|
||||
# Migration is stale and shouldn't be used since model hes been heavily
|
||||
# modified since then. Leaving it here for reference.
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="bookimage",
|
||||
name="image",
|
||||
field=models.ImageField(
|
||||
blank=True,
|
||||
null=True,
|
||||
storage=ram.utils.DeduplicatedStorage,
|
||||
upload_to=bookshelf.models.book_image_upload,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
move_images,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
# migrations.AlterField(
|
||||
# model_name="bookimage",
|
||||
# name="image",
|
||||
# field=models.ImageField(
|
||||
# blank=True,
|
||||
# null=True,
|
||||
# storage=ram.utils.DeduplicatedStorage,
|
||||
# upload_to=bookshelf.models.book_image_upload,
|
||||
# ),
|
||||
# ),
|
||||
# migrations.RunPython(
|
||||
# move_images,
|
||||
# reverse_code=migrations.RunPython.noop
|
||||
# ),
|
||||
]
|
||||
|
18
ram/bookshelf/migrations/0014_book_published.py
Normal file
18
ram/bookshelf/migrations/0014_book_published.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-04 13:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookshelf", "0013_book_description"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="book",
|
||||
name="published",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
18
ram/bookshelf/migrations/0015_alter_book_authors.py
Normal file
18
ram/bookshelf/migrations/0015_alter_book_authors.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-26 22:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookshelf", "0014_book_published"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="book",
|
||||
name="authors",
|
||||
field=models.ManyToManyField(blank=True, to="bookshelf.author"),
|
||||
),
|
||||
]
|
141
ram/bookshelf/migrations/0016_basebook_book_catalogue.py
Normal file
141
ram/bookshelf/migrations/0016_basebook_book_catalogue.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-27 16:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def basebook_to_book(apps, schema_editor):
|
||||
basebook = apps.get_model("bookshelf", "BaseBook")
|
||||
book = apps.get_model("bookshelf", "Book")
|
||||
for row in basebook.objects.all():
|
||||
b = book.objects.create(
|
||||
basebook_ptr=row,
|
||||
title=row.old_title,
|
||||
publisher=row.old_publisher,
|
||||
)
|
||||
b.authors.set(row.old_authors.all())
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookshelf", "0015_alter_book_authors"),
|
||||
("metadata", "0019_alter_scale_gauge"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="Book",
|
||||
options={"ordering": ["creation_time"]},
|
||||
),
|
||||
migrations.RenameModel(
|
||||
old_name="BookImage",
|
||||
new_name="BaseBookImage",
|
||||
),
|
||||
migrations.RenameModel(
|
||||
old_name="BookProperty",
|
||||
new_name="BaseBookProperty",
|
||||
),
|
||||
migrations.RenameModel(
|
||||
old_name="Book",
|
||||
new_name="BaseBook",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="basebook",
|
||||
old_name="title",
|
||||
new_name="old_title",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="basebook",
|
||||
old_name="authors",
|
||||
new_name="old_authors",
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name="basebook",
|
||||
old_name="publisher",
|
||||
new_name="old_publisher",
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="basebookimage",
|
||||
options={"ordering": ["order"], "verbose_name_plural": "Images"},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Book",
|
||||
fields=[
|
||||
(
|
||||
"basebook_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="bookshelf.basebook",
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=200)),
|
||||
(
|
||||
"authors",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
to="bookshelf.author"
|
||||
),
|
||||
),
|
||||
(
|
||||
"publisher",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="bookshelf.publisher"
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["title"],
|
||||
},
|
||||
),
|
||||
migrations.RunPython(
|
||||
basebook_to_book,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="basebook",
|
||||
name="old_title",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="basebook",
|
||||
name="old_authors",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="basebook",
|
||||
name="old_publisher",
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Catalog",
|
||||
fields=[
|
||||
(
|
||||
"basebook_ptr",
|
||||
models.OneToOneField(
|
||||
auto_created=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
parent_link=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
to="bookshelf.basebook",
|
||||
),
|
||||
),
|
||||
("years", models.CharField(max_length=12)),
|
||||
(
|
||||
"manufacturer",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="metadata.manufacturer",
|
||||
),
|
||||
),
|
||||
("scales", models.ManyToManyField(to="metadata.scale")),
|
||||
],
|
||||
options={
|
||||
"ordering": ["manufacturer", "publication_year"],
|
||||
},
|
||||
bases=("bookshelf.basebook",),
|
||||
),
|
||||
]
|
@@ -0,0 +1,52 @@
|
||||
# Generated by Django 5.1.2 on 2024-12-22 20:38
|
||||
|
||||
import django.db.models.deletion
|
||||
import ram.utils
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookshelf", "0016_basebook_book_catalogue"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="basebook",
|
||||
options={},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="BaseBookDocument",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("description", models.CharField(blank=True, max_length=128)),
|
||||
(
|
||||
"file",
|
||||
models.FileField(
|
||||
storage=ram.utils.DeduplicatedStorage(), upload_to="files/"
|
||||
),
|
||||
),
|
||||
("private", models.BooleanField(default=False)),
|
||||
(
|
||||
"book",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="document",
|
||||
to="bookshelf.basebook",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("book", "file")},
|
||||
},
|
||||
),
|
||||
]
|
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.4 on 2024-12-22 20:44
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookshelf", "0017_alter_basebook_options_basebookdocument"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="basebookdocument",
|
||||
options={"verbose_name_plural": "Documents"},
|
||||
),
|
||||
]
|
36
ram/bookshelf/migrations/0019_basebook_price.py
Normal file
36
ram/bookshelf/migrations/0019_basebook_price.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 5.1.4 on 2024-12-29 17:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def price_to_property(apps, schema_editor):
|
||||
basebook = apps.get_model("bookshelf", "BaseBook")
|
||||
for row in basebook.objects.all():
|
||||
prop = row.property.filter(property__name__icontains="price")
|
||||
for p in prop:
|
||||
try:
|
||||
row.price = float(p.value)
|
||||
except ValueError:
|
||||
pass
|
||||
row.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookshelf", "0018_alter_basebookdocument_options"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="basebook",
|
||||
name="price",
|
||||
field=models.DecimalField(
|
||||
blank=True, decimal_places=2, max_digits=10, null=True
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
price_to_property,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.4 on 2025-01-08 22:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookshelf", "0019_basebook_price"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name="basebookdocument",
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="basebookdocument",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("book", "file"), name="unique_book_file"
|
||||
),
|
||||
),
|
||||
]
|
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.1.4 on 2025-01-18 11:20
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookshelf", "0020_alter_basebookdocument_unique_together_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="basebookdocument",
|
||||
name="creation_time",
|
||||
field=models.DateTimeField(
|
||||
auto_now_add=True, default=django.utils.timezone.now
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="basebookdocument",
|
||||
name="updated_time",
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="basebookdocument",
|
||||
name="private",
|
||||
field=models.BooleanField(
|
||||
default=False, help_text="Document will be visible only to logged users"
|
||||
),
|
||||
),
|
||||
]
|
50
ram/bookshelf/migrations/0022_basebook_shop.py
Normal file
50
ram/bookshelf/migrations/0022_basebook_shop.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 5.1.4 on 2025-01-26 14:32
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def shop_from_property(apps, schema_editor):
|
||||
basebook = apps.get_model("bookshelf", "BaseBook")
|
||||
shop_model = apps.get_model("metadata", "Shop")
|
||||
for row in basebook.objects.all():
|
||||
property = row.property.filter(
|
||||
property__name__icontains="shop"
|
||||
).first()
|
||||
if property:
|
||||
shop, created = shop_model.objects.get_or_create(
|
||||
name=property.value,
|
||||
defaults={"on_line": False}
|
||||
)
|
||||
|
||||
row.shop = shop
|
||||
row.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookshelf", "0021_basebookdocument_creation_time_and_more"),
|
||||
("metadata", "0023_shop"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveConstraint(
|
||||
model_name="basebookdocument",
|
||||
name="unique_book_file",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="basebook",
|
||||
name="shop",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="metadata.shop",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
shop_from_property,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
17
ram/bookshelf/migrations/0023_delete_basebookdocument.py
Normal file
17
ram/bookshelf/migrations/0023_delete_basebookdocument.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.4 on 2025-02-09 13:47
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookshelf", "0022_basebook_shop"),
|
||||
("repository", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.DeleteModel(
|
||||
name="BaseBookDocument",
|
||||
),
|
||||
]
|
@@ -1,16 +1,13 @@
|
||||
import os
|
||||
import shutil
|
||||
from uuid import uuid4
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from django_countries.fields import CountryField
|
||||
|
||||
from tinymce import models as tinymce
|
||||
|
||||
from metadata.models import Tag
|
||||
from ram.utils import DeduplicatedStorage
|
||||
from ram.models import Image, PropertyInstance
|
||||
from ram.models import BaseModel, Image, PropertyInstance
|
||||
from metadata.models import Scale, Manufacturer, Shop, Tag
|
||||
|
||||
|
||||
class Publisher(models.Model):
|
||||
@@ -35,15 +32,12 @@ class Author(models.Model):
|
||||
def __str__(self):
|
||||
return f"{self.last_name}, {self.first_name}"
|
||||
|
||||
@property
|
||||
def short_name(self):
|
||||
return f"{self.last_name} {self.first_name[0]}."
|
||||
|
||||
|
||||
class Book(models.Model):
|
||||
uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
title = models.CharField(max_length=200)
|
||||
authors = models.ManyToManyField(Author)
|
||||
publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE)
|
||||
class BaseBook(BaseModel):
|
||||
ISBN = models.CharField(max_length=17, blank=True) # 13 + dashes
|
||||
language = models.CharField(
|
||||
max_length=7,
|
||||
@@ -52,26 +46,19 @@ class Book(models.Model):
|
||||
)
|
||||
number_of_pages = 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)
|
||||
tags = models.ManyToManyField(
|
||||
Tag, related_name="bookshelf", blank=True
|
||||
)
|
||||
notes = tinymce.HTMLField(blank=True)
|
||||
creation_time = models.DateTimeField(auto_now_add=True)
|
||||
updated_time = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["title"]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def publisher_name(self):
|
||||
return self.publisher.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("book", kwargs={"uuid": self.uuid})
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
shutil.rmtree(
|
||||
@@ -80,7 +67,7 @@ class Book(models.Model):
|
||||
),
|
||||
ignore_errors=True
|
||||
)
|
||||
super(Book, self).delete(*args, **kwargs)
|
||||
super(BaseBook, self).delete(*args, **kwargs)
|
||||
|
||||
|
||||
def book_image_upload(instance, filename):
|
||||
@@ -92,9 +79,9 @@ def book_image_upload(instance, filename):
|
||||
)
|
||||
|
||||
|
||||
class BookImage(Image):
|
||||
class BaseBookImage(Image):
|
||||
book = models.ForeignKey(
|
||||
Book, on_delete=models.CASCADE, related_name="image"
|
||||
BaseBook, on_delete=models.CASCADE, related_name="image"
|
||||
)
|
||||
image = models.ImageField(
|
||||
upload_to=book_image_upload,
|
||||
@@ -102,11 +89,67 @@ class BookImage(Image):
|
||||
)
|
||||
|
||||
|
||||
class BookProperty(PropertyInstance):
|
||||
class BaseBookProperty(PropertyInstance):
|
||||
book = models.ForeignKey(
|
||||
Book,
|
||||
BaseBook,
|
||||
on_delete=models.CASCADE,
|
||||
null=False,
|
||||
blank=False,
|
||||
related_name="property",
|
||||
)
|
||||
|
||||
|
||||
class Book(BaseBook):
|
||||
title = models.CharField(max_length=200)
|
||||
authors = models.ManyToManyField(Author, blank=True)
|
||||
publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
ordering = ["title"]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@property
|
||||
def publisher_name(self):
|
||||
return self.publisher.name
|
||||
|
||||
@property
|
||||
def authors_list(self):
|
||||
return ", ".join(a.short_name for a in self.authors.all())
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"bookshelf_item",
|
||||
kwargs={"selector": "book", "uuid": self.uuid}
|
||||
)
|
||||
|
||||
|
||||
class Catalog(BaseBook):
|
||||
manufacturer = models.ForeignKey(
|
||||
Manufacturer,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
years = models.CharField(max_length=12)
|
||||
scales = models.ManyToManyField(Scale)
|
||||
|
||||
class Meta:
|
||||
ordering = ["manufacturer", "publication_year"]
|
||||
|
||||
def __str__(self):
|
||||
# if the object is new, return an empty string to avoid
|
||||
# calling self.scales.all() which would raise a infinite recursion
|
||||
if self.pk is None:
|
||||
return str() # empty string
|
||||
scales = self.get_scales()
|
||||
return "%s %s %s" % (self.manufacturer.name, self.years, scales)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"bookshelf_item",
|
||||
kwargs={"selector": "catalog", "uuid": self.uuid}
|
||||
)
|
||||
|
||||
def get_scales(self):
|
||||
return "/".join([s.scale for s in self.scales.all()])
|
||||
get_scales.short_description = "Scales"
|
||||
|
@@ -1,6 +1,10 @@
|
||||
from rest_framework import serializers
|
||||
from bookshelf.models import Book, Author, Publisher
|
||||
from metadata.serializers import TagSerializer
|
||||
from bookshelf.models import Book, Catalog, Author, Publisher
|
||||
from metadata.serializers import (
|
||||
ScaleSerializer,
|
||||
ManufacturerSerializer,
|
||||
TagSerializer
|
||||
)
|
||||
|
||||
|
||||
class AuthorSerializer(serializers.ModelSerializer):
|
||||
@@ -22,5 +26,26 @@ class BookSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
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")
|
||||
|
@@ -1,7 +1,9 @@
|
||||
from django.urls import path
|
||||
from bookshelf.views import BookList, BookGet
|
||||
from bookshelf.views import BookList, BookGet, CatalogList, CatalogGet
|
||||
|
||||
urlpatterns = [
|
||||
path("book/list", BookList.as_view()),
|
||||
path("book/get/<str:uuid>", BookGet.as_view()),
|
||||
path("book/get/<uuid:uuid>", BookGet.as_view()),
|
||||
path("catalog/list", CatalogList.as_view()),
|
||||
path("catalog/get/<uuid:uuid>", CatalogGet.as_view()),
|
||||
]
|
||||
|
@@ -1,18 +1,40 @@
|
||||
from rest_framework.generics import ListAPIView, RetrieveAPIView
|
||||
from rest_framework.schemas.openapi import AutoSchema
|
||||
|
||||
from bookshelf.models import Book
|
||||
from bookshelf.serializers import BookSerializer
|
||||
from ram.views import CustomLimitOffsetPagination
|
||||
from bookshelf.models import Book, Catalog
|
||||
from bookshelf.serializers import BookSerializer, CatalogSerializer
|
||||
|
||||
|
||||
class BookList(ListAPIView):
|
||||
queryset = Book.objects.all()
|
||||
serializer_class = BookSerializer
|
||||
pagination_class = CustomLimitOffsetPagination
|
||||
|
||||
def get_queryset(self):
|
||||
return Book.objects.get_published(self.request.user)
|
||||
|
||||
|
||||
class BookGet(RetrieveAPIView):
|
||||
queryset = Book.objects.all()
|
||||
serializer_class = BookSerializer
|
||||
lookup_field = "uuid"
|
||||
|
||||
schema = AutoSchema(operation_id_base="retrieveBookByUUID")
|
||||
|
||||
def get_queryset(self):
|
||||
return Book.objects.get_published(self.request.user)
|
||||
|
||||
|
||||
class CatalogList(ListAPIView):
|
||||
serializer_class = CatalogSerializer
|
||||
pagination_class = CustomLimitOffsetPagination
|
||||
|
||||
def get_queryset(self):
|
||||
return Catalog.objects.get_published(self.request.user)
|
||||
|
||||
|
||||
class CatalogGet(RetrieveAPIView):
|
||||
serializer_class = CatalogSerializer
|
||||
lookup_field = "uuid"
|
||||
schema = AutoSchema(operation_id_base="retrieveCatalogByUUID")
|
||||
|
||||
def get_queryset(self):
|
||||
return Book.objects.get_published(self.request.user)
|
||||
|
@@ -1,14 +1,42 @@
|
||||
from django.contrib import admin
|
||||
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
|
||||
import html
|
||||
|
||||
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
|
||||
|
||||
|
||||
# for future reference
|
||||
# class ConsistItemInlineFormSet(CustomInlineFormSetMixin, BaseInlineFormSet):
|
||||
# def clean(self):
|
||||
# super().clean()
|
||||
|
||||
|
||||
class ConsistItemInline(SortableInlineAdminMixin, admin.TabularInline):
|
||||
model = ConsistItem
|
||||
min_num = 1
|
||||
extra = 0
|
||||
readonly_fields = ("address", "type", "company", "era")
|
||||
autocomplete_fields = ("rolling_stock",)
|
||||
readonly_fields = (
|
||||
"preview",
|
||||
"published",
|
||||
"scale",
|
||||
"manufacturer",
|
||||
"item_number",
|
||||
"company",
|
||||
"type",
|
||||
"era",
|
||||
"address",
|
||||
)
|
||||
|
||||
|
||||
@admin.register(Consist)
|
||||
@@ -18,26 +46,38 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
"creation_time",
|
||||
"updated_time",
|
||||
)
|
||||
list_display = ("identifier", "company", "era")
|
||||
list_filter = list_display
|
||||
search_fields = list_display
|
||||
list_filter = ("company__name", "era", "scale", "published")
|
||||
list_display = ("__str__",) + list_filter + ("country_flag",)
|
||||
search_fields = ("identifier",) + list_filter
|
||||
save_as = True
|
||||
|
||||
@admin.display(description="Country")
|
||||
def country_flag(self, obj):
|
||||
return format_html(
|
||||
'<img src="{}" /> {}'.format(obj.country.flag, obj.country)
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"published",
|
||||
"identifier",
|
||||
"consist_address",
|
||||
"company",
|
||||
"scale",
|
||||
"era",
|
||||
"consist_address",
|
||||
"description",
|
||||
"image",
|
||||
"notes",
|
||||
"tags",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Notes",
|
||||
{"classes": ("collapse",), "fields": ("notes",)},
|
||||
),
|
||||
(
|
||||
"Audit",
|
||||
{
|
||||
@@ -49,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]
|
||||
|
18
ram/consist/migrations/0012_consist_published.py
Normal file
18
ram/consist/migrations/0012_consist_published.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-04 12:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("consist", "0011_alter_consist_consist_address_alter_consist_era"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="consist",
|
||||
name="published",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.1.4 on 2025-01-08 21:50
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("consist", "0012_consist_published"),
|
||||
("roster", "0030_rollingstock_price"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name="consistitem",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("consist", "rolling_stock"), name="one_stock_per_consist"
|
||||
),
|
||||
),
|
||||
]
|
18
ram/consist/migrations/0014_alter_consistitem_order.py
Normal file
18
ram/consist/migrations/0014_alter_consistitem_order.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-01-08 22:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("consist", "0013_consistitem_one_stock_per_consist"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="consistitem",
|
||||
name="order",
|
||||
field=models.PositiveIntegerField(default=1000),
|
||||
),
|
||||
]
|
19
ram/consist/migrations/0015_consist_description.py
Normal file
19
ram/consist/migrations/0015_consist_description.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.1.4 on 2025-01-27 21:15
|
||||
|
||||
import tinymce.models
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("consist", "0014_alter_consistitem_order"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="consist",
|
||||
name="description",
|
||||
field=tinymce.models.HTMLField(blank=True),
|
||||
),
|
||||
]
|
18
ram/consist/migrations/0016_alter_consistitem_order.py
Normal file
18
ram/consist/migrations/0016_alter_consistitem_order.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.4 on 2025-04-27 19:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("consist", "0015_consist_description"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="consistitem",
|
||||
name="order",
|
||||
field=models.PositiveIntegerField(),
|
||||
),
|
||||
]
|
42
ram/consist/migrations/0017_consist_scale.py
Normal file
42
ram/consist/migrations/0017_consist_scale.py
Normal file
@@ -0,0 +1,42 @@
|
||||
# Generated by Django 5.1.4 on 2025-05-01 09:51
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def set_scale(apps, schema_editor):
|
||||
Consist = apps.get_model("consist", "Consist")
|
||||
|
||||
for consist in Consist.objects.all():
|
||||
try:
|
||||
consist.scale = consist.consist_item.first().rolling_stock.scale
|
||||
consist.save()
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("consist", "0016_alter_consistitem_order"),
|
||||
(
|
||||
"metadata",
|
||||
"0024_remove_genericdocument_tags_delete_decoderdocument_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="consist",
|
||||
name="scale",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="metadata.scale",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(
|
||||
set_scale,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
25
ram/consist/migrations/0018_alter_consist_scale.py
Normal file
25
ram/consist/migrations/0018_alter_consist_scale.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.1.4 on 2025-05-02 11:33
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("consist", "0017_consist_scale"),
|
||||
(
|
||||
"metadata",
|
||||
"0024_remove_genericdocument_tags_delete_decoderdocument_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="consist",
|
||||
name="scale",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="metadata.scale"
|
||||
),
|
||||
),
|
||||
]
|
@@ -1,18 +1,18 @@
|
||||
import os
|
||||
|
||||
from uuid import uuid4
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.text import Truncator
|
||||
from django.dispatch import receiver
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from tinymce import models as tinymce
|
||||
|
||||
from ram.models import BaseModel
|
||||
from ram.utils import DeduplicatedStorage
|
||||
from metadata.models import Company, Tag
|
||||
from metadata.models import Company, Scale, Tag
|
||||
from roster.models import RollingStock
|
||||
|
||||
|
||||
class Consist(models.Model):
|
||||
uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
class Consist(BaseModel):
|
||||
identifier = models.CharField(max_length=128, unique=False)
|
||||
tags = models.ManyToManyField(Tag, related_name="consist", blank=True)
|
||||
consist_address = models.SmallIntegerField(
|
||||
@@ -27,15 +27,13 @@ class Consist(models.Model):
|
||||
blank=True,
|
||||
help_text="Era or epoch of the consist",
|
||||
)
|
||||
scale = models.ForeignKey(Scale, on_delete=models.CASCADE)
|
||||
image = models.ImageField(
|
||||
upload_to=os.path.join("images", "consists"),
|
||||
storage=DeduplicatedStorage,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
notes = tinymce.HTMLField(blank=True)
|
||||
creation_time = models.DateTimeField(auto_now_add=True)
|
||||
updated_time = models.DateTimeField(auto_now=True)
|
||||
|
||||
def __str__(self):
|
||||
return "{0} {1}".format(self.company, self.identifier)
|
||||
@@ -43,6 +41,25 @@ class Consist(models.Model):
|
||||
def get_absolute_url(self):
|
||||
return reverse("consist", kwargs={"uuid": self.uuid})
|
||||
|
||||
@property
|
||||
def length(self):
|
||||
return self.consist_item.count()
|
||||
|
||||
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:
|
||||
ordering = ["company", "-creation_time"]
|
||||
|
||||
@@ -52,22 +69,81 @@ class ConsistItem(models.Model):
|
||||
Consist, on_delete=models.CASCADE, related_name="consist_item"
|
||||
)
|
||||
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"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["consist", "rolling_stock"],
|
||||
name="one_stock_per_consist"
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return "{0}".format(self.rolling_stock)
|
||||
|
||||
def type(self):
|
||||
return self.rolling_stock.rolling_class.type
|
||||
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):
|
||||
return self.rolling_stock.published
|
||||
published.boolean = True
|
||||
|
||||
def preview(self):
|
||||
return self.rolling_stock.image.first().image_thumbnail(100)
|
||||
|
||||
@property
|
||||
def manufacturer(self):
|
||||
return Truncator(self.rolling_stock.manufacturer).chars(10)
|
||||
|
||||
@property
|
||||
def item_number(self):
|
||||
return self.rolling_stock.item_number
|
||||
|
||||
@property
|
||||
def scale(self):
|
||||
return self.rolling_stock.scale
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
return self.rolling_stock.rolling_class.type.type
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
return self.rolling_stock.address
|
||||
|
||||
@property
|
||||
def company(self):
|
||||
return self.rolling_stock.company()
|
||||
return self.rolling_stock.company
|
||||
|
||||
@property
|
||||
def era(self):
|
||||
return self.rolling_stock.era
|
||||
|
||||
|
||||
# Unpublish any consist that contains an unpublished rolling stock
|
||||
# this signal is called after a rolling stock is saved
|
||||
# it is hosted here to avoid circular imports
|
||||
@receiver(models.signals.post_save, sender=RollingStock)
|
||||
def post_save_unpublish_consist(sender, instance, *args, **kwargs):
|
||||
if not instance.published:
|
||||
consists = Consist.objects.filter(consist_item__rolling_stock=instance)
|
||||
for consist in consists:
|
||||
consist.published = False
|
||||
consist.save()
|
||||
|
@@ -21,4 +21,5 @@ class ConsistSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Consist
|
||||
fields = "__all__"
|
||||
exclude = ("notes",)
|
||||
read_only_fields = ("creation_time", "updated_time")
|
||||
|
@@ -1,15 +1,21 @@
|
||||
from rest_framework.generics import ListAPIView, RetrieveAPIView
|
||||
|
||||
from ram.views import CustomLimitOffsetPagination
|
||||
from consist.models import Consist
|
||||
from consist.serializers import ConsistSerializer
|
||||
|
||||
|
||||
class ConsistList(ListAPIView):
|
||||
queryset = Consist.objects.all()
|
||||
serializer_class = ConsistSerializer
|
||||
pagination_class = CustomLimitOffsetPagination
|
||||
|
||||
def get_queryset(self):
|
||||
return Consist.objects.get_published(self.request.user)
|
||||
|
||||
|
||||
class ConsistGet(RetrieveAPIView):
|
||||
queryset = Consist.objects.all()
|
||||
serializer_class = ConsistSerializer
|
||||
lookup_field = "uuid"
|
||||
|
||||
def get_queryset(self):
|
||||
return Consist.objects.get_published(self.request.user)
|
||||
|
@@ -1,11 +1,13 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from adminsortable2.admin import SortableAdminMixin
|
||||
|
||||
from repository.models import DecoderDocument
|
||||
from metadata.models import (
|
||||
Property,
|
||||
Decoder,
|
||||
DecoderDocument,
|
||||
Scale,
|
||||
Shop,
|
||||
Manufacturer,
|
||||
Company,
|
||||
Tag,
|
||||
@@ -45,18 +47,30 @@ class ScaleAdmin(admin.ModelAdmin):
|
||||
@admin.register(Company)
|
||||
class CompanyAdmin(admin.ModelAdmin):
|
||||
readonly_fields = ("logo_thumbnail",)
|
||||
list_display = ("name", "country")
|
||||
list_filter = list_display
|
||||
list_display = ("name", "country_flag")
|
||||
list_filter = ("name", "country")
|
||||
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)
|
||||
class ManufacturerAdmin(admin.ModelAdmin):
|
||||
readonly_fields = ("logo_thumbnail",)
|
||||
list_display = ("name", "category")
|
||||
list_display = ("name", "category", "country_flag")
|
||||
list_filter = ("category",)
|
||||
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)
|
||||
class TagAdmin(admin.ModelAdmin):
|
||||
@@ -70,3 +84,10 @@ class RollingStockTypeAdmin(SortableAdminMixin, admin.ModelAdmin):
|
||||
list_display = ("__str__",)
|
||||
list_filter = ("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,69 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-04 21:17
|
||||
|
||||
import django.db.migrations.operations.special
|
||||
import metadata.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def gen_ratio(apps, schema_editor):
|
||||
Scale = apps.get_model('metadata', 'Scale')
|
||||
for row in Scale.objects.all():
|
||||
row.ratio_int = metadata.models.calculate_ratio(row.ratio)
|
||||
row.save(update_fields=['ratio_int'])
|
||||
|
||||
|
||||
def convert_tarcks(apps, schema_editor):
|
||||
Scale = apps.get_model("metadata", "Scale")
|
||||
for row in Scale.objects.all():
|
||||
row.tracks = "".join(
|
||||
filter(
|
||||
lambda x: str.isdigit(x) or x == "." or x == ",",
|
||||
row.tracks
|
||||
)
|
||||
)
|
||||
row.save(update_fields=["tracks"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('metadata', '0017_alter_property_private'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='decoder',
|
||||
options={'ordering': ['manufacturer__name', 'name']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='scale',
|
||||
options={'ordering': ['ratio_int', 'scale']},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='scale',
|
||||
name='ratio_int',
|
||||
field=models.SmallIntegerField(default=0, editable=False),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=gen_ratio,
|
||||
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='scale',
|
||||
name='ratio',
|
||||
field=models.CharField(max_length=16, validators=[metadata.models.calculate_ratio]),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='scale',
|
||||
options={'ordering': ['-ratio_int', '-tracks', 'scale']},
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=convert_tarcks,
|
||||
reverse_code=django.db.migrations.operations.special.RunPython.noop,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='scale',
|
||||
name='tracks',
|
||||
field=models.FloatField(help_text='Distance between model tracks in mm'),
|
||||
),
|
||||
]
|
22
ram/metadata/migrations/0019_alter_scale_gauge.py
Normal file
22
ram/metadata/migrations/0019_alter_scale_gauge.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.1.2 on 2024-11-04 21:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("metadata", "0018_alter_decoder_options_alter_scale_options_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="scale",
|
||||
name="gauge",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Distance between real tracks. Please specify the unit (mm, in, ...)",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
]
|
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 5.1.4 on 2025-01-08 22:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("metadata", "0019_alter_scale_gauge"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name="decoderdocument",
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="rollingstocktype",
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="decoderdocument",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("decoder", "file"), name="unique_decoder_file"
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="rollingstocktype",
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=("category", "type"), name="unique_category_type"
|
||||
),
|
||||
),
|
||||
]
|
40
ram/metadata/migrations/0021_genericdocument.py
Normal file
40
ram/metadata/migrations/0021_genericdocument.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.1.4 on 2025-01-17 09:31
|
||||
|
||||
import ram.utils
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("metadata", "0020_alter_decoderdocument_unique_together_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="GenericDocument",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("description", models.CharField(blank=True, max_length=128)),
|
||||
(
|
||||
"file",
|
||||
models.FileField(
|
||||
storage=ram.utils.DeduplicatedStorage(), upload_to="files/"
|
||||
),
|
||||
),
|
||||
("private", models.BooleanField(default=False)),
|
||||
("tags", models.ManyToManyField(blank=True, to="metadata.tag")),
|
||||
],
|
||||
options={
|
||||
"verbose_name_plural": "Generic Documents",
|
||||
},
|
||||
),
|
||||
]
|
@@ -0,0 +1,66 @@
|
||||
# Generated by Django 5.1.4 on 2025-01-18 11:20
|
||||
|
||||
import django.utils.timezone
|
||||
import django_countries.fields
|
||||
import tinymce.models
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("metadata", "0021_genericdocument"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="decoderdocument",
|
||||
name="creation_time",
|
||||
field=models.DateTimeField(
|
||||
auto_now_add=True, default=django.utils.timezone.now
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="decoderdocument",
|
||||
name="updated_time",
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="genericdocument",
|
||||
name="creation_time",
|
||||
field=models.DateTimeField(
|
||||
auto_now_add=True, default=django.utils.timezone.now
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="genericdocument",
|
||||
name="notes",
|
||||
field=tinymce.models.HTMLField(blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="genericdocument",
|
||||
name="updated_time",
|
||||
field=models.DateTimeField(auto_now=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="manufacturer",
|
||||
name="country",
|
||||
field=django_countries.fields.CountryField(blank=True, max_length=2),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="decoderdocument",
|
||||
name="private",
|
||||
field=models.BooleanField(
|
||||
default=False, help_text="Document will be visible only to logged users"
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="genericdocument",
|
||||
name="private",
|
||||
field=models.BooleanField(
|
||||
default=False, help_text="Document will be visible only to logged users"
|
||||
),
|
||||
),
|
||||
]
|
40
ram/metadata/migrations/0023_shop.py
Normal file
40
ram/metadata/migrations/0023_shop.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# Generated by Django 5.1.4 on 2025-01-26 14:27
|
||||
|
||||
import django_countries.fields
|
||||
import django.db.models.functions.text
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("metadata", "0022_decoderdocument_creation_time_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Shop",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=128, unique=True)),
|
||||
(
|
||||
"country",
|
||||
django_countries.fields.CountryField(blank=True, max_length=2),
|
||||
),
|
||||
("website", models.URLField(blank=True)),
|
||||
("on_line", models.BooleanField(default=True)),
|
||||
("active", models.BooleanField(default=True)),
|
||||
],
|
||||
options={
|
||||
"ordering": [django.db.models.functions.text.Lower("name")],
|
||||
},
|
||||
),
|
||||
]
|
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.1.4 on 2025-02-09 13:47
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("metadata", "0023_shop"),
|
||||
("repository", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="genericdocument",
|
||||
name="tags",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="DecoderDocument",
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name="GenericDocument",
|
||||
),
|
||||
]
|
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.1.4 on 2025-05-04 20:45
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"metadata",
|
||||
"0024_remove_genericdocument_tags_delete_decoderdocument_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name="company",
|
||||
options={"ordering": ["slug"], "verbose_name_plural": "Companies"},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="manufacturer",
|
||||
options={"ordering": ["category", "slug"]},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name="tag",
|
||||
options={"ordering": ["slug"]},
|
||||
),
|
||||
]
|
@@ -3,10 +3,11 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
from django.dispatch.dispatcher import receiver
|
||||
from django.core.exceptions import ValidationError
|
||||
from django_countries.fields import CountryField
|
||||
|
||||
from ram.models import Document
|
||||
from ram.utils import DeduplicatedStorage, get_image_preview, slugify
|
||||
from ram.managers import PublicManager
|
||||
|
||||
|
||||
class Property(models.Model):
|
||||
@@ -23,6 +24,8 @@ class Property(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
objects = PublicManager()
|
||||
|
||||
|
||||
class Manufacturer(models.Model):
|
||||
name = models.CharField(max_length=128, unique=True)
|
||||
@@ -30,6 +33,7 @@ class Manufacturer(models.Model):
|
||||
category = models.CharField(
|
||||
max_length=64, choices=settings.MANUFACTURER_TYPES
|
||||
)
|
||||
country = CountryField(blank=True)
|
||||
website = models.URLField(blank=True)
|
||||
logo = models.ImageField(
|
||||
upload_to=os.path.join("images", "manufacturers"),
|
||||
@@ -39,17 +43,18 @@ class Manufacturer(models.Model):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["category", "name"]
|
||||
ordering = ["category", "slug"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"filtered", kwargs={
|
||||
"filtered",
|
||||
kwargs={
|
||||
"_filter": "manufacturer",
|
||||
"search": self.slug,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def logo_thumbnail(self):
|
||||
@@ -73,17 +78,18 @@ class Company(models.Model):
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Companies"
|
||||
ordering = ["name"]
|
||||
ordering = ["slug"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"filtered", kwargs={
|
||||
"filtered",
|
||||
kwargs={
|
||||
"_filter": "company",
|
||||
"search": self.slug,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def extended_name_pp(self):
|
||||
@@ -111,8 +117,8 @@ class Decoder(models.Model):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
class Meta(object):
|
||||
ordering = ["manufacturer", "name"]
|
||||
class Meta:
|
||||
ordering = ["manufacturer__name", "name"]
|
||||
|
||||
def __str__(self):
|
||||
return "{0} - {1}".format(self.manufacturer, self.name)
|
||||
@@ -123,37 +129,49 @@ class Decoder(models.Model):
|
||||
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):
|
||||
try:
|
||||
num, den = ratio.split(":")
|
||||
return int(num) / float(den) * 10000
|
||||
except (ValueError, ZeroDivisionError):
|
||||
raise ValidationError("Invalid ratio format")
|
||||
|
||||
|
||||
class Scale(models.Model):
|
||||
scale = models.CharField(max_length=32, unique=True)
|
||||
slug = models.CharField(max_length=32, unique=True, editable=False)
|
||||
ratio = models.CharField(max_length=16, blank=True)
|
||||
gauge = models.CharField(max_length=16, blank=True)
|
||||
tracks = models.CharField(max_length=16, blank=True)
|
||||
ratio = models.CharField(max_length=16, validators=[calculate_ratio])
|
||||
ratio_int = models.SmallIntegerField(editable=False, default=0)
|
||||
tracks = models.FloatField(
|
||||
help_text="Distance between model tracks in mm",
|
||||
)
|
||||
gauge = models.CharField(
|
||||
max_length=16,
|
||||
blank=True,
|
||||
help_text="Distance between real tracks. Please specify the unit (mm, in, ...)", # noqa: E501
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["scale"]
|
||||
ordering = ["-ratio_int", "-tracks", "scale"]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"filtered", kwargs={
|
||||
"filtered",
|
||||
kwargs={
|
||||
"_filter": "scale",
|
||||
"search": self.slug,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return str(self.scale)
|
||||
|
||||
|
||||
@receiver(models.signals.pre_save, sender=Scale)
|
||||
def scale_save(sender, instance, **kwargs):
|
||||
instance.ratio_int = calculate_ratio(instance.ratio)
|
||||
|
||||
|
||||
class RollingStockType(models.Model):
|
||||
type = models.CharField(max_length=64)
|
||||
order = models.PositiveSmallIntegerField()
|
||||
@@ -162,16 +180,22 @@ class RollingStockType(models.Model):
|
||||
)
|
||||
slug = models.CharField(max_length=128, unique=True, editable=False)
|
||||
|
||||
class Meta(object):
|
||||
unique_together = ("category", "type")
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["category", "type"],
|
||||
name="unique_category_type"
|
||||
)
|
||||
]
|
||||
ordering = ["order"]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"filtered", kwargs={
|
||||
"filtered",
|
||||
kwargs={
|
||||
"_filter": "type",
|
||||
"search": self.slug,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
@@ -182,21 +206,36 @@ class Tag(models.Model):
|
||||
name = models.CharField(max_length=128, unique=True)
|
||||
slug = models.CharField(max_length=128, unique=True)
|
||||
|
||||
class Meta(object):
|
||||
ordering = ["name"]
|
||||
class Meta:
|
||||
ordering = ["slug"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
"filtered", kwargs={
|
||||
"filtered",
|
||||
kwargs={
|
||||
"_filter": "tag",
|
||||
"search": self.slug,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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=Company)
|
||||
@receiver(models.signals.pre_save, sender=Scale)
|
||||
|
@@ -1,12 +1,15 @@
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from solo.admin import SingletonModelAdmin
|
||||
from tinymce.widgets import TinyMCE
|
||||
|
||||
from ram.admin import publish, unpublish
|
||||
from portal.models import SiteConfiguration, Flatpage
|
||||
|
||||
|
||||
@admin.register(SiteConfiguration)
|
||||
class SiteConfigurationAdmin(SingletonModelAdmin):
|
||||
readonly_fields = ("site_name",)
|
||||
readonly_fields = ("site_name", "rest_api", "version")
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
@@ -17,8 +20,10 @@ class SiteConfigurationAdmin(SingletonModelAdmin):
|
||||
"about",
|
||||
"items_per_page",
|
||||
"items_ordering",
|
||||
"currency",
|
||||
"footer",
|
||||
"footer_extended",
|
||||
"disclaimer",
|
||||
)
|
||||
},
|
||||
),
|
||||
@@ -30,11 +35,30 @@ class SiteConfigurationAdmin(SingletonModelAdmin):
|
||||
"show_version",
|
||||
"use_cdn",
|
||||
"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)
|
||||
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"},
|
||||
),
|
||||
]
|
@@ -9,6 +9,7 @@ from solo.models import SingletonModel
|
||||
from tinymce import models as tinymce
|
||||
|
||||
from ram import __version__ as app_version
|
||||
from ram.managers import PublicManager
|
||||
from ram.utils import slugify
|
||||
|
||||
|
||||
@@ -29,8 +30,10 @@ class SiteConfiguration(SingletonModel):
|
||||
],
|
||||
default="type",
|
||||
)
|
||||
currency = models.CharField(max_length=3, default="EUR")
|
||||
footer = tinymce.HTMLField(blank=True)
|
||||
footer_extended = tinymce.HTMLField(blank=True)
|
||||
disclaimer = tinymce.HTMLField(blank=True)
|
||||
show_version = models.BooleanField(default=True)
|
||||
use_cdn = models.BooleanField(default=True)
|
||||
extra_head = models.TextField(blank=True)
|
||||
@@ -44,9 +47,11 @@ class SiteConfiguration(SingletonModel):
|
||||
def site_name(self):
|
||||
return settings.SITE_NAME
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
return app_version
|
||||
|
||||
@property
|
||||
def django_version(self):
|
||||
return django.get_version()
|
||||
|
||||
@@ -59,6 +64,10 @@ class Flatpage(models.Model):
|
||||
creation_time = models.DateTimeField(auto_now_add=True)
|
||||
updated_time = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "page"
|
||||
verbose_name_plural = "pages"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -72,6 +81,8 @@ class Flatpage(models.Model):
|
||||
)
|
||||
)
|
||||
|
||||
objects = PublicManager()
|
||||
|
||||
|
||||
@receiver(models.signals.pre_save, sender=Flatpage)
|
||||
def tag_pre_save(sender, instance, **kwargs):
|
||||
|
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
|
||||
* Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE)
|
||||
*/
|
||||
@@ -7,8 +7,8 @@
|
||||
@font-face {
|
||||
font-display: block;
|
||||
font-family: "bootstrap-icons";
|
||||
src: url("./fonts/bootstrap-icons.woff2?dd67030699838ea613ee6dbda90effa6") format("woff2"),
|
||||
url("./fonts/bootstrap-icons.woff?dd67030699838ea613ee6dbda90effa6") format("woff");
|
||||
src: url("./fonts/bootstrap-icons.woff2?e34853135f9e39acf64315236852cd5a") format("woff2"),
|
||||
url("./fonts/bootstrap-icons.woff?e34853135f9e39acf64315236852cd5a") format("woff");
|
||||
}
|
||||
|
||||
.bi::before,
|
||||
@@ -2076,3 +2076,31 @@ url("./fonts/bootstrap-icons.woff?dd67030699838ea613ee6dbda90effa6") format("wof
|
||||
.bi-suitcase2-fill::before { content: "\f901"; }
|
||||
.bi-suitcase2::before { content: "\f902"; }
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Disable margin on last <p> in a <td> */
|
||||
td > p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.btn > span {
|
||||
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);
|
||||
}
|
||||
|
||||
#nav-notes > p {
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
#nav-journal ul, #nav-journal ol {
|
||||
margin: 0;
|
||||
#nav-journal ul,
|
||||
#nav-journal ol {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
#nav-journal p {
|
||||
margin: 0;
|
||||
#nav-journal p:last-child,
|
||||
#nav-journal ul:last-child,
|
||||
#nav-journal ol:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#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">
|
||||
<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" />
|
||||
<style>
|
||||
path {
|
||||
text-indent:0;
|
||||
text-transform:none;
|
||||
}
|
||||
</style>
|
||||
</svg>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="32" height="16" preserveAspectRatio="xMidYMid" version="1.0" viewBox="0 0 24 12" xmlns="http://www.w3.org/2000/svg">
|
||||
<metadata>Created by potrace 1.15, written by Peter Selinger 2001-2017</metadata>
|
||||
<g transform="matrix(.0039261 0 0 -.0039261 -1.4249 18.53)">
|
||||
<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"/>
|
||||
</g>
|
||||
</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.svg" %}" type="image/svg+xml">
|
||||
{% 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-icons@1.11.3/font/bootstrap-icons.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.13.1/font/bootstrap-icons.css" rel="stylesheet">
|
||||
{% else %}
|
||||
<link href="{% static "bootstrap@5.3.3/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@5.3.6/dist/css/bootstrap.min.css" %}" rel="stylesheet">
|
||||
<link href="{% static "bootstrap-icons@1.13.1/font/bootstrap-icons.css" %}" rel="stylesheet">
|
||||
{% endif %}
|
||||
<link href="{% static "css/main.css" %}?v={{ site_conf.version }}" rel="stylesheet">
|
||||
<style>
|
||||
@@ -118,14 +118,16 @@
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var selectElement = document.getElementById('tabSelector');
|
||||
selectElement.addEventListener('change', function () {
|
||||
var selectedTabId = this.value;
|
||||
var tabs = document.querySelectorAll('.tab-pane');
|
||||
tabs.forEach(function (tab) {
|
||||
tab.classList.remove('show', 'active');
|
||||
});
|
||||
document.getElementById(selectedTabId).classList.add('show', 'active');
|
||||
});
|
||||
try {
|
||||
selectElement.addEventListener('change', function () {
|
||||
var selectedTabId = this.value;
|
||||
var tabs = document.querySelectorAll('.tab-pane');
|
||||
tabs.forEach(function (tab) {
|
||||
tab.classList.remove('show', 'active');
|
||||
});
|
||||
document.getElementById(selectedTabId).classList.add('show', 'active');
|
||||
});
|
||||
} catch (TypeError) { /* pass */ }
|
||||
});
|
||||
</script>
|
||||
{% block extra_head %}
|
||||
@@ -138,14 +140,10 @@
|
||||
<div class="container d-flex">
|
||||
<div class="me-auto">
|
||||
<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">
|
||||
<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" />
|
||||
<style>
|
||||
path {
|
||||
text-indent:0;
|
||||
text-transform:none;
|
||||
}
|
||||
</style>
|
||||
<svg class="me-2" width="32" height="16" version="1.0" viewBox="0 0 24 12" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(.0039261 0 0 -.0039261 -1.4249 18.53)">
|
||||
<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"/>
|
||||
</g>
|
||||
</svg>
|
||||
<strong>{{ site_conf.site_name }}</strong>
|
||||
</a>
|
||||
@@ -180,13 +178,13 @@
|
||||
<li><a class="dropdown-item" href="{% url 'manufacturers' category='model' %}">Manufacturer</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li class="ps-2 text-secondary">Prototype</li>
|
||||
<li><a class="dropdown-item" href="{% url 'types' %}">Type</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'rolling_stock_types' %}">Type</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'companies' %}">Company</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'manufacturers' category='real' %}">Manufacturer</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% show_bookshelf_menu %}
|
||||
{% show_flatpages_menu %}
|
||||
{% show_flatpages_menu user %}
|
||||
</ul>
|
||||
{% include 'includes/search.html' %}
|
||||
</div>
|
||||
@@ -213,12 +211,13 @@
|
||||
<div class="container">{% block pagination %}{% endblock %}</div>
|
||||
</div>
|
||||
{% block extra_content %}{% endblock %}
|
||||
{% include 'includes/symbols.html' %}
|
||||
</main>
|
||||
{% include 'includes/footer.html' %}
|
||||
{% 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 %}
|
||||
<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 %}
|
||||
</body>
|
||||
</html>
|
||||
|
@@ -1,4 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load dynamic_url %}
|
||||
|
||||
{% block header %}
|
||||
{% if book.tags.all %}
|
||||
@@ -8,7 +9,10 @@
|
||||
{% endfor %}
|
||||
</p>
|
||||
{% 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 %}
|
||||
{% block carousel %}
|
||||
<div class="row">
|
||||
@@ -27,11 +31,11 @@
|
||||
{% if book.image.count > 1 %}
|
||||
<button class="carousel-control-prev" type="button" data-bs-target="#carouselControls" data-bs-slide="prev">
|
||||
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Previous</span>
|
||||
<span class="visually-hidden"><i class="bi bi-chevron-left"></i></span>
|
||||
</button>
|
||||
<button class="carousel-control-next" type="button" data-bs-target="#carouselControls" data-bs-slide="next">
|
||||
<span class="carousel-control-next-icon" aria-hidden="true"></span>
|
||||
<span class="visually-hidden">Next</span>
|
||||
<span class="visually-hidden"><i class="bi bi-chevron-right"></i></span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -45,36 +49,49 @@
|
||||
<div class="mx-auto">
|
||||
<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>
|
||||
{% 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>
|
||||
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
|
||||
<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>
|
||||
<div class="tab-content" id="nav-tabContent">
|
||||
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
|
||||
<table class="table table-striped">
|
||||
{{ book.description | safe }}
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2" scope="row">Book</th>
|
||||
<th colspan="2" scope="row">
|
||||
{% if type == "catalog" %}Catalog
|
||||
{% elif type == "book" %}Book{% endif %}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="table-group-divider">
|
||||
{% if type == "catalog" %}
|
||||
<tr>
|
||||
<th class="w-33" scope="row">Manufacturer</th>
|
||||
<td>{{ book.manufacturer }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-33" scope="row">Scales</th>
|
||||
<td>{{ book.get_scales }}</td>
|
||||
</tr>
|
||||
{% elif type == "book" %}
|
||||
<tr>
|
||||
<th class="w-33" scope="row">Title</th>
|
||||
<td>{{ book.title }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Authors</th>
|
||||
<td>
|
||||
<ul class="mb-0 list-unstyled">{% for a in book.authors.all %}<li>{{ a }}</li>{% endfor %}</ul>
|
||||
</td>
|
||||
<th class="w-33" scope="row">Authors</th>
|
||||
<td>
|
||||
<ul class="mb-0 list-unstyled">{% for a in book.authors.all %}<li>{{ a }}</li>{% endfor %}</ul>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Publisher</th>
|
||||
<th class="w-33" scope="row">Publisher</th>
|
||||
<td>{{ book.publisher }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th scope="row">ISBN</th>
|
||||
<td>{{ book.ISBN|default:"-" }}</td>
|
||||
@@ -91,13 +108,41 @@
|
||||
<th scope="row">Publication year</th>
|
||||
<td>{{ book.publication_year|default:"-" }}</td>
|
||||
</tr>
|
||||
{% if book.description %}
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Price ({{ site_conf.currency }})</th>
|
||||
<td>{{ book.price|default:"-" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% if book_properties %}
|
||||
{% endif %}
|
||||
{% if properties %}
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -105,7 +150,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="table-group-divider">
|
||||
{% for p in book_properties %}
|
||||
{% for p in properties %}
|
||||
<tr>
|
||||
<th class="w-33" scope="row">{{ p.property }}</th>
|
||||
<td>{{ p.value }}</td>
|
||||
@@ -115,12 +160,27 @@
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="tab-pane" id="nav-notes" role="tabpanel" aria-labelledby="nav-notes-tab">
|
||||
{{ book.notes | safe }}
|
||||
<div class="tab-pane" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
|
||||
<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 class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:bookshelf_book_change' book.pk %}">Edit</a>{% endif %}
|
||||
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% dynamic_admin_url 'bookshelf' type book.pk %}">Edit</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,40 +0,0 @@
|
||||
{% extends "cards.html" %}
|
||||
{% block pagination %}
|
||||
{% if data.has_other_pages %}
|
||||
<nav aria-label="Page navigation example">
|
||||
<ul class="pagination justify-content-center mt-4 mb-0">
|
||||
{% if data.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{% url 'books_pagination' page=data.previous_page_number %}#main-content" tabindex="-1">Previous</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">Previous</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for i in page_range %}
|
||||
{% if data.number == i %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ i }}</span>
|
||||
</li>
|
||||
{% else %}
|
||||
{% if i == data.paginator.ELLIPSIS %}
|
||||
<li class="page-item"><span class="page-link">{{ i }}</span></li>
|
||||
{% else %}
|
||||
<li class="page-item"><a class="page-link" href="{% url 'books_pagination' page=i %}#main-content">{{ i }}</a></li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if data.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{% url 'books_pagination' page=data.next_page_number %}#main-content" tabindex="-1">Next</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">Next</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock %}
|
@@ -4,7 +4,12 @@
|
||||
Bookshelf
|
||||
</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="bookshelfDropdownMenuLink">
|
||||
{% if books_menu %}
|
||||
<li><a class="dropdown-item" href="{% url 'books' %}">Books</a></li>
|
||||
{% endif %}
|
||||
{% if catalogs_menu %}
|
||||
<li><a class="dropdown-item" href="{% url 'catalogs' %}">Catalogs</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
@@ -1,12 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block header %}
|
||||
<p class="lead text-muted">Results found: {{ matches }}</p>
|
||||
<p class="lead text-body-secondary">Results found: {{ matches }}</p>
|
||||
{% endblock %}
|
||||
{% block cards_layout %}
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
|
||||
{% block cards %}
|
||||
{% for d in data %}
|
||||
{% if d.type == "rolling_stock" %}
|
||||
{% if d.type == "roster" %}
|
||||
{% include "cards/roster.html" %}
|
||||
{% elif d.type == "company" %}
|
||||
{% include "cards/company.html" %}
|
||||
@@ -18,7 +18,7 @@
|
||||
{% include "cards/consist.html" %}
|
||||
{% elif d.type == "manufacturer" %}
|
||||
{% include "cards/manufacturer.html" %}
|
||||
{% elif d.type == "book" %}
|
||||
{% elif d.type == "book" or d.type == "catalog" %}
|
||||
{% include "cards/book.html" %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
@@ -1,7 +1,12 @@
|
||||
{% load static %}
|
||||
{% load dynamic_url %}
|
||||
<div class="col">
|
||||
<div class="card shadow-sm">
|
||||
{% 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>
|
||||
{% 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 %}
|
||||
<div class="card-body">
|
||||
<p class="card-text" style="position: relative;">
|
||||
@@ -18,10 +23,28 @@
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2" scope="row">Book</th>
|
||||
<th colspan="2" scope="row">
|
||||
{% if d.type == "catalog" %}Catalog
|
||||
{% elif d.type == "book" %}Book{% endif %}
|
||||
<div class="float-end">
|
||||
{% if not d.item.published %}
|
||||
<span class="badge text-bg-warning">Unpublished</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="table-group-divider">
|
||||
{% if d.type == "catalog" %}
|
||||
<tr>
|
||||
<th class="w-33" scope="row">Manufacturer</th>
|
||||
<td>{{ d.item.manufacturer }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-33" scope="row">Scales</th>
|
||||
<td>{{ d.item.get_scales }}</td>
|
||||
</tr>
|
||||
{% elif d.type == "book" %}
|
||||
<tr>
|
||||
<th class="w-33" scope="row">Authors</th>
|
||||
<td>
|
||||
@@ -32,6 +55,7 @@
|
||||
<th class="w-33" scope="row">Publisher</th>
|
||||
<td>{{ d.item.publisher }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th scope="row">Language</th>
|
||||
<td>{{ d.item.get_language_display }}</td>
|
||||
@@ -48,7 +72,7 @@
|
||||
</table>
|
||||
<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>
|
||||
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:bookshelf_book_change' d.item.pk %}">Edit</a>{% endif %}
|
||||
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% dynamic_admin_url 'bookshelf' d.type d.item.pk %}">Edit</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -7,7 +7,14 @@
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody class="table-group-divider">
|
||||
@@ -27,19 +34,15 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
{% if d.item.freelance %}
|
||||
<tr>
|
||||
<th class="w-33" scope="row">Notes</th>
|
||||
<td>A <em>freelance</em> company</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
<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 %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -24,7 +24,17 @@
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody class="table-group-divider">
|
||||
@@ -36,7 +46,10 @@
|
||||
{% endif %}
|
||||
<tr>
|
||||
<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>
|
||||
<th scope="row">Era</th>
|
||||
@@ -44,7 +57,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Length</th>
|
||||
<td>{{ d.item.consist_item.count }}</td>
|
||||
<td>{{ d.item.length }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
@@ -30,8 +30,10 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<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 %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -20,8 +20,10 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
{% 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 %}
|
||||
{% with items=d.item.num_items %}
|
||||
<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>
|
||||
|
@@ -1,4 +1,6 @@
|
||||
{% load static %}
|
||||
{% load dcc %}
|
||||
|
||||
<div class="col">
|
||||
<div class="card shadow-sm">
|
||||
{% if d.item.image.exists %}
|
||||
@@ -22,7 +24,17 @@
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody class="table-group-divider">
|
||||
@@ -33,7 +45,8 @@
|
||||
<tr>
|
||||
<th scope="row">Company</th>
|
||||
<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>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -56,33 +69,18 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Scale</th>
|
||||
<td><a href="{% url 'filtered' _filter="scale" search=d.item.scale.slug %}"><abbr title="{{ d.item.scale.ratio }} - {{ d.item.scale.tracks }}">{{ d.item.scale }}</abbr></a></td>
|
||||
<td><a href="{% url 'filtered' _filter="scale" search=d.item.scale.slug %}"><abbr title="{{ d.item.scale.ratio }} - {{ d.item.scale.tracks }} mm">{{ d.item.scale }}</abbr></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<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 search=d.item.item_number %}">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>
|
||||
<th scope="row">DCC</th>
|
||||
<td><a class="text-reset text-decoration-none" title="Symbols" href="" data-bs-toggle="modal" data-bs-target="#symbolsModal">{% dcc d.item %}</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% if d.item.decoder %}
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="2" scope="row">DCC data</th>
|
||||
</tr>
|
||||
</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>
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
<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>
|
||||
{% 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 %}
|
||||
|
@@ -18,18 +18,20 @@
|
||||
<td>{{ d.item.ratio }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-33" scope="row">Gauge</th>
|
||||
<td>{{ d.item.gauge }}</td>
|
||||
<th class="w-33" scope="row">Tracks</th>
|
||||
<td>{{ d.item.tracks }} mm</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="w-33" scope="row">Tracks</th>
|
||||
<td>{{ d.item.tracks }}</td>
|
||||
<th class="w-33" scope="row">Gauge</th>
|
||||
<td>{{ d.item.gauge }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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 %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,40 +0,0 @@
|
||||
{% extends "cards.html" %}
|
||||
{% block pagination %}
|
||||
{% if data.has_other_pages %}
|
||||
<nav aria-label="Page navigation example">
|
||||
<ul class="pagination justify-content-center mt-4 mb-0">
|
||||
{% if data.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{% url 'companies_pagination' page=data.previous_page_number %}#main-content" tabindex="-1">Previous</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">Previous</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for i in page_range %}
|
||||
{% if data.number == i %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ i }}</span>
|
||||
</li>
|
||||
{% else %}
|
||||
{% if i == data.paginator.ELLIPSIS %}
|
||||
<li class="page-item"><span class="page-link">{{ i }}</span></li>
|
||||
{% else %}
|
||||
<li class="page-item"><a class="page-link" href="{% url 'companies_pagination' page=i %}#main-content">{{ i }}</a></li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if data.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{% url 'companies_pagination' page=data.next_page_number %}#main-content" tabindex="-1">Next</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">Next</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock %}
|
@@ -7,7 +7,10 @@
|
||||
{{ t.name }}</a>{# new line is required #}
|
||||
{% endfor %}
|
||||
</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 %}
|
||||
{% endblock %}
|
||||
{% block carousel %}
|
||||
@@ -25,15 +28,15 @@
|
||||
{% endblock %}
|
||||
{% block pagination %}
|
||||
{% if data.has_other_pages %}
|
||||
<nav aria-label="Page navigation example">
|
||||
<ul class="pagination justify-content-center mt-4 mb-0">
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
|
||||
{% if data.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{% url 'consist_pagination' uuid=consist.uuid page=data.previous_page_number %}#main-content" tabindex="-1">Previous</a>
|
||||
<a class="page-link" href="{% url 'consist_pagination' uuid=consist.uuid page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">Previous</span>
|
||||
<span class="page-link"><i class="bi bi-chevron-left"></i></span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for i in page_range %}
|
||||
@@ -51,11 +54,11 @@
|
||||
{% endfor %}
|
||||
{% if data.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{% url 'consist_pagination' uuid=consist.uuid page=data.next_page_number %}#main-content" tabindex="-1">Next</a>
|
||||
<a class="page-link" href="{% url 'consist_pagination' uuid=consist.uuid page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">Next</span>
|
||||
<span class="page-link"><i class="bi bi-chevron-right"></i></span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
@@ -68,18 +71,23 @@
|
||||
<div class="mx-auto">
|
||||
<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>
|
||||
{% 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>
|
||||
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
|
||||
<option value="nav-summary" selected>Summary</option>
|
||||
{% if consist.notes %}<option value="nav-notes">Notes</option>{% endif %}
|
||||
</select>
|
||||
<div class="tab-content" id="nav-tabContent">
|
||||
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody class="table-group-divider">
|
||||
@@ -93,23 +101,19 @@
|
||||
<th scope="row">Era</th>
|
||||
<td>{{ consist.era }}</td>
|
||||
</tr>
|
||||
{% if consist.description %}
|
||||
<tr>
|
||||
<th scope="row">Description</th>
|
||||
<td>{{ consist.description | safe }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<th scope="row">Length</th>
|
||||
<td>{{ data | length }}</td>
|
||||
<td>{{ consist.length }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="tab-pane" id="nav-notes" role="tabpanel" aria-labelledby="nav-notes-tab">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="row">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="table-group-divider">
|
||||
<tr>
|
||||
<td>{{ consist.notes | safe }}</td>
|
||||
<th scope="row">Composition</th>
|
||||
<td>{% for t in consist.get_type_count %}{{ t.count }}x {{ t.type }} {{t.category }}{% if not forloop.last %} » {% endif %}{% endfor %}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
@@ -1,40 +0,0 @@
|
||||
{% extends "cards.html" %}
|
||||
{% block pagination %}
|
||||
{% if data.has_other_pages %}
|
||||
<nav aria-label="Page navigation example">
|
||||
<ul class="pagination justify-content-center mt-4 mb-0">
|
||||
{% if data.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{% url 'consists_pagination' page=data.previous_page_number %}#main-content" tabindex="-1">Previous</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">Previous</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for i in page_range %}
|
||||
{% if data.number == i %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ i }}</span>
|
||||
</li>
|
||||
{% else %}
|
||||
{% if i == data.paginator.ELLIPSIS %}
|
||||
<li class="page-item"><span class="page-link">{{ i }}</span></li>
|
||||
{% else %}
|
||||
<li class="page-item"><a class="page-link" href="{% url 'consists_pagination' page=i %}#main-content">{{ i }}</a></li>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if data.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{% url 'consists_pagination' page=data.next_page_number %}#main-content" tabindex="-1">Next</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">Next</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock %}
|
@@ -1,15 +1,15 @@
|
||||
{% extends "cards.html" %}
|
||||
{% block pagination %}
|
||||
{% if data.has_other_pages %}
|
||||
<nav aria-label="Page navigation example">
|
||||
<ul class="pagination justify-content-center mt-4 mb-0">
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
|
||||
{% if data.has_previous %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{% url 'filtered_pagination' _filter=filter search=search page=data.previous_page_number %}#main-content" tabindex="-1">Previous</a>
|
||||
<a class="page-link" href="{% url 'filtered_pagination' _filter=filter search=search page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">Previous</span>
|
||||
<span class="page-link"><i class="bi bi-chevron-left"></i></span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for i in page_range %}
|
||||
@@ -27,11 +27,11 @@
|
||||
{% endfor %}
|
||||
{% if data.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{% url 'filtered_pagination' _filter=filter search=search page=data.next_page_number %}#main-content" tabindex="-1">Next</a>
|
||||
<a class="page-link" href="{% url 'filtered_pagination' _filter=filter search=search page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">Next</span>
|
||||
<span class="page-link"><i class="bi bi-chevron-right"></i></span>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
@@ -1,7 +1,12 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% 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 %}
|
||||
{% block extra_content %}
|
||||
<section class="py-4 text-start container">
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{% if flatpages_menu %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="flatpageDropdownMenuLink" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Articles
|
||||
Pages
|
||||
</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="flatpageDropdownMenuLink">
|
||||
{% for m in flatpages_menu %}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
{% extends "roster.html" %}
|
||||
{% extends "pagination.html" %}
|
||||
|
||||
{% block header %}
|
||||
<div class="text-muted">{{ site_conf.about | safe }}</div>
|
||||
<div class="text-body-secondary">{{ site_conf.about | safe }}</div>
|
||||
{% endblock %}
|
||||
|
@@ -1,18 +1,36 @@
|
||||
<footer class="text-muted py-4">
|
||||
<div class="container">
|
||||
<p class="float-end mb-1">
|
||||
<a href="#">Back to top</a>
|
||||
</p>
|
||||
<footer class="text-body-secondary py-4">
|
||||
<div class="container d-lg-flex justify-content-between">
|
||||
<div id="footer" class="mb-1">
|
||||
<p>© {% now "Y" %}</p> {{ site_conf.footer | safe }}
|
||||
<p>© {% now "Y" %}</p> {{ site_conf.footer | safe }}
|
||||
</div>
|
||||
<div id="footer_extended" class="mb-0">
|
||||
</div>
|
||||
<div class="container">
|
||||
<div id="footer_extended">
|
||||
{{ site_conf.footer_extended | safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<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 %}
|
||||
<div class="container d-flex text-body-secondary">
|
||||
<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>
|
||||
{% if site_conf.show_version %}<br>Version {{ site_conf.version }}{% endif %}</p>
|
||||
<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>
|
||||
</footer>
|
||||
|
@@ -18,7 +18,12 @@
|
||||
<li><a class="dropdown-item" href="{% url 'admin:index' %}">Admin</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'admin:portal_siteconfiguration_changelist' %}">Site configuration</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item text-danger" href="{% url 'admin:logout' %}?next={{ request.path }}">Logout</a></li>
|
||||
<li>
|
||||
<form id="logout-form" method="post" action="{% url 'admin:logout' %}?next={{ request.path }}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-link dropdown-item text-danger" type="submit">Log out</button>
|
||||
</form>
|
||||
</li>
|
||||
</ul>
|
||||
{% else %}
|
||||
<a class="nav-link" href="{% url 'admin:login' %}?next={{ request.path }}">Log in</a>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user