39 Commits

Author SHA1 Message Date
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
136 changed files with 2718 additions and 472 deletions

1
.gitignore vendored
View File

@@ -131,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.10+
- Python 3.11+
- A USB port when running Arduino hardware (and adaptors if you have a Mac)
## Web portal installation
### Using containers
coming soon
Do it yourself, otherwise, raise a request :)
### Manual installation
@@ -83,6 +110,8 @@ $ python manage.py migrate
$ python manage.py createsuperuser
```
To load some sample metadata, see the [sample_data folder instructions](./sample_data/README.md).
Run Django
```bash
@@ -99,43 +128,52 @@ connected via serial port, to the network, allowing commands to be sent via a
TCP socket. A response generated by the DCC++ EX board is sent to all connected clients,
providing synchronization between multiple clients (eg. multiple JMRI instances).
Its use is not needed when running DCC++ EX from a [WiFi](https://dcc-ex.com/get-started/wifi-setup.html) capable board (like when
using an ESP8266 module or a [Mega+WiFi board](https://dcc-ex.com/advanced-setup/supported-microcontrollers/wifi-mega.html)).
Its use is not needed when running DCC++ EX from a [WiFi](https://dcc-ex.com/get-started/wifi-setup.html) capable board, like when
using an ESP8266 module, a [Mega+WiFi board](https://dcc-ex.com/reference/hardware/microcontrollers/wifi-mega.html), or an
[ESP32](https://dcc-ex.com/reference/hardware/microcontrollers/esp32.html) (recommended).
### Customize the settings
### Manual setup
The daemon comes with default settings in `config.ini`.
Settings may need to be customized based on your setup.
You'll need [namp-ncat](https://nmap.org/ncat/) , and `stty` to setup the serial port.
> [!IMPORTANT]
> Other variants of `nc` or `ncat` may not work as expected.
Then you can run the following commands:
```bash
$ stty -F /dev/ttyACM0 -echo 115200
$ ncat -n -k -l 2560 </dev/ttyACM0 >/dev/ttyACM0
```
> [!IMPORTANT]
> You'll might need to change the serial port (`/dev/ttyACM0`) to match your board.
> [!NOTE]
> Your user will also need access to the device file, so you might need to add it to the `dialout` group.
### Using containers
```bash
$ cd daemons
$ podman build -t dcc/net-to-serial .
$ podman run --group-add keep-groups --device /dev/ttyACM0 -p 2560:2560 dcc/net-to-serial
```
### Manual setup
```bash
$ cd daemons
$ pip install -r requirements.txt
$ python ./net-to-serial.py
$ cd connector
$ podman build -t dcc/connector .
$ podman run -d --group-add keep-groups --device /dev/ttyACM0:/dev/arduino -p 2560:2560 dcc/connector
```
### Test with a simulator
A [QEMU AVR based simulator](daemons/simulator/README.md) running DCC++ EX is bundled togheter with the `net-to-serial.py`
daemon into a container. To run it:
A [QEMU AVR based simulator](daemons/simulator/README.md) running DCC++ EX is bundled togheter with the connector
into a container. To run it:
```bash
$ cd daemons/simulator
$ podman build -t dcc/net-to-serial:sim .
$ podman run --init --cpus 0.1 -d -p 2560:2560 dcc/net-to-serial:sim
$ cd connector/simulator
$ podman build -t dcc/connector:sim .
$ podman run --init --cpus 0.1 -d -p 2560:2560 dcc/connector:sim
```
To be continued ...
> [!WARNING]
> The simulator is intended for light development and testing purposes only and far from being a complete replacement for a real hardware.
## Screenshots
@@ -146,15 +184,12 @@ To be continued ...
![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,6 +1,13 @@
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 ram.admin import publish, unpublish
from ram.utils import generate_csv
from portal.utils import get_site_conf
from bookshelf.models import (
BaseBookProperty,
BaseBookImage,
@@ -52,7 +59,7 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
"number_of_pages",
"published",
)
autocomplete_fields = ("authors", "publisher")
autocomplete_fields = ("authors", "publisher", "shop")
readonly_fields = ("creation_time", "updated_time")
search_fields = ("title", "publisher__name", "authors__last_name")
list_filter = ("publisher__name", "authors")
@@ -71,12 +78,24 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
"number_of_pages",
"publication_year",
"description",
"purchase_date",
"notes",
"tags",
)
},
),
(
"Purchase data",
{
"fields": (
"shop",
"purchase_date",
"price",
)
},
),
(
"Notes",
{"classes": ("collapse",), "fields": ("notes",)},
),
(
"Audit",
{
@@ -89,13 +108,70 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
),
)
def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
form.base_fields["price"].label = "Price ({})".format(
get_site_conf().currency
)
return form
@admin.display(description="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)
@@ -109,9 +185,15 @@ class AuthorAdmin(admin.ModelAdmin):
@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):
@@ -146,12 +228,23 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
"number_of_pages",
"publication_year",
"description",
"purchase_date",
"notes",
"tags",
)
},
),
(
"Purchase data",
{
"fields": (
"purchase_date",
"price",
)
},
),
(
"Notes",
{"classes": ("collapse",), "fields": ("notes",)},
),
(
"Audit",
{
@@ -164,6 +257,61 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
),
)
@admin.display(description="Scales")
def get_scales(self, obj):
return "/".join(s.scale for s in obj.scales.all())
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
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

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

