89 Commits

Author SHA1 Message Date
c7db5fceb2 Update submodules 2025-02-17 23:23:53 +01:00
f246656425 Add support for invoices 2025-02-10 00:13:15 +01:00
570c00e34f Complete the implementation of document repository and add invoices 2025-02-09 18:50:39 +01:00
81d251e310 Step two (broken) 2025-02-09 15:31:03 +01:00
83b80cd7b0 Create a repository app for documents, first step 2025-02-09 14:44:54 +01:00
5088f19b33 Fix a typo 2025-02-04 22:33:52 +01:00
50bfc44978 Add Meta migration for Portal 2025-02-02 00:27:02 +01:00
453729b05c Rename articles to pages 2025-02-02 00:25:45 +01:00
5d89cb96d2 Add options for a disclaimer, fix html code and remove deprecations (#50)
* Add options for a disclaimer, fix html code and remove deprecations

* Update READMEs

* Minor improvement to portal admin [skip ci]
2025-01-30 23:13:32 +01:00
04757d868a Update README.md 2025-01-30 11:46:21 +01:00
b897141212 Update README.md 2025-01-30 11:45:15 +01:00
3df8b461a0 HOTFIX: fix duplicated results introduced in #1a8f2aa 2025-01-28 19:30:24 +01:00
284632892d Update sample data 2025-01-28 19:28:30 +01:00
bb58dcf6fa Fix a bug related to consist 2025-01-27 23:52:37 +01:00
c971ff9601 Fix a CASCADE on shops 2025-01-27 23:22:08 +01:00
b10e1f3952 Add shop as a fixed property (#49)
* Add shop field (from properties)

* Update template

* Implement description in BaseModel and then consist

* Make notes internal only

* Fix a merge issue
2025-01-27 23:16:52 +01:00
d16e00d66b Add shop field (from properties) (#48)
* Add shop field (from properties)

* Update template
2025-01-27 00:34:44 +01:00
1a8f2aace8 Allow multiple manufacturers per class (#47)
* Allow multiple manufacturers per class

* Fix REST API serializer
2025-01-20 22:51:56 +01:00
0413c1c5ab Add a draft tag to unpublished items and minor improvements (#46)
* Add a draft tag to unpublished items

* Add X-Cache-Hit header

* Expose decoder interface in roster cards

* Manage decoder interface set to None
2025-01-20 18:24:20 +01:00
f914c79786 Minor fix to cleanup js logs 2025-01-20 13:58:36 +01:00
456f1b7294 Update .gitignore 2025-01-19 15:52:41 +01:00
f19a0995b0 HOTFIX: Add a missing signal
Regression introduced in v0.14.0
2025-01-19 15:07:04 +01:00
3dd134f132 Improve freelance tag 2025-01-18 23:02:55 +01:00
ddcf06994d Improve user experience in admin and UI (#45) 2025-01-18 15:37:56 +01:00
c467fb24ca Add support for generic documents (admin only) (#44)
* Add support for generic documents
* Add publish / unpublish actions
* Minor improvements to models properties
2025-01-17 22:44:50 +01:00
db79a02c85 Add REST API pagination and mke REST API optional (#43)
* Implement Rest API pagination

* REST API must be enabled in settings

* Report REST API status in the admin site settings page
2025-01-16 22:53:19 +01:00
d237129c99 Update README.md 2025-01-15 18:32:23 +01:00
af54acae86 Update README.md 2025-01-15 18:31:22 +01:00
90211562f9 Replace custom python connector with ncat (#42)
* Replace custom made daemon with nmap-ncat

* Use stderr to log ncat output

* Refresh the branch
2025-01-15 18:30:36 +01:00
1e7f72e9ec Implement publish, unpublish actions 2025-01-08 23:47:58 +01:00
26be22c0bd Minor admin improvements and remove unique_together deprecated Meta
Also make rolling stock unique per consist
2025-01-08 23:28:04 +01:00
f286ec9780 Update submodules 2025-01-05 20:58:29 +01:00
ead9fe649b Small templates improvements for books and rs 2025-01-05 15:28:42 +01:00
206b9aea57 Make purchase date a private field as well 2025-01-04 19:06:10 +01:00
8557e2b778 Improve a bit the layout for descriptions 2024-12-30 12:07:46 +01:00
6457486445 Add coming soon image to books and catalogs 2024-12-29 22:47:33 +01:00
ee5b5f0b3a Add a custom middleware to improve caching behavior 2024-12-29 22:28:39 +01:00
159bc66b59 Minor change to roster admin 2024-12-29 22:06:25 +01:00
0ea9978ffb Remove troublesome code 2024-12-29 21:57:54 +01:00
026ab06354 Add a CSV export functionality in admin and add price fields (#41)
* Implement an action do download data in csv
* Refactor CSV download
* Move price to main models and add csv to bookshelf
* Update template and API
* Small refactoring
2024-12-29 21:46:57 +01:00
7eddd1b52b Fix a regression introduced in v0.14.0 2024-12-23 12:16:22 +01:00
11515d79ef Fix a regression in bookshelf properties 2024-12-23 02:15:15 +01:00
f2b817103f Add catalog to by tag filter 2024-12-23 02:02:34 +01:00
2d00436a87 Disable scales if no items are available 2024-12-23 01:50:58 +01:00
6ff5450124 Minor fixes and improvements 2024-12-23 01:26:22 +01:00
f4af44c41c Merge pull request #40 from daniviga/catalogue
Introduce the concept of catalogs, improve books and code refactoring
2024-12-22 22:13:30 +01:00
e3ae18a4bd Update Python versions in GitHub workflows 2024-12-22 22:00:26 +01:00
2695358d9b Disable an old migration 2024-12-22 21:59:14 +01:00
3fbae0417e Update a migration 2024-12-22 21:56:16 +01:00
7a51ab9095 Bump version 2024-12-22 21:46:42 +01:00
dad40b3ee7 Implement documents inline for books and catalogs 2024-12-22 21:45:56 +01:00
d55bce6e78 More code refactoring, reduce template duplications 2024-12-22 21:32:22 +01:00
cbf6c942b9 Complete Catalogs with code refactoring 2024-12-22 18:53:47 +01:00
64f616d89f Merge branch 'master' into catalogue 2024-11-30 18:51:24 +01:00
f8246c31d3 Hotfix the manufacturer template 2024-11-30 14:56:26 +01:00
005ea11011 Minor improvements 2024-11-29 23:49:35 +01:00
83444266cb Add Catalogs views, but still need to fix templates (use books for now) 2024-11-29 23:43:36 +01:00
1a3b30ace3 Enable Catalogs in Admin 2024-11-29 23:30:33 +01:00
21c99f73c3 Implement Book data migration 2024-11-29 23:16:31 +01:00
b5b88f7714 Minor change to Image model Meta 2024-11-27 23:14:44 +01:00
119d25ede6 WIP: implement catalogue type of books 2024-11-27 23:07:43 +01:00
41d9338459 Allow books with no authors 2024-11-26 23:22:37 +01:00
32785f321a Fix the logout to use a POST (introduced with Django 4.1)
Also add an handy command to clear Django cache
2024-11-05 22:43:15 +01:00
5b975355a1 Add an help text to gauge 2024-11-04 22:33:26 +01:00
7d8c539e47 Update tracks related templates 2024-11-04 22:31:56 +01:00
9a832bca82 Improve sorting 2024-11-04 22:27:23 +01:00
54254bda7d Fix get_data method signatore in portal views.py 2024-11-04 15:06:55 +01:00
1c07c6a7a9 Add a custom manager to filter private and unpublished stuff (#39)
* Implement a customer manager for flatpages

* Implement public manager for private objects

* Add support for unpublished objects in roster and consist

* Add support for unpublished objects in bookshelf

* Update filtering on REST views

* Use uuid in urls.py

* Increment version
2024-11-04 15:00:34 +01:00
61b6d7a84e Update submodules 2024-11-04 11:33:44 +01:00
d0854a4cff Speedup inlines using autocomplete field and add more previews (#38) 2024-11-04 11:33:28 +01:00
456272b93a Add built-in decoder interface type 2024-04-30 11:08:16 +02:00
35905bafdf Improve rendering of pagination on mobile (#37) 2024-04-27 15:00:23 +02:00
6a9f37ca05 Add a 404 page and improve manufacturer lookup (#36)
* Add a custom 404 page
* Better manufacturer and item lookup
* Add migration to populate new field
* Version bump
2024-04-24 00:33:41 +02:00
54a68d9b1f Fix data retreival issue on GetData (#35) 2024-04-21 15:34:16 +02:00
aa02404dfe Fix an ordering issue on items in a set query 2024-04-21 09:56:10 +02:00
e4ad98fa38 Implement support for sets and other improvements (#34)
* Add a boolean to define item as part of a set
* Add contextual help in admin
* Introduce support to sets and to item code lookup
Also review the url path for pagination
2024-04-21 00:31:52 +02:00
b37f5420c5 Update to Bootstrap 5.3.3 (#33)
* Update to Bootstrap 5.3.3
* Remove support for python 3.9
2024-04-09 23:45:58 +02:00
4b74a69f3f Add the possbility to provide descriptions (#32)
to class, rolling stock, book
2024-03-02 15:45:42 +01:00
e7d34ce8e0 Remove unused args in upload_image 2024-02-17 23:06:41 +01:00
19eb70c492 Replace ckeditor with tinymce (#30)
* Replace ckeditor with tinymce due to deprecation
* Remove any ckeditor dependency from old migrations
   Disable alters, replace create with plain models.TextField
* Reformat files
* Add more hardening in image_upload
2024-02-17 23:05:18 +01:00
4428b8c11d Fix a RuntimeWarning introduced in Django 5 (#29) 2024-01-20 22:08:10 +01:00
8400a5acd3 Add a sample background to sample_data 2023-11-12 15:30:13 +01:00
7dadf23f5f Make pylibmc optional in requirements-prod.txt 2023-11-04 23:58:51 +01:00
4a12201d22 Make Document and Image files not nullable 2023-11-04 23:54:56 +01:00
830da80302 Keep media folder clean (#28)
* Reorg roster, portal and bookshelf media
* Extend media reorg to consists
* Delete roster and bookshelf images on delte.
   Do not delete others data that might be dedup! 
* Bump version
2023-10-31 11:16:55 +01:00
416ca5bbc6 eu.gif is part of dajngo-countries 2023-10-28 14:00:52 +02:00
03fc82c38d Enable csrf protection 2023-10-28 13:56:43 +02:00
ec8684dbc0 Add a "None" country and "Europe" with flags 2023-10-28 13:55:21 +02:00
7ec8baf733 Replace \t with spaces in base.html 2023-10-28 09:29:11 +02:00
208 changed files with 5810 additions and 1031 deletions

View File

@@ -13,7 +13,7 @@ jobs:
strategy:
max-parallel: 2
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12']
python-version: ['3.12', '3.13']
steps:
- uses: actions/checkout@v3

2
.gitignore vendored
View File

@@ -10,7 +10,6 @@ __pycache__/
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
@@ -132,3 +131,4 @@ dmypy.json
ram/storage/
!ram/storage/.gitignore
arduino/CommandStation-EX/build/
utils

100
README.md
View File

@@ -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.9+
- 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 ...
![Screenshot 2023-09-18 at 21-59-30 RGS 1930s short train - Railroad Assets Manager](https://github.com/daniviga/django-ram/assets/1818657/77f9b7c9-27b3-4a65-bad0-26e9cf77e623)
#### Dark mode
![Screenshot 2023-09-18 at 21-58-22 Company RGS - Railroad Assets Manager](https://github.com/daniviga/django-ram/assets/1818657/c95697c9-0897-46f4-941c-6092271e4743)
---
### Backoffice
![image](https://user-images.githubusercontent.com/1818657/175789937-3e4970a2-b37d-44c3-8605-62dabe209c65.png)
@@ -166,8 +201,3 @@ To be continued ...
### Rest API
![image](https://user-images.githubusercontent.com/1818657/180622471-ade06c84-c73b-41d5-a2a7-02a95b2ffc02.png)

9
connector/Dockerfile Normal file
View 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
View 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
```

Binary file not shown.

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

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
## DCC++ EX connector
See [README.md](../README.md)

View File

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

View File

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

View File

@@ -1 +0,0 @@
PySerial

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
# Generated by Django 4.2.5 on 2023-10-01 20:16
import ckeditor_uploader.fields
# ckeditor removal
# import ckeditor_uploader.fields
from django.db import migrations, models
import django.db.models.deletion
import uuid
@@ -47,7 +48,8 @@ class Migration(migrations.Migration):
("ISBN", models.CharField(max_length=13, unique=True)),
("publication_year", models.SmallIntegerField(blank=True, null=True)),
("purchase_date", models.DateField(blank=True, null=True)),
("notes", ckeditor_uploader.fields.RichTextUploadingField(blank=True)),
# ("notes", ckeditor_uploader.fields.RichTextUploadingField(blank=True)),
("notes", models.TextField(blank=True)),
("creation_time", models.DateTimeField(auto_now_add=True)),
("updated_time", models.DateTimeField(auto_now=True)),
("authors", models.ManyToManyField(to="bookshelf.author")),

View File

@@ -0,0 +1,51 @@
# Generated by Django 4.2.6 on 2023-10-30 13:16
import os
import sys
import shutil
import ram.utils
import bookshelf.models
from django.db import migrations, models
from django.conf import settings
def move_images(apps, schema_editor):
sys.stdout.write("\n Processing files. Please await...")
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)
os.makedirs(os.path.dirname(new_path), exist_ok=True)
try:
shutil.move(r.image.path, new_path)
except FileNotFoundError:
sys.stderr.write(" !! FileNotFoundError: {}\n".format(new_image))
pass
r.image.name = new_image
r.save()
class Migration(migrations.Migration):
dependencies = [
("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
# ),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 4.2.6 on 2023-11-04 22:53
import bookshelf.models
from django.db import migrations, models
import ram.utils
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0009_alter_bookimage_image"),
]
operations = [
migrations.AlterField(
model_name="bookimage",
name="image",
field=models.ImageField(
storage=ram.utils.DeduplicatedStorage,
upload_to=bookshelf.models.book_image_upload,
),
),
]

View File

@@ -0,0 +1,121 @@
# Generated by Django 5.0.1 on 2024-01-20 21:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0010_alter_bookimage_image"),
]
operations = [
migrations.AlterField(
model_name="book",
name="language",
field=models.CharField(
choices=[
("af", "Afrikaans"),
("ar", "Arabic"),
("ar-dz", "Algerian Arabic"),
("ast", "Asturian"),
("az", "Azerbaijani"),
("bg", "Bulgarian"),
("be", "Belarusian"),
("bn", "Bengali"),
("br", "Breton"),
("bs", "Bosnian"),
("ca", "Catalan"),
("ckb", "Central Kurdish (Sorani)"),
("cs", "Czech"),
("cy", "Welsh"),
("da", "Danish"),
("de", "German"),
("dsb", "Lower Sorbian"),
("el", "Greek"),
("en", "English"),
("en-au", "Australian English"),
("en-gb", "British English"),
("eo", "Esperanto"),
("es", "Spanish"),
("es-ar", "Argentinian Spanish"),
("es-co", "Colombian Spanish"),
("es-mx", "Mexican Spanish"),
("es-ni", "Nicaraguan Spanish"),
("es-ve", "Venezuelan Spanish"),
("et", "Estonian"),
("eu", "Basque"),
("fa", "Persian"),
("fi", "Finnish"),
("fr", "French"),
("fy", "Frisian"),
("ga", "Irish"),
("gd", "Scottish Gaelic"),
("gl", "Galician"),
("he", "Hebrew"),
("hi", "Hindi"),
("hr", "Croatian"),
("hsb", "Upper Sorbian"),
("hu", "Hungarian"),
("hy", "Armenian"),
("ia", "Interlingua"),
("id", "Indonesian"),
("ig", "Igbo"),
("io", "Ido"),
("is", "Icelandic"),
("it", "Italian"),
("ja", "Japanese"),
("ka", "Georgian"),
("kab", "Kabyle"),
("kk", "Kazakh"),
("km", "Khmer"),
("kn", "Kannada"),
("ko", "Korean"),
("ky", "Kyrgyz"),
("lb", "Luxembourgish"),
("lt", "Lithuanian"),
("lv", "Latvian"),
("mk", "Macedonian"),
("ml", "Malayalam"),
("mn", "Mongolian"),
("mr", "Marathi"),
("ms", "Malay"),
("my", "Burmese"),
("nb", "Norwegian Bokmål"),
("ne", "Nepali"),
("nl", "Dutch"),
("nn", "Norwegian Nynorsk"),
("os", "Ossetic"),
("pa", "Punjabi"),
("pl", "Polish"),
("pt", "Portuguese"),
("pt-br", "Brazilian Portuguese"),
("ro", "Romanian"),
("ru", "Russian"),
("sk", "Slovak"),
("sl", "Slovenian"),
("sq", "Albanian"),
("sr", "Serbian"),
("sr-latn", "Serbian Latin"),
("sv", "Swedish"),
("sw", "Swahili"),
("ta", "Tamil"),
("te", "Telugu"),
("tg", "Tajik"),
("th", "Thai"),
("tk", "Turkmen"),
("tr", "Turkish"),
("tt", "Tatar"),
("udm", "Udmurt"),
("ug", "Uyghur"),
("uk", "Ukrainian"),
("ur", "Urdu"),
("uz", "Uzbek"),
("vi", "Vietnamese"),
("zh-hans", "Simplified Chinese"),
("zh-hant", "Traditional Chinese"),
],
default="en",
max_length=7,
),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.0.2 on 2024-02-17 12:19
import tinymce.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0011_alter_book_language"),
]
operations = [
migrations.AlterField(
model_name="book",
name="notes",
field=tinymce.models.HTMLField(blank=True),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.0.2 on 2024-03-02 14:31
import tinymce.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0012_alter_book_notes"),
]
operations = [
migrations.AddField(
model_name="book",
name="description",
field=tinymce.models.HTMLField(blank=True),
),
]

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

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

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

View File

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

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

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

View File

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

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

View File

@@ -0,0 +1,46 @@
# 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.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
),
]

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

View File

@@ -1,14 +1,13 @@
from uuid import uuid4
import os
import shutil
from django.db import models
from django.conf import settings
from django.urls import reverse
from django_countries.fields import CountryField
from ckeditor_uploader.fields import RichTextUploadingField
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):
@@ -33,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,
@@ -50,13 +46,63 @@ class Book(models.Model):
)
number_of_pages = models.SmallIntegerField(null=True, blank=True)
publication_year = models.SmallIntegerField(null=True, 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 = RichTextUploadingField(blank=True)
creation_time = models.DateTimeField(auto_now_add=True)
updated_time = models.DateTimeField(auto_now=True)
def delete(self, *args, **kwargs):
shutil.rmtree(
os.path.join(
settings.MEDIA_ROOT, "images", "books", str(self.uuid)
),
ignore_errors=True
)
super(BaseBook, self).delete(*args, **kwargs)
def book_image_upload(instance, filename):
return os.path.join(
"images",
"books",
str(instance.book.uuid),
filename
)
class BaseBookImage(Image):
book = models.ForeignKey(
BaseBook, on_delete=models.CASCADE, related_name="image"
)
image = models.ImageField(
upload_to=book_image_upload,
storage=DeduplicatedStorage,
)
class BaseBookProperty(PropertyInstance):
book = models.ForeignKey(
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"]
@@ -64,30 +110,42 @@ class Book(models.Model):
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("book", kwargs={"uuid": self.uuid})
return reverse(
"bookshelf_item",
kwargs={"selector": "book", "uuid": self.uuid}
)
class BookImage(Image):
book = models.ForeignKey(
Book, on_delete=models.CASCADE, related_name="image"
)
image = models.ImageField(
upload_to="images/books/", # FIXME, find a better way to replace this
storage=DeduplicatedStorage,
null=True,
blank=True
)
class BookProperty(PropertyInstance):
book = models.ForeignKey(
Book,
class Catalog(BaseBook):
manufacturer = models.ForeignKey(
Manufacturer,
on_delete=models.CASCADE,
null=False,
blank=False,
related_name="property",
)
years = models.CharField(max_length=12)
scales = models.ManyToManyField(Scale)
class Meta:
ordering = ["manufacturer", "publication_year"]
def __str__(self):
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"

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
from django.contrib import admin
from django.utils.html import format_html
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
from ram.admin import publish, unpublish
from consist.models import Consist, ConsistItem
@@ -8,7 +10,15 @@ 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",
"address",
"type",
"company",
"era",
)
@admin.register(Consist)
@@ -18,26 +28,37 @@ 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", "era", "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",
"era",
"description",
"image",
"notes",
"tags",
)
},
),
(
"Notes",
{"classes": ("collapse",), "fields": ("notes",)},
),
(
"Audit",
{
@@ -49,3 +70,4 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
},
),
)
actions = [publish, unpublish]

View File

@@ -1,6 +1,7 @@
# Generated by Django 4.1 on 2022-08-23 15:54
import ckeditor_uploader.fields
# ckeditor removal
# import ckeditor_uploader.fields
from django.db import migrations
@@ -11,9 +12,9 @@ class Migration(migrations.Migration):
]
operations = [
migrations.AlterField(
model_name="consist",
name="notes",
field=ckeditor_uploader.fields.RichTextUploadingField(blank=True),
),
# migrations.AlterField(
# model_name="consist",
# name="notes",
# field=ckeditor_uploader.fields.RichTextUploadingField(blank=True),
# ),
]

View File

@@ -0,0 +1,51 @@
# Generated by Django 4.2.6 on 2023-10-31 09:41
import os
import sys
import shutil
import ram.utils
from django.conf import settings
from django.db import migrations, models
def move_images(apps, schema_editor):
sys.stdout.write("\n Processing files. Please await...")
model = apps.get_model("consist", "Consist")
for r in model.objects.all():
if not r.image: # exit the loop if there's no image
continue
fname = os.path.basename(r.image.path)
new_image = os.path.join("images", "consists", fname)
new_path = os.path.join(settings.MEDIA_ROOT, new_image)
os.makedirs(os.path.dirname(new_path), exist_ok=True)
try:
shutil.move(r.image.path, new_path)
except FileNotFoundError:
sys.stderr.write(" !! FileNotFoundError: {}\n".format(new_image))
pass
r.image.name = new_image
r.save()
class Migration(migrations.Migration):
dependencies = [
("consist", "0008_alter_consist_options"),
]
operations = [
migrations.AlterField(
model_name="consist",
name="image",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to="images/consists",
),
),
migrations.RunPython(
move_images,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.0.2 on 2024-02-17 12:19
import tinymce.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("consist", "0009_alter_consist_image"),
]
operations = [
migrations.AlterField(
model_name="consist",
name="notes",
field=tinymce.models.HTMLField(blank=True),
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 5.0.4 on 2024-04-20 12:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("consist", "0010_alter_consist_notes"),
]
operations = [
migrations.AlterField(
model_name="consist",
name="consist_address",
field=models.SmallIntegerField(
blank=True,
default=None,
help_text="DCC consist address if enabled",
null=True,
),
),
migrations.AlterField(
model_name="consist",
name="era",
field=models.CharField(
blank=True, help_text="Era or epoch of the consist", max_length=32
),
),
]

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

View File

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

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

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

View File

@@ -1,29 +1,37 @@
from uuid import uuid4
import os
from django.db import models
from django.urls import reverse
from django.dispatch import receiver
from django.core.exceptions import ValidationError
from ckeditor_uploader.fields import RichTextUploadingField
from ram.models import BaseModel
from ram.utils import DeduplicatedStorage
from metadata.models import Company, 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(
default=None, null=True, blank=True
default=None,
null=True,
blank=True,
help_text="DCC consist address if enabled",
)
company = models.ForeignKey(Company, on_delete=models.CASCADE)
era = models.CharField(max_length=32, blank=True)
image = models.ImageField(
upload_to="images/", storage=DeduplicatedStorage, null=True, blank=True
era = models.CharField(
max_length=32,
blank=True,
help_text="Era or epoch of the consist",
)
image = models.ImageField(
upload_to=os.path.join("images", "consists"),
storage=DeduplicatedStorage,
null=True,
blank=True,
)
notes = RichTextUploadingField(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)
@@ -31,6 +39,16 @@ class Consist(models.Model):
def get_absolute_url(self):
return reverse("consist", kwargs={"uuid": self.uuid})
@property
def country(self):
return self.company.country
def clean(self):
if self.consist_item.filter(rolling_stock__published=False).exists():
raise ValidationError(
"You must publish all items in the consist before publishing the consist." # noqa: E501
)
class Meta:
ordering = ["company", "-creation_time"]
@@ -40,22 +58,55 @@ 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(
default=1000, # make sure it is always added at the end
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 published(self):
return self.rolling_stock.published
published.boolean = True
def preview(self):
return self.rolling_stock.image.first().image_thumbnail(100)
@property
def type(self):
return self.rolling_stock.rolling_class.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()

View File

@@ -21,4 +21,5 @@ class ConsistSerializer(serializers.ModelSerializer):
class Meta:
model = Consist
fields = "__all__"
exclude = ("notes",)
read_only_fields = ("creation_time", "updated_time")

View File

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

View File

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

View File

@@ -0,0 +1,80 @@
# Generated by Django 4.2.6 on 2023-10-30 13:16
import os
import sys
import shutil
import ram.utils
from django.conf import settings
from django.db import migrations, models
def move_images(apps, schema_editor):
fields = {
"Company": ["companies", "logo"],
"Decoder": ["decoders", "image"],
"Manufacturer": ["manufacturers", "logo"],
}
sys.stdout.write("\n Processing files. Please await...")
for m in fields.items():
model = apps.get_model("metadata", m[0])
for r in model.objects.all():
field = getattr(r, m[1][1])
if not field: # exit the loop if there's no image
continue
fname = os.path.basename(field.path)
new_image = os.path.join("images", m[1][0], fname)
new_path = os.path.join(settings.MEDIA_ROOT, new_image)
os.makedirs(os.path.dirname(new_path), exist_ok=True)
try:
shutil.move(field.path, new_path)
except FileNotFoundError:
sys.stderr.write(
" !! FileNotFoundError: {}\n".format(new_image)
)
pass
field.name = new_image
r.save()
class Migration(migrations.Migration):
dependencies = [
("metadata", "0014_alter_decoder_options_alter_tag_options"),
]
operations = [
migrations.AlterField(
model_name="company",
name="logo",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to="images/companies",
),
),
migrations.AlterField(
model_name="decoder",
name="image",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to="images/decoders",
),
),
migrations.AlterField(
model_name="manufacturer",
name="logo",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to="images/manufacturers",
),
),
migrations.RunPython(
move_images,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 4.2.6 on 2023-11-04 22:53
from django.db import migrations, models
import ram.utils
class Migration(migrations.Migration):
dependencies = [
("metadata", "0015_alter_company_logo_alter_decoder_image_and_more"),
]
operations = [
migrations.AlterField(
model_name="decoderdocument",
name="file",
field=models.FileField(
storage=ram.utils.DeduplicatedStorage(), upload_to="files/"
),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.0.4 on 2024-04-20 12:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("metadata", "0016_alter_decoderdocument_file"),
]
operations = [
migrations.AlterField(
model_name="property",
name="private",
field=models.BooleanField(
default=False, help_text="Property will be only visible to logged users"
),
),
]

View File

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

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

View File

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

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

View File

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

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

View File

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

View File

@@ -1,16 +1,21 @@
import os
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):
name = models.CharField(max_length=128, unique=True)
private = models.BooleanField(default=False)
private = models.BooleanField(
default=False,
help_text="Property will be only visible to logged users",
)
class Meta:
verbose_name_plural = "Properties"
@@ -19,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)
@@ -26,9 +33,13 @@ 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="images/", storage=DeduplicatedStorage, null=True, blank=True
upload_to=os.path.join("images", "manufacturers"),
storage=DeduplicatedStorage,
null=True,
blank=True,
)
class Meta:
@@ -39,10 +50,11 @@ class Manufacturer(models.Model):
def get_absolute_url(self):
return reverse(
"filtered", kwargs={
"filtered",
kwargs={
"_filter": "manufacturer",
"search": self.slug,
}
},
)
def logo_thumbnail(self):
@@ -58,7 +70,10 @@ class Company(models.Model):
country = CountryField()
freelance = models.BooleanField(default=False)
logo = models.ImageField(
upload_to="images/", storage=DeduplicatedStorage, null=True, blank=True
upload_to=os.path.join("images", "companies"),
storage=DeduplicatedStorage,
null=True,
blank=True,
)
class Meta:
@@ -70,10 +85,11 @@ class Company(models.Model):
def get_absolute_url(self):
return reverse(
"filtered", kwargs={
"filtered",
kwargs={
"_filter": "company",
"search": self.slug,
}
},
)
def extended_name_pp(self):
@@ -95,11 +111,14 @@ class Decoder(models.Model):
version = models.CharField(max_length=64, blank=True)
sound = models.BooleanField(default=False)
image = models.ImageField(
upload_to="images/", storage=DeduplicatedStorage, null=True, blank=True
upload_to=os.path.join("images", "decoders"),
storage=DeduplicatedStorage,
null=True,
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)
@@ -110,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()
@@ -149,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):
@@ -169,7 +206,7 @@ class Tag(models.Model):
name = models.CharField(max_length=128, unique=True)
slug = models.CharField(max_length=128, unique=True)
class Meta(object):
class Meta:
ordering = ["name"]
def __str__(self):
@@ -177,13 +214,28 @@ class Tag(models.Model):
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)

View File

@@ -1,11 +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", "rest_api", "version")
fieldsets = (
(
None,
@@ -16,8 +20,10 @@ class SiteConfigurationAdmin(SingletonModelAdmin):
"about",
"items_per_page",
"items_ordering",
"currency",
"footer",
"footer_extended",
"disclaimer",
)
},
),
@@ -29,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):
@@ -65,3 +90,4 @@ class FlatpageAdmin(admin.ModelAdmin):
},
),
)
actions = [publish, unpublish]

View File

@@ -1,7 +1,8 @@
# Generated by Django 4.1 on 2022-08-23 15:54
import ckeditor.fields
import ckeditor_uploader.fields
# ckeditor dependency removal
# import ckeditor.fields
# import ckeditor_uploader.fields
from django.db import migrations
@@ -12,24 +13,24 @@ class Migration(migrations.Migration):
]
operations = [
migrations.AlterField(
model_name="flatpage",
name="content",
field=ckeditor_uploader.fields.RichTextUploadingField(),
),
migrations.AlterField(
model_name="siteconfiguration",
name="about",
field=ckeditor.fields.RichTextField(blank=True),
),
migrations.AlterField(
model_name="siteconfiguration",
name="footer",
field=ckeditor.fields.RichTextField(blank=True),
),
migrations.AlterField(
model_name="siteconfiguration",
name="footer_extended",
field=ckeditor.fields.RichTextField(blank=True),
),
# migrations.AlterField(
# model_name="flatpage",
# name="content",
# field=ckeditor_uploader.fields.RichTextUploadingField(),
# ),
# migrations.AlterField(
# model_name="siteconfiguration",
# name="about",
# field=ckeditor.fields.RichTextField(blank=True),
# ),
# migrations.AlterField(
# model_name="siteconfiguration",
# name="footer",
# field=ckeditor.fields.RichTextField(blank=True),
# ),
# migrations.AlterField(
# model_name="siteconfiguration",
# name="footer_extended",
# field=ckeditor.fields.RichTextField(blank=True),
# ),
]

View File

@@ -0,0 +1,16 @@
# Generated by Django 5.0.1 on 2024-01-20 21:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("portal", "0015_siteconfiguration_use_cdn"),
]
operations = [
migrations.RemoveField(
model_name="siteconfiguration",
name="site_name",
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.0.2 on 2024-02-17 12:19
import tinymce.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("portal", "0016_remove_siteconfiguration_site_name"),
]
operations = [
migrations.AlterField(
model_name="flatpage",
name="content",
field=tinymce.models.HTMLField(),
),
migrations.AlterField(
model_name="siteconfiguration",
name="about",
field=tinymce.models.HTMLField(blank=True),
),
migrations.AlterField(
model_name="siteconfiguration",
name="footer",
field=tinymce.models.HTMLField(blank=True),
),
migrations.AlterField(
model_name="siteconfiguration",
name="footer_extended",
field=tinymce.models.HTMLField(blank=True),
),
]

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

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

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

View File

@@ -1,23 +1,21 @@
import django
from django.db import models
from django.conf import settings
from django.urls import reverse
from django.dispatch.dispatcher import receiver
from django.utils.safestring import mark_safe
from solo.models import SingletonModel
from ckeditor.fields import RichTextField
from ckeditor_uploader.fields import RichTextUploadingField
from tinymce import models as tinymce
from ram import __version__ as app_version
from ram.managers import PublicManager
from ram.utils import slugify
class SiteConfiguration(SingletonModel):
site_name = models.CharField(
max_length=256, default="Railroad Assets Manager"
)
site_author = models.CharField(max_length=256, blank=True)
about = RichTextField(blank=True)
about = tinymce.HTMLField(blank=True)
items_per_page = models.CharField(
max_length=2,
choices=[(str(x * 3), str(x * 3)) for x in range(2, 11)],
@@ -32,8 +30,10 @@ class SiteConfiguration(SingletonModel):
],
default="type",
)
footer = RichTextField(blank=True)
footer_extended = RichTextField(blank=True)
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 +44,14 @@ class SiteConfiguration(SingletonModel):
def __str__(self):
return "Site Configuration"
def site_name(self):
return settings.SITE_NAME
@property
def version(self):
return app_version
@property
def django_version(self):
return django.get_version()
@@ -55,10 +60,14 @@ class Flatpage(models.Model):
name = models.CharField(max_length=256, unique=True)
path = models.CharField(max_length=256, unique=True)
published = models.BooleanField(default=False)
content = RichTextUploadingField()
content = tinymce.HTMLField()
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):

View File

@@ -1,14 +1,14 @@
/*!
* Bootstrap Icons v1.11.1 (https://icons.getbootstrap.com/)
* Copyright 2019-2023 The Bootstrap Authors
* Bootstrap Icons v1.11.3 (https://icons.getbootstrap.com/)
* Copyright 2019-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE)
*/
@font-face {
font-display: block;
font-family: "bootstrap-icons";
src: url("./fonts/bootstrap-icons.woff2?2820a3852bdb9a5832199cc61cec4e65") format("woff2"),
url("./fonts/bootstrap-icons.woff?2820a3852bdb9a5832199cc61cec4e65") format("woff");
src: url("./fonts/bootstrap-icons.woff2?dd67030699838ea613ee6dbda90effa6") format("woff2"),
url("./fonts/bootstrap-icons.woff?dd67030699838ea613ee6dbda90effa6") format("woff");
}
.bi::before,

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

View File

@@ -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,10 +43,6 @@ 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;
padding-left: 1rem;

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

View File

@@ -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.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
<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">
{% else %}
<link href="{% static "bootstrap@5.3.2/dist/css/bootstrap.min.css" %}" rel="stylesheet">
<link href="{% static "bootstrap-icons@1.11.1/font/bootstrap-icons.css" %}" rel="stylesheet">
<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">
{% endif %}
<link href="{% static "css/main.css" %}?v={{ site_conf.version }}" rel="stylesheet">
<style>
@@ -115,19 +115,21 @@
})
})()
</script>
<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');
});
});
</script>
<script>
document.addEventListener('DOMContentLoaded', function () {
var selectElement = document.getElementById('tabSelector');
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 %}
{{ site_conf.extra_head | safe }}
{% endblock %}
@@ -180,13 +182,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>
@@ -216,9 +218,9 @@
</main>
{% include 'includes/footer.html' %}
{% if site_conf.use_cdn %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% else %}
<script src="{% static "bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" %}"></script>
<script src="{% static "bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" %}"></script>
{% endif %}
</body>
</html>

View File

@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load dynamic_url %}
{% block header %}
{% if book.tags.all %}
@@ -8,7 +9,7 @@
{% endfor %}
</p>
{% endif %}
<small class="text-muted">Updated {{ book.updated_time | date:"M d, Y H:i" }}</small>
<small class="text-body-secondary">Updated {{ book.updated_time | date:"M d, Y H:i" }}</small>
{% endblock %}
{% block carousel %}
<div class="row">
@@ -27,11 +28,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,35 +46,54 @@
<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">
<thead>
<tr>
<th colspan="2" scope="row">Book</th>
<th colspan="2" scope="row">
{% if type == "catalog" %}Catalog
{% elif type == "book" %}Book{% endif %}
<div class="float-end">
{% if not book.published %}
<span class="badge text-bg-warning">Draft</span>
{% endif %}
</div>
</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>
@@ -90,13 +110,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>
@@ -104,7 +152,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>
@@ -114,12 +162,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>

View File

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

View File

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

View File

@@ -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 }}"></a>
<a href="{{d.item.get_absolute_url}}"><img class="card-img-top" src="{{ d.item.image.first.image.url }}" alt="{{ d.item }}"></a>
{% else %}
<!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
<a href="{{d.item.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d.item }}"></a>
{% endif %}
<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">Draft</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>

View File

@@ -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,14 +34,8 @@
</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">

View File

@@ -2,10 +2,10 @@
<div class="card shadow-sm">
<a href="{{ d.item.get_absolute_url }}">
{% if d.item.image %}
<img class="card-img-top" src="{{ d.item.image.url }}" alt="{{ d }}">
<img class="card-img-top" src="{{ d.item.image.url }}" alt="{{ d.item }}">
{% else %}
{% with d.item.consist_item.first.rolling_stock as r %}
<img class="card-img-top" src="{{ r.image.first.image.url }}" alt="{{ d }}">
<img class="card-img-top" src="{{ r.image.first.image.url }}" alt="{{ d.item }}">
{% endwith %}
{% endif %}
</a>
@@ -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 d.item.company.freelance %}
<span class="badge text-bg-secondary">Freelance</span>
{% endif %}
{% if not d.item.published %}
<span class="badge text-bg-warning">Draft</span>
{% endif %}
</div>
</th>
</tr>
</thead>
<tbody class="table-group-divider">

View File

@@ -2,10 +2,10 @@
<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 }}"></a>
<a href="{{d.item.get_absolute_url}}"><img class="card-img-top" src="{{ d.item.image.first.image.url }}" alt="{{ d.item }}"></a>
{% else %}
<!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
<a href="{{d.item.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d }}"></a>
<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;">
@@ -22,7 +22,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 d.item.company.freelance %}
<span class="badge text-bg-secondary">Freelance</span>
{% endif %}
{% if not d.item.published %}
<span class="badge text-bg-warning">Draft</span>
{% endif %}
</div>
</th>
</tr>
</thead>
<tbody class="table-group-divider">
@@ -33,7 +43,7 @@
<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>
<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,15 +66,15 @@
</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 }}</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>
</tbody>
</table>
{% if d.item.decoder %}
{% if d.item.decoder or d.item.decoder_interface %}
<table class="table table-striped">
<thead>
<tr>
@@ -73,13 +83,19 @@
</thead>
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Decoder</th>
<th class="w-33" scope="row">Interface</th>
<td>{{ d.item.get_decoder_interface }}</td>
</tr>
{% if d.item.decoder %}
<tr>
<th scope="row">Decoder</th>
<td>{{ d.item.decoder }}</td>
</tr>
<tr>
<th scope="row">Address</th>
<td>{{ d.item.address }}</td>
</tr>
{% endif %}
</tbody>
</table>
{% endif %}

View File

@@ -18,17 +18,17 @@
<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>
<a class="btn btn-sm btn-outline-primary{% if d.item.num_items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="scale" 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 %}
</div>
</div>

View File

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

View File

@@ -7,7 +7,7 @@
{{ t.name }}</a>{# new line is required #}
{% endfor %}
</p>
<small class="text-muted">Updated {{ consist.updated_time | date:"M d, Y H:i" }}</small>
<small class="text-body-secondary">Updated {{ consist.updated_time | date:"M d, Y H:i" }}</small>
{% endif %}
{% endblock %}
{% block carousel %}
@@ -25,15 +25,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 +51,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 +68,26 @@
<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 %}
{% if not consist.published %}
<span class="badge text-bg-warning">Draft</span>
{% endif %}
</div>
</th>
</tr>
</thead>
<tbody class="table-group-divider">
@@ -93,6 +101,12 @@
<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>
@@ -100,20 +114,6 @@
</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>
</tr>
</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:consist_consist_change' consist.pk %}">Edit</a>{% endif %}

View File

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

View File

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

View File

@@ -1,12 +1,19 @@
{% extends 'base.html' %}
{% block header %}
<small class="text-muted">Updated {{ flatpage.updated_time | date:"M d, Y H:i" }}</small>
<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">
<div class="row">
<div class="mx-auto">
{% if not flatpage.published %}
<div class="alert alert-warning" role="alert">
⚠️ This page is a <strong>draft</strong> and is not published.
</div>
{% endif %}
<div>{{ flatpage.content | safe }} </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:portal_flatpage_change' flatpage.pk %}">Edit</a>{% endif %}

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More