@@ -5,12 +5,9 @@ from django.conf import settings
from django.urls import reverse
from django_countries.fields import CountryField
from tinymce import models as tinymce
from metadata.models import Tag
from ram.utils import DeduplicatedStorage
from ram.models import BaseModel, Image, Document, PropertyInstance
from metadata.models import Scale, Manufacturer
from metadata.models import Scale, Manufacturer, Shop, Tag
class Publisher(models.Model):
@@ -35,6 +32,7 @@ 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]}."
@@ -48,7 +46,15 @@ class BaseBook(BaseModel):
)
number_of_pages = models.SmallIntegerField(null=True, blank=True)
publication_year = models.SmallIntegerField(null=True, blank=True)
description = tinymce.HTMLField(blank=True)
shop = models.ForeignKey(
Shop, on_delete=models.CASCADE, null=True, blank=True
)
price = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
)
purchase_date = models.DateField(null=True, blank=True)
tags = models.ManyToManyField(
Tag, related_name="bookshelf", blank=True
@@ -90,7 +96,12 @@ class BaseBookDocument(Document):
class Meta:
verbose_name_plural = "Documents"
unique_together = ("book", "file")
constraints = [
models.UniqueConstraint(
fields=["book", "file"],
name="unique_book_file"
)
]
class BaseBookProperty(PropertyInstance):
@@ -114,9 +125,14 @@ class Book(BaseBook):
def __str__(self):
return self.title
@property
def publisher_name(self):
return self.publisher.name
@property
def authors_list(self):
return ", ".join(a.short_name for a in self.authors.all())
def get_absolute_url(self):
return reverse(
"bookshelf_item",
@@ -136,7 +152,7 @@ class Catalog(BaseBook):
ordering = ["manufacturer", "publication_year"]
def __str__(self):
scales = self.get_scales
scales = self.get_scales()
return "%s %s %s" % (self.manufacturer.name, self.years, scales)
def get_absolute_url(self):
@@ -145,6 +161,6 @@ class Catalog(BaseBook):
kwargs={"selector": "catalog", "uuid": self.uuid}
)
@property
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/<uuid:uuid>", BookGet.as_view()),
path("catalog/list", CatalogList.as_view()),
path("catalog/get/<uuid:uuid>", CatalogGet.as_view()),
]

View File

@@ -1,12 +1,14 @@
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):
serializer_class = BookSerializer
pagination_class = CustomLimitOffsetPagination
def get_queryset(self):
return Book.objects.get_published(self.request.user)
@@ -19,3 +21,20 @@ class BookGet(RetrieveAPIView):
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
@@ -9,7 +11,14 @@ class ConsistItemInline(SortableInlineAdminMixin, admin.TabularInline):
min_num = 1
extra = 0
autocomplete_fields = ("rolling_stock",)
readonly_fields = ("preview", "published", "address", "type", "company", "era")
readonly_fields = (
"preview",
"published",
"address",
"type",
"company",
"era",
)
@admin.register(Consist)
@@ -19,11 +28,17 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
"creation_time",
"updated_time",
)
list_display = ("identifier", "published", "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,
@@ -34,12 +49,16 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
"consist_address",
"company",
"era",
"description",
"image",
"notes",
"tags",
)
},
),
(
"Notes",
{"classes": ("collapse",), "fields": ("notes",)},
),
(
"Audit",
{
@@ -51,3 +70,4 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
},
),
)
actions = [publish, unpublish]

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

@@ -39,6 +39,10 @@ class Consist(BaseModel):
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(
@@ -54,10 +58,20 @@ 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)
@@ -69,15 +83,19 @@ class ConsistItem(models.Model):
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
@@ -87,7 +105,8 @@ class ConsistItem(models.Model):
# it is hosted here to avoid circular imports
@receiver(models.signals.post_save, sender=RollingStock)
def post_save_unpublish_consist(sender, instance, *args, **kwargs):
consists = Consist.objects.filter(consist_item__rolling_stock=instance)
for consist in consists:
consist.published = False
consist.save()
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,11 +1,13 @@
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):
serializer_class = ConsistSerializer
pagination_class = CustomLimitOffsetPagination
def get_queryset(self):
return Consist.objects.get_published(self.request.user)

View File

@@ -1,15 +1,19 @@
from django.contrib import admin
from django.utils.html import format_html
from adminsortable2.admin import SortableAdminMixin
from ram.admin import publish, unpublish
from metadata.models import (
Property,
Decoder,
DecoderDocument,
Scale,
Shop,
Manufacturer,
Company,
Tag,
RollingStockType,
GenericDocument,
)
@@ -45,18 +49,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 +86,55 @@ class RollingStockTypeAdmin(SortableAdminMixin, admin.ModelAdmin):
list_display = ("__str__",)
list_filter = ("type", "category")
search_fields = ("type", "category")
@admin.register(GenericDocument)
class GenericDocumentAdmin(admin.ModelAdmin):
readonly_fields = ("size", "creation_time", "updated_time")
list_display = (
"__str__",
"description",
"private",
"size",
"download",
)
search_fields = (
"description",
"file",
)
fieldsets = (
(
None,
{
"fields": (
"private",
"description",
"file",
"size",
"tags",
)
},
),
(
"Notes",
{"classes": ("collapse",), "fields": ("notes",)},
),
(
"Audit",
{
"classes": ("collapse",),
"fields": (
"creation_time",
"updated_time",
),
},
),
)
actions = [publish, unpublish]
@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,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

@@ -6,6 +6,8 @@ from django.dispatch.dispatcher import receiver
from django.core.exceptions import ValidationError
from django_countries.fields import CountryField
from tinymce import models as tinymce
from ram.models import Document
from ram.utils import DeduplicatedStorage, get_image_preview, slugify
from ram.managers import PublicManager
@@ -34,6 +36,7 @@ class Manufacturer(models.Model):
category = models.CharField(
max_length=64, choices=settings.MANUFACTURER_TYPES
)
country = CountryField(blank=True)
website = models.URLField(blank=True)
logo = models.ImageField(
upload_to=os.path.join("images", "manufacturers"),
@@ -117,7 +120,7 @@ class Decoder(models.Model):
blank=True,
)
class Meta(object):
class Meta:
ordering = ["manufacturer__name", "name"]
def __str__(self):
@@ -135,7 +138,12 @@ class DecoderDocument(Document):
)
class Meta:
unique_together = ("decoder", "file")
constraints = [
models.UniqueConstraint(
fields=["decoder", "file"],
name="unique_decoder_file"
)
]
def calculate_ratio(ratio):
@@ -189,8 +197,13 @@ 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):
@@ -210,7 +223,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):
@@ -226,6 +239,28 @@ class Tag(models.Model):
)
class GenericDocument(Document):
notes = tinymce.HTMLField(blank=True)
tags = models.ManyToManyField(Tag, blank=True)
class Meta:
verbose_name_plural = "Generic Documents"
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,12 +1,15 @@
from django.conf import settings
from django.contrib import admin
from solo.admin import SingletonModelAdmin
from tinymce.widgets import TinyMCE
from ram.admin import publish, unpublish
from portal.models import SiteConfiguration, Flatpage
@admin.register(SiteConfiguration)
class SiteConfigurationAdmin(SingletonModelAdmin):
readonly_fields = ("site_name",)
readonly_fields = ("site_name", "rest_api", "version")
fieldsets = (
(
None,
@@ -17,8 +20,10 @@ class SiteConfigurationAdmin(SingletonModelAdmin):
"about",
"items_per_page",
"items_ordering",
"currency",
"footer",
"footer_extended",
"disclaimer",
)
},
),
@@ -30,11 +35,30 @@ class SiteConfigurationAdmin(SingletonModelAdmin):
"show_version",
"use_cdn",
"extra_head",
"rest_api",
"version",
),
},
),
)
@admin.display(description="REST API enabled", boolean=True)
def rest_api(self, obj):
return settings.REST_ENABLED
@admin.display()
def version(self, obj):
return "{} (Django {})".format(obj.version, obj.django_version)
def formfield_for_dbfield(self, db_field, **kwargs):
if db_field.name in ("footer", "footer_extended", "disclaimer"):
return db_field.formfield(
widget=TinyMCE(
mce_attrs={"height": "200"},
)
)
return super().formfield_for_dbfield(db_field, **kwargs)
@admin.register(Flatpage)
class FlatpageAdmin(admin.ModelAdmin):
@@ -66,3 +90,4 @@ class FlatpageAdmin(admin.ModelAdmin):
},
),
)
actions = [publish, unpublish]

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

@@ -30,8 +30,10 @@ class SiteConfiguration(SingletonModel):
],
default="type",
)
currency = models.CharField(max_length=3, default="EUR")
footer = tinymce.HTMLField(blank=True)
footer_extended = tinymce.HTMLField(blank=True)
disclaimer = tinymce.HTMLField(blank=True)
show_version = models.BooleanField(default=True)
use_cdn = models.BooleanField(default=True)
extra_head = models.TextField(blank=True)
@@ -45,9 +47,11 @@ class SiteConfiguration(SingletonModel):
def site_name(self):
return settings.SITE_NAME
@property
def version(self):
return app_version
@property
def django_version(self):
return django.get_version()
@@ -60,6 +64,10 @@ class Flatpage(models.Model):
creation_time = models.DateTimeField(auto_now_add=True)
updated_time = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "page"
verbose_name_plural = "pages"
def __str__(self):
return self.name

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;

View File

@@ -118,14 +118,16 @@
<script>
document.addEventListener('DOMContentLoaded', function () {
var selectElement = document.getElementById('tabSelector');
selectElement.addEventListener('change', function () {
var selectedTabId = this.value;
var tabs = document.querySelectorAll('.tab-pane');
tabs.forEach(function (tab) {
tab.classList.remove('show', 'active');
});
document.getElementById(selectedTabId).classList.add('show', 'active');
});
try {
selectElement.addEventListener('change', function () {
var selectedTabId = this.value;
var tabs = document.querySelectorAll('.tab-pane');
tabs.forEach(function (tab) {
tab.classList.remove('show', 'active');
});
document.getElementById(selectedTabId).classList.add('show', 'active');
});
} catch (TypeError) { /* pass */ }
});
</script>
{% block extra_head %}

View File

@@ -9,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">
@@ -47,24 +47,25 @@
<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 documents %}<button class="nav-link" id="nav-documents-tab" data-bs-toggle="tab" data-bs-target="#nav-documents" type="button" role="tab" aria-controls="nav-documents" aria-selected="false">Documents</button>{% endif %}
{% if 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 %}
</nav>
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
<option value="nav-summary" selected>Summary</option>
{% if documents %}<option value="nav-documents">Documents</option>{% endif %}
{% if book.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">
{{ book.description | safe }}
<thead>
<tr>
{% if type == "catalog" %}
<th colspan="2" scope="row">Catalog</th>
{% elif type == "book" %}
<th colspan="2" scope="row">Book</th>
{% endif %}
<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">
@@ -109,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>
@@ -123,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>
@@ -151,9 +180,6 @@
</tbody>
</table>
</div>
<div class="tab-pane" id="nav-notes" role="tabpanel" aria-labelledby="nav-notes-tab">
{{ book.notes | safe }}
</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="{% dynamic_admin_url 'bookshelf' type book.pk %}">Edit</a>{% endif %}

View File

@@ -1,6 +1,6 @@
{% 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">

View File

@@ -1,8 +1,12 @@
{% load static %}
{% load dynamic_url %}
<div class="col">
<div class="card shadow-sm">
{% if d.item.image.exists %}
<a href="{{d.item.get_absolute_url}}"><img class="card-img-top" src="{{ d.item.image.first.image.url }}" alt="{{ d.item }}"></a>
{% else %}
<!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
<a href="{{d.item.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d.item }}"></a>
{% endif %}
<div class="card-body">
<p class="card-text" style="position: relative;">
@@ -19,11 +23,15 @@
<table class="table table-striped">
<thead>
<tr>
{% if d.type == "catalog" %}
<th colspan="2" scope="row">Catalog</th>
{% elif d.type == "book" %}
<th colspan="2" scope="row">Book</th>
{% endif %}
<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">

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

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

@@ -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>
@@ -64,7 +74,7 @@
</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

@@ -28,7 +28,7 @@
</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

@@ -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,7 +25,7 @@
{% endblock %}
{% block pagination %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<nav aria-label="Page navigation">
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %}
<li class="page-item">
@@ -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,7 +1,7 @@
{% extends "cards.html" %}
{% block pagination %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<nav aria-label="Page navigation">
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %}
<li class="page-item">

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 "pagination.html" %}
{% block header %}
<div class="text-muted">{{ site_conf.about | safe }}</div>
<div class="text-body-secondary">{{ site_conf.about | safe }}</div>
{% endblock %}

View File

@@ -1,18 +1,34 @@
<footer class="text-muted py-4">
<div class="container">
<p class="float-end mb-1">
<a href="#">Back to top</a>
</p>
<footer class="text-body-secondary py-4">
<div class="container d-lg-flex justify-content-between">
<div id="footer" class="mb-1">
<p>&copy; {% now "Y" %}</p> {{ site_conf.footer | safe }}
<p>&copy; {% now "Y" %}</p> {{ site_conf.footer | safe }}
</div>
<div id="footer_extended" class="mb-0">
</div>
<div class="container">
<div id="footer_extended">
{{ site_conf.footer_extended | safe }}
</div>
</div>
<div class="container">
<p class="small text-muted">Made with ❤️ for 🚂 and <a href="https://github.com/daniviga/django-ram">django-ram</a>
{% if site_conf.show_version %}<br>Version {{ site_conf.version }}{% endif %}
<div class="container d-flex text-body-secondary">
<p class="flex-fill small">Made with ❤️ for 🚂 and <i class="bi bi-github"></i> <a href="https://github.com/daniviga/django-ram">django-ram</a>
{% if site_conf.show_version %}<br>Version {{ site_conf.version }}{% endif %}</p>
<p class="text-end fs-5">
{% if site_conf.disclaimer %}<a class="text-reset" title="Disclaimer" href="" data-bs-toggle="modal" data-bs-target="#disclaimerModal"><i class="bi bi-info-square-fill"></i></a> {% endif %}
<a class="text-reset" title="Back to top" href="#"><i class="bi bi-arrow-up-left-square-fill"></i></a>
</p>
</div>
<!-- Modal -->
<div class="modal fade" id="disclaimerModal" tabindex="-1" aria-labelledby="disclaimerLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="disclaimerLabel">Disclaimer</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{ site_conf.disclaimer | safe }}
</div>
</div>
</div>
</div>
</footer>

View File

@@ -1,7 +1,7 @@
{% extends "cards.html" %}
{% block pagination %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<nav aria-label="Page navigation">
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %}
<li class="page-item">

View File

@@ -3,7 +3,7 @@
{% block pagination %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<nav aria-label="Page navigation">
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %}
<li class="page-item">

View File

@@ -2,7 +2,7 @@
{% block pagination %}
{% if data.has_other_pages %}
{% with data.0.item.category as c %}
<nav aria-label="Page navigation example">
<nav aria-label="Page navigation">
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %}
<li class="page-item">

View File

@@ -8,7 +8,7 @@
{% endfor %}
</p>
{% endif %}
<small class="text-muted">Updated {{ rolling_stock.updated_time | date:"M d, Y H:i" }}</small>
<small class="text-body-secondary">Updated {{ rolling_stock.updated_time | date:"M d, Y H:i" }}</small>
{% endblock %}
{% block carousel %}
<div class="row">
@@ -51,7 +51,6 @@
{% if rolling_stock.decoder %}<button class="nav-link" id="nav-dcc-tab" data-bs-toggle="tab" data-bs-target="#nav-dcc" type="button" role="tab" aria-controls="nav-dcc" aria-selected="false">DCC</button>{% endif %}
{% if documents or decoder_documents %}<button class="nav-link" id="nav-documents-tab" data-bs-toggle="tab" data-bs-target="#nav-documents" type="button" role="tab" aria-controls="nav-documents" aria-selected="false">Documents</button>{% endif %}
{% if journal %}<button class="nav-link" id="nav-journal-tab" data-bs-toggle="tab" data-bs-target="#nav-journal" type="button" role="tab" aria-controls="nav-journal" aria-selected="false">Journal</button>{% endif %}
{% if rolling_stock.notes %}<button class="nav-link" id="nav-notes-tab" data-bs-toggle="tab" data-bs-target="#nav-notes" type="button" role="tab" aria-controls="nav-notes" aria-selected="false">Notes</button>{% endif %}
{% if set %}<button class="nav-link" id="nav-set-tab" data-bs-toggle="tab" data-bs-target="#nav-set" type="button" role="tab" aria-controls="nav-set" aria-selected="false">Set</button>{% endif %}
{% if consists %}<button class="nav-link" id="nav-consists-tab" data-bs-toggle="tab" data-bs-target="#nav-consists" type="button" role="tab" aria-controls="nav-consists" aria-selected="false">Consists</button>{% endif %}
</nav>
@@ -63,7 +62,6 @@
{% if rolling_stock.decoder %}<option value="nav-dcc">DCC</option>{% endif %}
{% if documents or decoder_documents %}<option value="nav-documents">Documents</option>{% endif %}
{% if journal %}<option value="nav-journal">Journal</option>{% endif %}
{% if rolling_stock.notes %}<option value="nav-notes">Notes</option>{% endif %}
{% if set %}<option value="nav-set">Set</option>{% endif %}
{% if consists %}<option value="nav-consists">Consists</option>{% endif %}
</select>
@@ -73,7 +71,17 @@
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Data</th>
<th colspan="2" scope="row">
Rolling stock
<div class="mt-1 float-end">
{% if company.freelance %}
<span class="badge text-bg-secondary">Freelance</span>
{% endif %}
{% if not rolling_stock.published %}
<span class="badge text-bg-warning">Draft</span>
{% endif %}
</div>
</th>
</tr>
</thead>
<tbody class="table-group-divider">
@@ -84,7 +92,13 @@
<tr>
<th scope="row">Company</th>
<td>
<a href="{% url 'filtered' _filter="company" search=company.slug %}">{{ company }}</a> {{ company.extended_name_pp }}
<a href="{% url 'filtered' _filter="company" search=company.slug %}">{{ company }}</a> {{ company.extended_name_pp }}
</td>
</tr>
<tr>
<th scope="row">Country</th>
<td>
<img src="{{ company.country.flag }}" alt="{{ company.country }}"> {{ company.country.name }}
</td>
</tr>
<tr>
@@ -134,7 +148,7 @@
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Interface</th>
<td>{{ rolling_stock.get_decoder_interface_display }}</td>
<td>{{ rolling_stock.get_decoder_interface }}</td>
</tr>
{% if rolling_stock.decoder %}
<tr>
@@ -151,7 +165,6 @@
{% endif %}
</div>
<div class="tab-pane" id="nav-model" role="tabpanel" aria-labelledby="nav-model-tab">
{{ rolling_stock.description | safe }}
<table class="table table-striped">
<thead>
<tr>
@@ -181,14 +194,42 @@
</tr>
<tr>
<th scope="row">Production year</th>
<td>{{ rolling_stock.production_year|default:"-" }}</td>
<td>{{ rolling_stock.production_year | default:"-" }}</td>
</tr>
{% if rolling_stock.description %}
<tr>
<th class="w-33" scope="row">Description</th>
<td>{{ rolling_stock.description | safe }}</td>
</tr>
{% endif %}
</tbody>
</table>
{% if request.user.is_staff %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Purchase</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Shop</th>
<td>
{{ rolling_stock.shop | default:"-" }}
{% if rolling_stock.shop.website %} <a href="{{ rolling_stock.shop.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr>
<tr>
<th scope="row">Purchase date</th>
<td>{{ rolling_stock.purchase_date|default:"-" }}</td>
<th class="w-33" scope="row">Purchase date</th>
<td>{{ rolling_stock.purchase_date | default:"-" }}</td>
</tr>
<tr>
<th scope="row">Price ({{ site_conf.currency }})</th>
<td>{{ rolling_stock.price | default:"-" }}</td>
</tr>
</tbody>
</table>
{% endif %}
{% if properties %}
<table class="table table-striped">
<thead>
@@ -208,7 +249,6 @@
{% endif %}
</div>
<div class="tab-pane" id="nav-class" role="tabpanel" aria-labelledby="nav-class-tab">
{{ class.description | safe }}
<table class="table table-striped">
<thead>
<tr>
@@ -227,11 +267,20 @@
<tr>
<th scope="row">Manufacturer</th>
<td>
{%if class.manufacturer %}
<a href="{% url 'filtered' _filter="manufacturer" search=class.manufacturer.slug %}">{{ class.manufacturer }}</a>{% if class.manufacturer.website %} <a href="{{ class.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
{% else %}-{% endif %}
{% for m in class.manufacturer.all %}
{% if not forloop.first %} / {% endif %}
<a href="{% url 'filtered' _filter="manufacturer" search=m.slug %}">{{ m }}</a>{% if m.website %} <a href="{{ m.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
{% empty %}
-
{% endfor %}
</td>
</tr>
{% if class.description %}
<tr>
<th class="w-33" scope="row">Description</th>
<td>{{ class.description | safe }}</td>
</tr>
{% endif %}
</tbody>
</table>
{% if class_properties %}
@@ -256,7 +305,12 @@
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Company data</th>
<th colspan="2" scope="row">
Company data
{% if company.freelance %}
<span class="mt-1 float-end badge text-bg-secondary">Freelance</span>
{% endif %}
</th>
</tr>
</thead>
<tbody class="table-group-divider">
@@ -268,18 +322,16 @@
{% endif %}
<tr>
<th class="w-33" scope="row">Name</th>
<td><a href="{% url 'filtered' _filter="company" search=company.slug %}">{{ company.name }}</a> {{ company.extended_name_pp }}</td>
<td>
<a href="{% url 'filtered' _filter="company" search=company.slug %}">{{ company.name }}</a> {{ company.extended_name_pp }}
</td>
</tr>
<tr>
<th class="w-33" scope="row">Country</th>
<td>{{ company.country.name }} <img src="{{ company.country.flag }}" alt="{{ company.country }}">
<td>
<img src="{{ company.country.flag }}" alt="{{ company.country }}"> {{ company.country.name }}
</td>
</tr>
{% if company.freelance %}
<tr>
<th class="w-33" scope="row">Notes</th>
<td>A <em>freelance</em> company</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
@@ -293,7 +345,7 @@
<tbody class="table-group-divider">
<tr>
<th scope="row">Interface</th>
<td>{{ rolling_stock.get_decoder_interface_display }}</td>
<td>{{ rolling_stock.get_decoder_interface }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Address</th>
@@ -305,11 +357,11 @@
</tr>
<tr>
<th scope="row">Manufacturer</th>
<td>{{ rolling_stock.decoder.manufacturer|default:"-" }}</td>
<td>{{ rolling_stock.decoder.manufacturer | default:"-" }}</td>
</tr>
<tr>
<th scope="row">Version</th>
<td>{{ rolling_stock.decoder.version }}</td>
<td>{{ rolling_stock.decoder.version | default:"-"}}</td>
</tr>
<tr>
<th scope="row">Sound</th>
@@ -373,9 +425,6 @@
</tbody>
</table>
</div>
<div class="tab-pane" id="nav-notes" role="tabpanel" aria-labelledby="nav-notes-tab">
{{ rolling_stock.notes | safe }}
</div>
<div class="tab-pane" id="nav-set" role="tabpanel" aria-labelledby="nav-set-tab">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3 mb-3">
{% for d in set %}

View File

@@ -2,7 +2,7 @@
{% block pagination %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<nav aria-label="Page navigation">
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %}
<li class="page-item">

View File

@@ -7,7 +7,7 @@ from urllib.parse import unquote
from django.views import View
from django.http import Http404, HttpResponseBadRequest
from django.db.utils import OperationalError, ProgrammingError
from django.db.models import Q
from django.db.models import Q, Count
from django.shortcuts import render, get_object_or_404, get_list_or_404
from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator
@@ -259,16 +259,17 @@ class GetManufacturerItem(View):
manufacturer,
# all returned records must have the same `item_number``;
# just pick it up the first result, otherwise `search`
roster.first.item_number if roster else search,
roster[0].item_number if roster else search,
)
else:
roster = (
RollingStock.objects.get_published(request.user)
.order_by(*get_order_by_field())
.filter(
Q(manufacturer=manufacturer)
| Q(rolling_class__manufacturer=manufacturer)
)
.distinct()
.order_by(*get_order_by_field())
)
title = "Manufacturer: {0}".format(manufacturer)
@@ -344,6 +345,13 @@ class GetObjectsFiltered(View):
)
for item in books:
data.append({"type": "book", "item": item})
catalogs = (
Catalog.objects.get_published(request.user)
.filter(query_2nd)
.distinct()
)
for item in catalogs:
data.append({"type": "catalog", "item": item})
except NameError:
pass
@@ -507,7 +515,9 @@ class Scales(GetData):
queryset = Scale.objects.all()
def get_data(self, request):
return Scale.objects.all()
return Scale.objects.annotate(
num_items=Count("rollingstock")
) # .filter(num_items__gt=0) to filter data with no items
class Types(GetData):

View File

@@ -1,4 +1,4 @@
from ram.utils import git_suffix
__version__ = "0.14.1"
__version__ = "0.16.9"
__version__ += git_suffix(__file__)

View File

@@ -2,3 +2,21 @@ from django.contrib import admin
from django.conf import settings
admin.site.site_header = settings.SITE_NAME
def publish(modeladmin, request, queryset):
for obj in queryset:
obj.published = True
obj.save()
publish.short_description = "Publish selected items"
def unpublish(modeladmin, request, queryset):
for obj in queryset:
obj.published = False
obj.save()
unpublish.short_description = "Unpublish selected items"

View File

@@ -15,6 +15,7 @@ DEBUG = False
# SECURITY WARNING: cache middlewares must be loaded before cookies one
MIDDLEWARE = [
"ram.middleware.DisableClientSideCachingMiddleware",
"django.middleware.cache.UpdateCacheMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.cache.FetchFromCacheMiddleware",
@@ -22,8 +23,8 @@ MIDDLEWARE = [
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://127.0.0.1:6379",
"BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
"LOCATION": settings.STORAGE_DIR / "cache",
}
}

20
ram/ram/middleware.py Normal file
View File

@@ -0,0 +1,20 @@
from django.core.cache import cache
from django.utils.cache import add_never_cache_headers, get_cache_key
class DisableClientSideCachingMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
# Check if the cache key exists for this request
cache_key = get_cache_key(request)
cache_hit = "MISS"
if cache_key and cache.get(cache_key):
cache_hit = "HIT"
response['X-Cache-Hit'] = cache_hit
add_never_cache_headers(response)
return response

View File

@@ -11,6 +11,7 @@ from ram.managers import PublicManager
class BaseModel(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False)
description = tinymce.HTMLField(blank=True)
notes = tinymce.HTMLField(blank=True)
creation_time = models.DateTimeField(auto_now_add=True)
updated_time = models.DateTimeField(auto_now=True)
@@ -28,7 +29,12 @@ class Document(models.Model):
upload_to="files/",
storage=DeduplicatedStorage(),
)
private = models.BooleanField(default=False)
private = models.BooleanField(
default=False,
help_text="Document will be visible only to logged users",
)
creation_time = models.DateTimeField(auto_now_add=True)
updated_time = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
@@ -37,9 +43,19 @@ class Document(models.Model):
def __str__(self):
return "{0}".format(os.path.basename(self.file.name))
@property
def filename(self):
return self.__str__()
@property
def size(self):
kb = self.file.size / 1024.0
if kb < 1024:
size = "{0} KB".format(round(kb))
else:
size = "{0} MB".format(round(kb / 1024.0))
return size
def download(self):
return mark_safe(
'<a href="{0}" target="_blank">Link</a>'.format(self.file.url)

View File

@@ -142,6 +142,12 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
MEDIA_URL = "media/"
MEDIA_ROOT = STORAGE_DIR / "media"
REST_ENABLED = False # Set to True to enable the REST API
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
"PAGE_SIZE": 5,
}
TINYMCE_DEFAULT_CONFIG = {
"height": "500px",
"menubar": False,
@@ -170,6 +176,9 @@ SITE_NAME = "Railroad Assets Manger"
# The file must be placed in the root of the 'static' folder
DEFAULT_CARD_IMAGE = "coming_soon.svg"
# Second level ALT separator for CSV files (e.g. for properties)
CSV_SEPARATOR_ALT = ";"
DECODER_INTERFACES = [
(0, "Built-in"),
(1, "NEM651"),

View File

@@ -32,9 +32,6 @@ urlpatterns = [
path("tinymce/upload_image", UploadImage.as_view(), name="upload_image"),
path("portal/", include("portal.urls")),
path("admin/", admin.site.urls),
path("api/v1/consist/", include("consist.urls")),
path("api/v1/roster/", include("roster.urls")),
path("api/v1/bookshelf/", include("bookshelf.urls")),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# Enable the "/dcc" routing only if the "driver" app is active
@@ -43,30 +40,37 @@ if apps.is_installed("driver"):
path("api/v1/dcc/", include("driver.urls")),
]
if settings.DEBUG:
from django.views.generic import TemplateView
from rest_framework.schemas import get_schema_view
if settings.REST_ENABLED:
urlpatterns += [
path(
"swagger/",
TemplateView.as_view(
template_name="swagger.html",
extra_context={"schema_url": "openapi-schema"},
),
name="swagger",
),
path(
"openapi",
get_schema_view(
title="RAM - Railroad Assets Manager",
description="RAM API",
version="1.0.0",
),
name="openapi-schema",
),
path("api/v1/consist/", include("consist.urls")),
path("api/v1/roster/", include("roster.urls")),
path("api/v1/bookshelf/", include("bookshelf.urls")),
]
if settings.DEBUG:
if apps.is_installed("debug_toolbar"):
urlpatterns += [
path("__debug__/", include("debug_toolbar.urls")),
]
if settings.REST_ENABLED:
from django.views.generic import TemplateView
from rest_framework.schemas import get_schema_view
urlpatterns += [
path(
"swagger/",
TemplateView.as_view(
template_name="swagger.html",
extra_context={"schema_url": "openapi-schema"},
),
name="swagger",
),
path(
"openapi",
get_schema_view(
title="RAM - Railroad Assets Manager",
description="RAM API",
version="1.0.0",
),
name="openapi-schema",
),
]

View File

@@ -1,7 +1,9 @@
import os
import csv
import hashlib
import subprocess
from django.http import HttpResponse
from django.utils.html import format_html
from django.utils.text import slugify as django_slugify
from django.core.files.storage import FileSystemStorage
@@ -57,3 +59,15 @@ def slugify(string, custom_separator=None):
if custom_separator is not None:
string = string.replace("-", custom_separator)
return string
def generate_csv(header, data, filename):
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = 'attachment; filename="{}"'.format(
filename
)
writer = csv.writer(response)
writer.writerow(header)
for row in data:
writer.writerow(row)
return response

View File

@@ -16,6 +16,13 @@ from django.utils.text import slugify as slugify
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from rest_framework.pagination import LimitOffsetPagination
class CustomLimitOffsetPagination(LimitOffsetPagination):
default_limit = 10
max_limit = 25
@method_decorator(csrf_exempt, name="dispatch")
class UploadImage(View):

View File

@@ -1,6 +1,14 @@
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 ram.admin import publish, unpublish
from ram.utils import generate_csv
from portal.utils import get_site_conf
from roster.models import (
RollingClass,
RollingClassProperty,
@@ -23,7 +31,7 @@ class RollingClassPropertyInline(admin.TabularInline):
class RollingClass(admin.ModelAdmin):
inlines = (RollingClassPropertyInline,)
autocomplete_fields = ("manufacturer",)
list_display = ("__str__", "type", "company")
list_display = ("__str__", "type", "company", "country_flag")
list_filter = ("company", "type__category", "type")
search_fields = (
"identifier",
@@ -32,6 +40,12 @@ class RollingClass(admin.ModelAdmin):
)
save_as = True
@admin.display(description="Country")
def country_flag(self, obj):
return format_html(
'<img src="{}" /> {}'.format(obj.country.flag, obj.country)
)
class RollingStockDocInline(admin.TabularInline):
model = RollingStockDocument
@@ -64,11 +78,13 @@ class RollingStockJournalInline(admin.TabularInline):
@admin.register(RollingStockDocument)
class RollingStockDocumentAdmin(admin.ModelAdmin):
readonly_fields = ("size",)
list_display = (
"__str__",
"rolling_stock",
"description",
"private",
"size",
"download",
)
search_fields = (
@@ -77,6 +93,21 @@ class RollingStockDocumentAdmin(admin.ModelAdmin):
"description",
"file",
)
autocomplete_fields = ("rolling_stock",)
fieldsets = (
(
None,
{
"fields": (
"private",
"rolling_stock",
"description",
"file",
"size",
)
},
),
)
@admin.register(RollingStockJournal)
@@ -84,19 +115,32 @@ class RollingJournalDocumentAdmin(admin.ModelAdmin):
list_display = (
"__str__",
"date",
"rolling_stock",
"private",
)
list_filter = (
"date",
"private",
)
autocomplete_fields = ("rolling_stock",)
search_fields = (
"rolling_stock__rolling_class__identifier",
"rolling_stock__road_number",
"rolling_stock__item_number",
"log",
)
fieldsets = (
(
None,
{
"fields": (
"private",
"rolling_stock",
"log",
"date",
)
},
),
)
@admin.register(RollingStock)
@@ -107,17 +151,17 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
RollingStockDocInline,
RollingStockJournalInline,
)
autocomplete_fields = ("rolling_class",)
autocomplete_fields = ("rolling_class", "shop")
readonly_fields = ("preview", "creation_time", "updated_time")
list_display = (
"__str__",
"published",
"address",
"manufacturer",
"scale",
"item_number",
"company",
"country",
"country_flag",
"published",
)
list_filter = (
"rolling_class__type__category",
@@ -136,6 +180,12 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
)
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,
@@ -152,8 +202,6 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
"era",
"description",
"production_year",
"purchase_date",
"notes",
"tags",
)
},
@@ -168,6 +216,20 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
)
},
),
(
"Purchase data",
{
"fields": (
"shop",
"purchase_date",
"price",
)
},
),
(
"Notes",
{"classes": ("collapse",), "fields": ("notes",)},
),
(
"Audit",
{
@@ -179,3 +241,71 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
},
),
)
def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
form.base_fields["price"].label = "Price ({})".format(
get_site_conf().currency
)
return form
def download_csv(modeladmin, request, queryset):
header = [
"Name",
"Company",
"Identifier",
"Road Number",
"Manufacturer",
"Scale",
"Item Number",
"Set",
"Era",
"Description",
"Production Year",
"Notes",
"Tags",
"Decoder Interface",
"Decoder",
"Address",
"Shop",
"Purchase Date",
"Price ({})".format(get_site_conf().currency),
"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.rolling_class.company.name,
obj.rolling_class.identifier,
obj.road_number,
obj.manufacturer.name,
obj.scale.scale,
obj.item_number,
obj.set,
obj.era,
html.unescape(strip_tags(obj.description)),
obj.production_year,
html.unescape(strip_tags(obj.notes)),
settings.CSV_SEPARATOR_ALT.join(
t.name for t in obj.tags.all()
),
obj.decoder_interface,
obj.decoder,
obj.address,
obj.purchase_date,
obj.shop,
obj.price,
properties,
]
)
return generate_csv(header, data, "rolling_stock.csv")
download_csv.short_description = "Download selected items as CSV"
actions = [publish, unpublish, download_csv]

View File

@@ -0,0 +1,36 @@
# Generated by Django 5.1.4 on 2024-12-29 15:23
from django.db import migrations, models
def price_to_property(apps, schema_editor):
rollingstock = apps.get_model("roster", "RollingStock")
for row in rollingstock.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 = [
("roster", "0029_alter_rollingstockimage_options"),
]
operations = [
migrations.AddField(
model_name="rollingstock",
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:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("roster", "0030_rollingstock_price"),
]
operations = [
migrations.AlterUniqueTogether(
name="rollingstockdocument",
unique_together=set(),
),
migrations.AddConstraint(
model_name="rollingstockdocument",
constraint=models.UniqueConstraint(
fields=("rolling_stock", "file"), name="unique_stock_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 = [
("roster", "0031_alter_rollingstockdocument_unique_together_and_more"),
]
operations = [
migrations.AddField(
model_name="rollingstockdocument",
name="creation_time",
field=models.DateTimeField(
auto_now_add=True, default=django.utils.timezone.now
),
preserve_default=False,
),
migrations.AddField(
model_name="rollingstockdocument",
name="updated_time",
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name="rollingstockdocument",
name="private",
field=models.BooleanField(
default=False, help_text="Document will be visible only to logged users"
),
),
]

View File

@@ -0,0 +1,45 @@
# Generated by Django 5.1.4 on 2025-01-20 21:25
from django.db import migrations, models
def manufacturer_to_many(apps, schema_editor):
rolling_class = apps.get_model("roster", "RollingClass")
for row in rolling_class.objects.all():
manufacturer = row.manufacturer_old
if manufacturer:
row.manufacturer.add(manufacturer)
row.save()
class Migration(migrations.Migration):
dependencies = [
("metadata", "0022_decoderdocument_creation_time_and_more"),
("roster", "0032_rollingstockdocument_creation_time_and_more"),
]
operations = [
migrations.RenameField(
model_name="rollingclass",
old_name="manufacturer",
new_name="manufacturer_old",
),
migrations.AddField(
model_name="rollingclass",
name="manufacturer",
field=models.ManyToManyField(
blank=True,
limit_choices_to={"category": "real"},
to="metadata.manufacturer",
),
),
migrations.RunPython(
manufacturer_to_many,
reverse_code=migrations.RunPython.noop
),
migrations.RemoveField(
model_name="rollingclass",
name="manufacturer_old",
),
]

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):
rolling_stock = apps.get_model("roster", "RollingStock")
shop_model = apps.get_model("metadata", "Shop")
for row in rolling_stock.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 = [
("metadata", "0023_shop"),
("roster", "0033_rename_manufacturer_rollingclass_manufacturer_old"),
]
operations = [
migrations.AddField(
model_name="rollingstock",
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,25 @@
# Generated by Django 5.1.4 on 2025-01-27 22:21
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("metadata", "0023_shop"),
("roster", "0034_rollingstock_shop"),
]
operations = [
migrations.AlterField(
model_name="rollingstock",
name="shop",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="metadata.shop",
),
),
]

View File

@@ -9,11 +9,12 @@ from django.dispatch import receiver
from tinymce import models as tinymce
from ram.models import BaseModel, Document, Image, PropertyInstance
from ram.utils import DeduplicatedStorage
from ram.utils import DeduplicatedStorage, slugify
from ram.managers import PublicManager
from metadata.models import (
Scale,
Manufacturer,
Shop,
Decoder,
Company,
Tag,
@@ -26,10 +27,8 @@ class RollingClass(models.Model):
type = models.ForeignKey(RollingStockType, on_delete=models.CASCADE)
company = models.ForeignKey(Company, on_delete=models.CASCADE)
description = tinymce.HTMLField(blank=True)
manufacturer = models.ForeignKey(
manufacturer = models.ManyToManyField(
Manufacturer,
on_delete=models.CASCADE,
null=True,
blank=True,
limit_choices_to={"category": "real"},
)
@@ -42,6 +41,10 @@ class RollingClass(models.Model):
def __str__(self):
return "{0} {1}".format(self.company, self.identifier)
@property
def country(self):
return self.company.country
class RollingClassProperty(PropertyInstance):
rolling_class = models.ForeignKey(
@@ -100,8 +103,16 @@ class RollingStock(BaseModel):
help_text="Era or epoch of the model",
)
production_year = models.SmallIntegerField(null=True, blank=True)
shop = models.ForeignKey(
Shop, on_delete=models.SET_NULL, null=True, blank=True
)
purchase_date = models.DateField(null=True, blank=True)
description = tinymce.HTMLField(blank=True)
price = models.DecimalField(
max_digits=10,
decimal_places=2,
null=True,
blank=True,
)
tags = models.ManyToManyField(
Tag, related_name="rolling_stock", blank=True
)
@@ -119,11 +130,21 @@ class RollingStock(BaseModel):
def preview(self):
return self.image.first().image_thumbnail(350)
def country(self):
return str(self.rolling_class.company.country)
# similar to get_decoder_interface_display in template render,
# but returns "-" if no decoder interface is set
def get_decoder_interface(self):
return str(
dict(settings.DECODER_INTERFACES).get(self.decoder_interface)
or "-"
)
@property
def country(self):
return self.rolling_class.company.country
@property
def company(self):
return str(self.rolling_class.company)
return self.rolling_class.company
def delete(self, *args, **kwargs):
shutil.rmtree(
@@ -136,13 +157,16 @@ class RollingStock(BaseModel):
@receiver(models.signals.pre_save, sender=RollingStock)
def pre_save_running_number(sender, instance, *args, **kwargs):
def pre_save_internal_fields(sender, instance, *args, **kwargs):
# Extract road number integer from road number
try:
instance.road_number_int = int(
re.findall(r"\d+", instance.road_number)[0]
)
except IndexError:
pass
# Generate a machine-friendly item number from original item number
instance.item_number_slug = slugify(instance.item_number)
class RollingStockDocument(Document):
@@ -150,8 +174,13 @@ class RollingStockDocument(Document):
RollingStock, on_delete=models.CASCADE, related_name="document"
)
class Meta(object):
unique_together = ("rolling_stock", "file")
class Meta:
constraints = [
models.UniqueConstraint(
fields=["rolling_stock", "file"],
name="unique_stock_file"
)
]
def rolling_stock_image_upload(instance, filename):

View File

@@ -11,6 +11,7 @@ from metadata.serializers import (
class RollingClassSerializer(serializers.ModelSerializer):
manufacturer = ManufacturerSerializer(many=True)
company = CompanySerializer()
type = RollingStockTypeSerializer()
@@ -28,5 +29,10 @@ class RollingStockSerializer(serializers.ModelSerializer):
class Meta:
model = RollingStock
fields = "__all__"
exclude = (
"notes",
"shop",
"purchase_date",
"price",
)
read_only_fields = ("creation_time", "updated_time")

View File

@@ -1,12 +1,14 @@
from rest_framework.generics import ListAPIView, RetrieveAPIView
from rest_framework.schemas.openapi import AutoSchema
from ram.views import CustomLimitOffsetPagination
from roster.models import RollingStock
from roster.serializers import RollingStockSerializer
class RosterList(ListAPIView):
serializer_class = RollingStockSerializer
pagination_class = CustomLimitOffsetPagination
def get_queryset(self):
return RollingStock.objects.get_published(self.request.user)
@@ -23,6 +25,7 @@ class RosterGet(RetrieveAPIView):
class RosterAddress(ListAPIView):
serializer_class = RollingStockSerializer
pagination_class = CustomLimitOffsetPagination
schema = AutoSchema(operation_id_base="retrieveRollingStockByAddress")
def get_queryset(self):
@@ -34,6 +37,7 @@ class RosterAddress(ListAPIView):
class RosterClass(ListAPIView):
serializer_class = RollingStockSerializer
pagination_class = CustomLimitOffsetPagination
schema = AutoSchema(operation_id_base="retrieveRollingStockByClass")

View File

@@ -3,4 +3,7 @@ pdbpp
ipython
flake8
pyinstrument
pyyaml
uritemplate
inflection
django-debug-toolbar

View File

@@ -9,7 +9,6 @@ django-health-check
django-admin-sortable2
django-tinymce
# Optional: # psycopg2-binary
# Optional: # pySerial
# Required by django-countries and not always installed
# by default on modern venvs (like Python 3.12 on Fedora 39)
setuptools

17
sample_data/README.md Normal file
View File

@@ -0,0 +1,17 @@
# Load sample data
```bash
$ python manage.py loaddata ../sample_data/metadata.json
$ cp -R ../sample_data/images/ <storage_folder>
```
# Disclaimer
Logos, names and design concepts are property of each respective owner.
This is a hobby project and it is not affiliated with any of the companies
or manufacturers mentioned in this project.
## Add the disclaimer to your site
You can add the disclaimer to your site by adding it to the `Disclaimer` area
in the `Site Configuration` section of the admin interface.

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

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