Compare commits

6 Commits

62 changed files with 835 additions and 1779 deletions

129
monitoring/README.md Normal file
View File

@@ -0,0 +1,129 @@
# Asset telemetry monitoring
> [!CAUTION]
> This is a PoC, not suitable for real world due to lack of any authentication and security
## Pre-requisites
- Python 3.12
- Podman (or Docker)
## Architecture
The `dispatcher.py` script collects data (`cab` commands) from a CommandStation and sends it a MQTT broker.
The command being monitored is `<l cab reg speedByte functMap>` which is returned by the `<t cab speed dir>` throttle command. See the [DCC-EX command reference](https://dcc-ex.com/reference/software/command-summary-consolidated.html#t-cab-speed-dir-set-cab-loco-speed).
`mosquitto` is the MQTT broker.
The `handler.py` script subscribes to the MQTT broker and saves relevant data to the Timescale database.
Data is finally save into a Timescale hypertable.
## How to run
### Deploy Timescale
```bash
$ podman run -d -p 5432:5432 -v $(pwd)/data:/var/lib/postgresql/data -e "POSTGRES_USER=dccmonitor" -e "POSTGRES_PASSWORD=dccmonitor" --name timescale timescale/timescaledb:latest-pg17
```
> [!IMPORTANT]
> A volume should be created for persistent data
Tables and hypertables are automatically created by the `handler.py` script
### Deploy Mosquitto
```bash
$ podman run --userns=keep-id -d -p 1883:1883 -v $(pwd)/config/mosquitto.conf:/mosquitto/config/mosquitto.conf --name mosquitto eclipse-mosquitto:2.0
```
### Run the dispatcher and the handler
```bash
$ python dispatcher.py
```
```bash
$ python handler.py
```
## Debug data in Timescale
### Create a 10 secs aggregated data table
```sql
CREATE MATERIALIZED VIEW telemetry_10secs
WITH (timescaledb.continuous) AS
SELECT
time_bucket('10 seconds', timestamp) AS bucket,
cab,
ROUND(CAST(AVG(speed) AS NUMERIC), 1) AS avg_speed,
MIN(speed) AS min_speed,
MAX(speed) AS max_speed
FROM telemetry
GROUP BY bucket, cab;
```
and set the update policy:
```sql
SELECT add_continuous_aggregate_policy(
'telemetry_10secs',
start_offset => INTERVAL '1 hour', -- Go back 1 hour for updates
end_offset => INTERVAL '1 minute', -- Keep the latest 5 min fresh
schedule_interval => INTERVAL '1 minute' -- Run every minute
);
```
### Running statistics from 10 seconds table
```sql
WITH speed_durations AS (
SELECT
cab,
avg_speed,
max_speed,
bucket AS start_time,
LEAD(bucket) OVER (
PARTITION BY cab ORDER BY bucket
) AS end_time,
LEAD(bucket) OVER (PARTITION BY cab ORDER BY bucket) - bucket AS duration
FROM telemetry_10secs
)
SELECT * FROM speed_durations WHERE end_time IS NOT NULL;
```
and filtered by `cab` number, via a function
```sql
CREATE FUNCTION get_speed_durations(cab_id INT)
RETURNS TABLE (
cab INT,
speed DOUBLE PRECISION,
dir TEXT,
start_time TIMESTAMPTZ,
end_time TIMESTAMPTZ,
duration INTERVAL
)
AS $$
WITH speed_durations AS (
SELECT
cab,
avg_speed,
max_speed,
bucket AS start_time,
LEAD(bucket) OVER (
PARTITION BY cab ORDER BY bucket
) AS end_time,
LEAD(bucket) OVER (PARTITION BY cab ORDER BY bucket) - bucket AS duration
FROM telemetry_10secs
)
SELECT * FROM speed_durations WHERE end_time IS NOT NULL AND cab = cab_id;
$$ LANGUAGE sql;
-- Refresh data
CALL refresh_continuous_aggregate('telemetry_10secs', NULL, NULL);
SELECT * FROM get_speed_durations(1);
```

36
monitoring/compose.yml Normal file
View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# vim: tabstop=2 shiftwidth=2 softtabstop=2
networks:
net:
volumes:
pgdata:
staticdata:
x-op-service-default: &service_default
restart: always # unless-stopped
init: true
services:
timescale:
<<: *service_default
image: timescale/timescaledb:latest-pg17
ports:
- "${CUSTOM_DOCKER_IP:-0.0.0.0}:5432:5432"
environment:
POSTGRES_USER: "dccmonitor"
POSTGRES_PASSWORD: "dccmonitor"
volumes:
- "pgdata:/var/lib/postgresql/data"
networks:
- net
broker:
<<: *service_default
image: eclipse-mosquitto:2.0
ports:
- "${CUSTOM_DOCKER_IP:-0.0.0.0}:1883:1883"
volumes:
- "./config/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro"
networks:
- net

View File

@@ -0,0 +1,2 @@
allow_anonymous true
listener 1883

107
monitoring/dispatcher.py Executable file
View File

@@ -0,0 +1,107 @@
#!/usr/bin/env python3
import os
import time
import json
import socket
import logging
import paho.mqtt.client as mqtt
# FIXME: create a configuration
# TCP Socket Configuration
TCP_HOST = "192.168.10.110" # Replace with your TCP server IP
TCP_PORT = 2560 # Replace with your TCP server port
# FIXME: create a configuration
# MQTT Broker Configuration
MQTT_BROKER = "localhost"
MQTT_PORT = 1883
MQTT_TOPIC = "telemetry/commandstation"
# Connect to MQTT Broker
mqtt_client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
# Connect function with automatic reconnection
def connect_mqtt():
while True:
try:
mqtt_client.connect(MQTT_BROKER, MQTT_PORT, keepalive=60)
mqtt_client.loop_start() # Start background loop
logging.info("Connected to MQTT broker!")
return
except Exception as e:
logging.info(f"Connection failed: {e}. Retrying in 5 seconds...")
time.sleep(5) # Wait before Retrying
# Ensure connection before publishing
def safe_publish(topic, message):
if not mqtt_client.is_connected():
print("MQTT Disconnected! Reconnecting...")
connect_mqtt() # Reconnect if disconnected
result = mqtt_client.publish(topic, message, qos=1)
result.wait_for_publish() # Ensure message is published
logging.debug(f"Published: {message}")
def process_message(message):
"""Parses the '<l cab speed dir>' format and converts it to JSON."""
if not message.startswith("<l"):
return None
parts = message.strip().split() # Split by spaces
if len(parts) != 5:
logging.debug(f"Invalid speed command: {message}")
return None
_, _cab, _, _speed, _ = parts # Ignore the first `<t`
cab = int(_cab)
speed = int(_speed)
if speed > 1 and speed < 128:
direction = "r"
speed = speed - 1
elif speed > 129 and speed < 256:
direction = "f"
speed = speed - 129
else:
speed = 0
direction = "n"
try:
json_data = {
"cab": cab,
"speed": speed,
"dir": direction
}
return json_data
except ValueError as e:
logging.error(f"Error parsing message: {e}")
return None
def start_tcp_listener():
"""Listens for incoming TCP messages and publishes them to MQTT."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.connect((TCP_HOST, TCP_PORT))
logging.info(
f"Connected to TCP server at {TCP_HOST}:{TCP_PORT}"
)
while True:
data = sock.recv(1024).decode("utf-8") # Read a chunk of data
if not data:
break
lines = data.strip().split("\n") # Handle multiple lines
for line in lines:
json_data = process_message(line)
if json_data:
safe_publish(MQTT_TOPIC, json.dumps(json_data))
# Start the listener
if __name__ == "__main__":
logging.basicConfig(level=os.getenv("DCC_LOGLEVEL", "INFO").upper())
start_tcp_listener()

87
monitoring/handler.py Executable file
View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
import os
import json
import logging
import datetime
import psycopg2
import paho.mqtt.client as mqtt
# MQTT Broker Configuration
MQTT_BROKER = "localhost"
MQTT_PORT = 1883
MQTT_TOPIC = "telemetry/commandstation"
# TimescaleDB Configuration
DB_HOST = "localhost"
DB_NAME = "dccmonitor"
DB_USER = "dccmonitor"
DB_PASSWORD = "dccmonitor"
# The callback for when the client receives a CONNACK response from the server.
def on_connect(client, userdata, flags, reason_code, properties):
logging.info(f"Connected with result code {reason_code}")
# Subscribing in on_connect() means that if we lose the connection and
# reconnect then subscriptions will be renewed.
client.subscribe(MQTT_TOPIC)
# MQTT Callback: When a new message arrives
def on_message(client, userdata, msg):
try:
payload = json.loads(msg.payload.decode("utf-8"))
cab = payload["cab"]
speed = payload["speed"]
direction = payload["dir"]
timestamp = datetime.datetime.now(datetime.UTC)
# Insert into TimescaleDB
cur.execute(
"INSERT INTO telemetry (timestamp, cab, speed, dir) VALUES (%s, %s, %s, %s)", # noqa: E501
(timestamp, cab, speed, direction),
)
conn.commit()
logging.debug(
f"Inserted: {timestamp} | Cab: {cab} | Speed: {speed} | Dir: {direction}" # noqa: E501
)
except Exception as e:
logging.error(f"Error processing message: {e}")
if __name__ == "__main__":
logging.basicConfig(level=os.getenv("DCC_LOGLEVEL", "INFO").upper())
# Connect to TimescaleDB
conn = psycopg2.connect(
dbname=DB_NAME, user=DB_USER, password=DB_PASSWORD, host=DB_HOST
)
cur = conn.cursor()
# Ensure hypertable exists
cur.execute("""
CREATE TABLE IF NOT EXISTS telemetry (
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
cab INT NOT NULL,
speed DOUBLE PRECISION NOT NULL,
dir TEXT NOT NULL
);
""")
conn.commit()
# Convert table to hypertable if not already
cur.execute("SELECT EXISTS (SELECT 1 FROM timescaledb_information.hypertables WHERE hypertable_name = 'telemetry');") # noqa: E501
if not cur.fetchone()[0]:
cur.execute("SELECT create_hypertable('telemetry', 'timestamp');")
conn.commit()
# Setup MQTT Client
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
client.on_connect = on_connect
client.on_message = on_message
client.connect(MQTT_BROKER, MQTT_PORT)
# Start listening for messages
logging.info(f"Listening for MQTT messages on {MQTT_TOPIC}...")
client.loop_forever()

View File

@@ -0,0 +1,2 @@
paho-mqtt
psycopg2-binary

View File

@@ -8,11 +8,7 @@ from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
from ram.admin import publish, unpublish from ram.admin import publish, unpublish
from ram.utils import generate_csv from ram.utils import generate_csv
from portal.utils import get_site_conf from portal.utils import get_site_conf
from repository.models import ( from repository.models import BookDocument, CatalogDocument
BookDocument,
CatalogDocument,
MagazineIssueDocument,
)
from bookshelf.models import ( from bookshelf.models import (
BaseBookProperty, BaseBookProperty,
BaseBookImage, BaseBookImage,
@@ -20,8 +16,6 @@ from bookshelf.models import (
Author, Author,
Publisher, Publisher,
Catalog, Catalog,
Magazine,
MagazineIssue,
) )
@@ -54,10 +48,6 @@ class CatalogDocInline(BookDocInline):
model = CatalogDocument model = CatalogDocument
class MagazineIssueDocInline(BookDocInline):
model = MagazineIssueDocument
@admin.register(Book) @admin.register(Book)
class BookAdmin(SortableAdminBase, admin.ModelAdmin): class BookAdmin(SortableAdminBase, admin.ModelAdmin):
inlines = ( inlines = (
@@ -66,17 +56,17 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
BookDocInline, BookDocInline,
) )
list_display = ( list_display = (
"published",
"title", "title",
"get_authors", "get_authors",
"get_publisher", "get_publisher",
"publication_year", "publication_year",
"number_of_pages", "number_of_pages",
"published",
) )
autocomplete_fields = ("authors", "publisher", "shop") autocomplete_fields = ("authors", "publisher", "shop")
readonly_fields = ("invoices", "creation_time", "updated_time") readonly_fields = ("invoices", "creation_time", "updated_time")
search_fields = ("title", "publisher__name", "authors__last_name") search_fields = ("title", "publisher__name", "authors__last_name")
list_filter = ("publisher__name", "authors", "published") list_filter = ("publisher__name", "authors")
fieldsets = ( fieldsets = (
( (
@@ -135,8 +125,8 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
if obj.invoice.exists(): if obj.invoice.exists():
html = format_html_join( html = format_html_join(
"<br>", "<br>",
'<a href="{}" target="_blank">{}</a>', "<a href=\"{}\" target=\"_blank\">{}</a>",
((i.file.url, i) for i in obj.invoice.all()), ((i.file.url, i) for i in obj.invoice.all())
) )
else: else:
html = "-" html = "-"
@@ -212,11 +202,11 @@ class AuthorAdmin(admin.ModelAdmin):
@admin.register(Publisher) @admin.register(Publisher)
class PublisherAdmin(admin.ModelAdmin): class PublisherAdmin(admin.ModelAdmin):
list_display = ("name", "country_flag_name") list_display = ("name", "country_flag")
search_fields = ("name",) search_fields = ("name",)
@admin.display(description="Country") @admin.display(description="Country")
def country_flag_name(self, obj): def country_flag(self, obj):
return format_html( return format_html(
'<img src="{}" /> {}', obj.country.flag, obj.country.name '<img src="{}" /> {}', obj.country.flag, obj.country.name
) )
@@ -239,12 +229,7 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
autocomplete_fields = ("manufacturer",) autocomplete_fields = ("manufacturer",)
readonly_fields = ("invoices", "creation_time", "updated_time") readonly_fields = ("invoices", "creation_time", "updated_time")
search_fields = ("manufacturer__name", "years", "scales__scale") search_fields = ("manufacturer__name", "years", "scales__scale")
list_filter = ( list_filter = ("manufacturer__name", "publication_year", "scales__scale")
"published",
"manufacturer__name",
"publication_year",
"scales__scale",
)
fieldsets = ( fieldsets = (
( (
@@ -303,8 +288,8 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
if obj.invoice.exists(): if obj.invoice.exists():
html = format_html_join( html = format_html_join(
"<br>", "<br>",
'<a href="{}" target="_blank">{}</a>', "<a href=\"{}\" target=\"_blank\">{}</a>",
((i.file.url, i) for i in obj.invoice.all()), ((i.file.url, i) for i in obj.invoice.all())
) )
else: else:
html = "-" html = "-"
@@ -361,144 +346,3 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
download_csv.short_description = "Download selected items as CSV" download_csv.short_description = "Download selected items as CSV"
actions = [publish, unpublish, download_csv] actions = [publish, unpublish, download_csv]
@admin.register(MagazineIssue)
class MagazineIssueAdmin(SortableAdminBase, admin.ModelAdmin):
inlines = (
BookPropertyInline,
BookImageInline,
MagazineIssueDocInline,
)
list_display = (
"__str__",
"issue_number",
"published",
)
autocomplete_fields = ("shop",)
readonly_fields = ("magazine", "creation_time", "updated_time")
def get_model_perms(self, request):
"""
Return empty perms dict thus hiding the model from admin index.
"""
return {}
fieldsets = (
(
None,
{
"fields": (
"published",
"magazine",
"issue_number",
"publication_year",
"publication_month",
"ISBN",
"language",
"number_of_pages",
"description",
"tags",
)
},
),
(
"Purchase data",
{
"classes": ("collapse",),
"fields": (
"shop",
"purchase_date",
"price",
),
},
),
(
"Notes",
{"classes": ("collapse",), "fields": ("notes",)},
),
(
"Audit",
{
"classes": ("collapse",),
"fields": (
"creation_time",
"updated_time",
),
},
),
)
actions = [publish, unpublish]
class MagazineIssueInline(admin.TabularInline):
model = MagazineIssue
min_num = 0
extra = 0
autocomplete_fields = ("shop",)
show_change_link = True
fields = (
"preview",
"published",
"issue_number",
"publication_year",
"publication_month",
"number_of_pages",
"language",
)
readonly_fields = ("preview",)
class Media:
js = ("admin/js/magazine_issue_defaults.js",)
@admin.register(Magazine)
class MagazineAdmin(SortableAdminBase, admin.ModelAdmin):
inlines = (MagazineIssueInline,)
list_display = (
"__str__",
"publisher",
"published",
)
autocomplete_fields = ("publisher",)
readonly_fields = ("creation_time", "updated_time")
search_fields = ("name", "publisher__name")
list_filter = (
"published",
"publisher__name",
)
fieldsets = (
(
None,
{
"fields": (
"published",
"name",
"website",
"publisher",
"ISBN",
"language",
"description",
"image",
"tags",
)
},
),
(
"Notes",
{"classes": ("collapse",), "fields": ("notes",)},
),
(
"Audit",
{
"classes": ("collapse",),
"fields": (
"creation_time",
"updated_time",
),
},
),
)
actions = [publish, unpublish]

View File

@@ -1,224 +0,0 @@
# Generated by Django 6.0 on 2025-12-08 17:47
import bookshelf.models
import django.db.models.deletion
import ram.utils
import tinymce.models
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0024_alter_basebook_language"),
("metadata", "0025_alter_company_options_alter_manufacturer_options_and_more"),
]
operations = [
migrations.CreateModel(
name="Magazine",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("description", tinymce.models.HTMLField(blank=True)),
("notes", tinymce.models.HTMLField(blank=True)),
("creation_time", models.DateTimeField(auto_now_add=True)),
("updated_time", models.DateTimeField(auto_now=True)),
("published", models.BooleanField(default=True)),
("name", models.CharField(max_length=200)),
("ISBN", models.CharField(blank=True, max_length=17)),
(
"image",
models.ImageField(
blank=True,
storage=ram.utils.DeduplicatedStorage,
upload_to=bookshelf.models.book_image_upload,
),
),
(
"language",
models.CharField(
choices=[
("af", "Afrikaans"),
("ar", "Arabic"),
("ar-dz", "Algerian Arabic"),
("ast", "Asturian"),
("az", "Azerbaijani"),
("bg", "Bulgarian"),
("be", "Belarusian"),
("bn", "Bengali"),
("br", "Breton"),
("bs", "Bosnian"),
("ca", "Catalan"),
("ckb", "Central Kurdish (Sorani)"),
("cs", "Czech"),
("cy", "Welsh"),
("da", "Danish"),
("de", "German"),
("dsb", "Lower Sorbian"),
("el", "Greek"),
("en", "English"),
("en-au", "Australian English"),
("en-gb", "British English"),
("eo", "Esperanto"),
("es", "Spanish"),
("es-ar", "Argentinian Spanish"),
("es-co", "Colombian Spanish"),
("es-mx", "Mexican Spanish"),
("es-ni", "Nicaraguan Spanish"),
("es-ve", "Venezuelan Spanish"),
("et", "Estonian"),
("eu", "Basque"),
("fa", "Persian"),
("fi", "Finnish"),
("fr", "French"),
("fy", "Frisian"),
("ga", "Irish"),
("gd", "Scottish Gaelic"),
("gl", "Galician"),
("he", "Hebrew"),
("hi", "Hindi"),
("hr", "Croatian"),
("hsb", "Upper Sorbian"),
("ht", "Haitian Creole"),
("hu", "Hungarian"),
("hy", "Armenian"),
("ia", "Interlingua"),
("id", "Indonesian"),
("ig", "Igbo"),
("io", "Ido"),
("is", "Icelandic"),
("it", "Italian"),
("ja", "Japanese"),
("ka", "Georgian"),
("kab", "Kabyle"),
("kk", "Kazakh"),
("km", "Khmer"),
("kn", "Kannada"),
("ko", "Korean"),
("ky", "Kyrgyz"),
("lb", "Luxembourgish"),
("lt", "Lithuanian"),
("lv", "Latvian"),
("mk", "Macedonian"),
("ml", "Malayalam"),
("mn", "Mongolian"),
("mr", "Marathi"),
("ms", "Malay"),
("my", "Burmese"),
("nb", "Norwegian Bokmål"),
("ne", "Nepali"),
("nl", "Dutch"),
("nn", "Norwegian Nynorsk"),
("os", "Ossetic"),
("pa", "Punjabi"),
("pl", "Polish"),
("pt", "Portuguese"),
("pt-br", "Brazilian Portuguese"),
("ro", "Romanian"),
("ru", "Russian"),
("sk", "Slovak"),
("sl", "Slovenian"),
("sq", "Albanian"),
("sr", "Serbian"),
("sr-latn", "Serbian Latin"),
("sv", "Swedish"),
("sw", "Swahili"),
("ta", "Tamil"),
("te", "Telugu"),
("tg", "Tajik"),
("th", "Thai"),
("tk", "Turkmen"),
("tr", "Turkish"),
("tt", "Tatar"),
("udm", "Udmurt"),
("ug", "Uyghur"),
("uk", "Ukrainian"),
("ur", "Urdu"),
("uz", "Uzbek"),
("vi", "Vietnamese"),
("zh-hans", "Simplified Chinese"),
("zh-hant", "Traditional Chinese"),
],
default="en",
max_length=7,
),
),
(
"publisher",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="bookshelf.publisher",
),
),
(
"tags",
models.ManyToManyField(
blank=True, related_name="magazine", to="metadata.tag"
),
),
],
options={
"ordering": ["name"],
},
),
migrations.CreateModel(
name="MagazineIssue",
fields=[
(
"basebook_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="bookshelf.basebook",
),
),
("issue_number", models.CharField(max_length=100)),
(
"publication_month",
models.SmallIntegerField(
blank=True,
choices=[
(1, "January"),
(2, "February"),
(3, "March"),
(4, "April"),
(5, "May"),
(6, "June"),
(7, "July"),
(8, "August"),
(9, "September"),
(10, "October"),
(11, "November"),
(12, "December"),
],
null=True,
),
),
(
"magazine",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="issue",
to="bookshelf.magazine",
),
),
],
options={
"ordering": ["magazine", "issue_number"],
"unique_together": {("magazine", "issue_number")},
},
bases=("bookshelf.basebook",),
),
]

View File

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

View File

@@ -1,18 +0,0 @@
# Generated by Django 6.0 on 2025-12-12 14:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0026_alter_basebook_language_alter_magazine_image_and_more"),
]
operations = [
migrations.AddField(
model_name="magazine",
name="website",
field=models.URLField(blank=True),
),
]

View File

@@ -1,29 +0,0 @@
# Generated by Django 6.0 on 2025-12-21 21:56
import django.db.models.functions.text
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0027_magazine_website"),
]
operations = [
migrations.AlterModelOptions(
name="magazine",
options={"ordering": [django.db.models.functions.text.Lower("name")]},
),
migrations.AlterModelOptions(
name="magazineissue",
options={
"ordering": [
"magazine",
"publication_year",
"publication_month",
"issue_number",
]
},
),
]

View File

@@ -1,29 +0,0 @@
# Generated by Django 6.0 on 2025-12-23 11:18
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0028_alter_magazine_options_alter_magazineissue_options"),
("metadata", "0025_alter_company_options_alter_manufacturer_options_and_more"),
]
operations = [
migrations.AlterField(
model_name="catalog",
name="manufacturer",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="catalogs",
to="metadata.manufacturer",
),
),
migrations.AlterField(
model_name="catalog",
name="scales",
field=models.ManyToManyField(related_name="catalogs", to="metadata.scale"),
),
]

View File

@@ -1,12 +1,8 @@
import os import os
import shutil import shutil
from urllib.parse import urlparse
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings
from django.urls import reverse from django.urls import reverse
from django.utils.dates import MONTHS
from django.db.models.functions import Lower
from django.core.exceptions import ValidationError
from django_countries.fields import CountryField from django_countries.fields import CountryField
from ram.utils import DeduplicatedStorage from ram.utils import DeduplicatedStorage
@@ -45,8 +41,8 @@ class BaseBook(BaseModel):
ISBN = models.CharField(max_length=17, blank=True) # 13 + dashes ISBN = models.CharField(max_length=17, blank=True) # 13 + dashes
language = models.CharField( language = models.CharField(
max_length=7, max_length=7,
choices=sorted(settings.LANGUAGES, key=lambda s: s[1]), choices=settings.LANGUAGES,
default="en", default='en'
) )
number_of_pages = models.SmallIntegerField(null=True, blank=True) number_of_pages = models.SmallIntegerField(null=True, blank=True)
publication_year = models.SmallIntegerField(null=True, blank=True) publication_year = models.SmallIntegerField(null=True, blank=True)
@@ -60,24 +56,27 @@ class BaseBook(BaseModel):
blank=True, blank=True,
) )
purchase_date = models.DateField(null=True, blank=True) purchase_date = models.DateField(null=True, blank=True)
tags = models.ManyToManyField(Tag, related_name="bookshelf", blank=True) tags = models.ManyToManyField(
Tag, related_name="bookshelf", blank=True
)
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
shutil.rmtree( shutil.rmtree(
os.path.join( os.path.join(
settings.MEDIA_ROOT, "images", "books", str(self.uuid) settings.MEDIA_ROOT, "images", "books", str(self.uuid)
), ),
ignore_errors=True, ignore_errors=True
) )
super(BaseBook, self).delete(*args, **kwargs) super(BaseBook, self).delete(*args, **kwargs)
def book_image_upload(instance, filename): def book_image_upload(instance, filename):
return os.path.join("images", "books", str(instance.book.uuid), filename) return os.path.join(
"images",
"books",
def magazine_image_upload(instance, filename): str(instance.book.uuid),
return os.path.join("images", "magazines", str(instance.uuid), filename) filename
)
class BaseBookImage(Image): class BaseBookImage(Image):
@@ -121,7 +120,8 @@ class Book(BaseBook):
def get_absolute_url(self): def get_absolute_url(self):
return reverse( return reverse(
"bookshelf_item", kwargs={"selector": "book", "uuid": self.uuid} "bookshelf_item",
kwargs={"selector": "book", "uuid": self.uuid}
) )
@@ -129,10 +129,9 @@ class Catalog(BaseBook):
manufacturer = models.ForeignKey( manufacturer = models.ForeignKey(
Manufacturer, Manufacturer,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="catalogs",
) )
years = models.CharField(max_length=12) years = models.CharField(max_length=12)
scales = models.ManyToManyField(Scale, related_name="catalogs") scales = models.ManyToManyField(Scale)
class Meta: class Meta:
ordering = ["manufacturer", "publication_year"] ordering = ["manufacturer", "publication_year"]
@@ -147,95 +146,10 @@ class Catalog(BaseBook):
def get_absolute_url(self): def get_absolute_url(self):
return reverse( return reverse(
"bookshelf_item", kwargs={"selector": "catalog", "uuid": self.uuid} "bookshelf_item",
kwargs={"selector": "catalog", "uuid": self.uuid}
) )
def get_scales(self): def get_scales(self):
return "/".join([s.scale for s in self.scales.all()]) return "/".join([s.scale for s in self.scales.all()])
get_scales.short_description = "Scales" get_scales.short_description = "Scales"
class Magazine(BaseModel):
name = models.CharField(max_length=200)
publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE)
website = models.URLField(blank=True)
ISBN = models.CharField(max_length=17, blank=True) # 13 + dashes
image = models.ImageField(
blank=True,
upload_to=magazine_image_upload,
storage=DeduplicatedStorage,
)
language = models.CharField(
max_length=7,
choices=sorted(settings.LANGUAGES, key=lambda s: s[1]),
default="en",
)
tags = models.ManyToManyField(Tag, related_name="magazine", blank=True)
def delete(self, *args, **kwargs):
shutil.rmtree(
os.path.join(
settings.MEDIA_ROOT, "images", "magazines", str(self.uuid)
),
ignore_errors=True,
)
super(Magazine, self).delete(*args, **kwargs)
class Meta:
ordering = [Lower("name")]
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("magazine", kwargs={"uuid": self.uuid})
def website_short(self):
if self.website:
return urlparse(self.website).netloc.replace("www.", "")
class MagazineIssue(BaseBook):
magazine = models.ForeignKey(
Magazine, on_delete=models.CASCADE, related_name="issue"
)
issue_number = models.CharField(max_length=100)
publication_month = models.SmallIntegerField(
null=True, blank=True, choices=MONTHS.items()
)
class Meta:
unique_together = ("magazine", "issue_number")
ordering = [
"magazine",
"publication_year",
"publication_month",
"issue_number",
]
def __str__(self):
return f"{self.magazine.name} - {self.issue_number}"
def clean(self):
if self.magazine.published is False and self.published is True:
raise ValidationError(
"Cannot set an issue as published if the magazine is not "
"published."
)
@property
def obj_label(self):
return "Magazine Issue"
def preview(self):
return self.image.first().image_thumbnail(100)
@property
def publisher(self):
return self.magazine.publisher
def get_absolute_url(self):
return reverse(
"issue", kwargs={"uuid": self.uuid, "magazine": self.magazine.uuid}
)

View File

@@ -49,5 +49,3 @@ class CatalogSerializer(serializers.ModelSerializer):
"price", "price",
) )
read_only_fields = ("creation_time", "updated_time") read_only_fields = ("creation_time", "updated_time")
# FIXME: add Magazine and MagazineIssue serializers

View File

@@ -1,16 +0,0 @@
document.addEventListener('formset:added', function(event) {
const newForm = event.target; // the new inline form element
const defaultLanguage = document.querySelector('#id_language').value;
const defaultStatus = document.querySelector('#id_published').checked;
const languageInput = newForm.querySelector('select[name$="language"]');
const statusInput = newForm.querySelector('input[name$="published"]');
if (languageInput) {
languageInput.value = defaultLanguage;
}
if (statusInput) {
statusInput.checked = defaultStatus;
}
});

View File

@@ -38,5 +38,3 @@ class CatalogGet(RetrieveAPIView):
def get_queryset(self): def get_queryset(self):
return Book.objects.get_published(self.request.user) return Book.objects.get_published(self.request.user)
# FIXME: add Magazine and MagazineIssue views

View File

@@ -2,7 +2,6 @@ import html
from django.conf import settings from django.conf import settings
from django.contrib import admin from django.contrib import admin
# from django.forms import BaseInlineFormSet # for future reference # from django.forms import BaseInlineFormSet # for future reference
from django.utils.html import format_html, strip_tags from django.utils.html import format_html, strip_tags
from adminsortable2.admin import ( from adminsortable2.admin import (
@@ -47,22 +46,15 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
"creation_time", "creation_time",
"updated_time", "updated_time",
) )
list_filter = ("published", "company__name", "era", "scale") list_filter = ("company__name", "era", "scale", "published")
list_display = ( list_display = ("__str__",) + list_filter + ("country_flag",)
"__str__",
"company__name",
"era",
"scale",
"country_flag",
"published",
)
search_fields = ("identifier",) + list_filter search_fields = ("identifier",) + list_filter
save_as = True save_as = True
@admin.display(description="Country") @admin.display(description="Country")
def country_flag(self, obj): def country_flag(self, obj):
return format_html( return format_html(
'<img src="{}" title="{}" />', obj.country.flag, obj.country.name '<img src="{}" /> {}', obj.country.flag, obj.country
) )
fieldsets = ( fieldsets = (
@@ -146,7 +138,6 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
) )
return generate_csv(header, data, "consists.csv") return generate_csv(header, data, "consists.csv")
download_csv.short_description = "Download selected items as CSV" download_csv.short_description = "Download selected items as CSV"
actions = [publish, unpublish, download_csv] actions = [publish, unpublish, download_csv]

View File

@@ -47,12 +47,12 @@ class ScaleAdmin(admin.ModelAdmin):
@admin.register(Company) @admin.register(Company)
class CompanyAdmin(admin.ModelAdmin): class CompanyAdmin(admin.ModelAdmin):
readonly_fields = ("logo_thumbnail",) readonly_fields = ("logo_thumbnail",)
list_display = ("name", "country_flag_name") list_display = ("name", "country_flag")
list_filter = ("name", "country") list_filter = ("name", "country")
search_fields = ("name",) search_fields = ("name",)
@admin.display(description="Country") @admin.display(description="Country")
def country_flag_name(self, obj): def country_flag(self, obj):
return format_html( return format_html(
'<img src="{}" /> {}', obj.country.flag, obj.country.name '<img src="{}" /> {}', obj.country.flag, obj.country.name
) )
@@ -61,12 +61,12 @@ class CompanyAdmin(admin.ModelAdmin):
@admin.register(Manufacturer) @admin.register(Manufacturer)
class ManufacturerAdmin(admin.ModelAdmin): class ManufacturerAdmin(admin.ModelAdmin):
readonly_fields = ("logo_thumbnail",) readonly_fields = ("logo_thumbnail",)
list_display = ("name", "category", "country_flag_name") list_display = ("name", "category", "country_flag")
list_filter = ("category",) list_filter = ("category",)
search_fields = ("name",) search_fields = ("name",)
@admin.display(description="Country") @admin.display(description="Country")
def country_flag_name(self, obj): def country_flag(self, obj):
return format_html( return format_html(
'<img src="{}" /> {}', obj.country.flag, obj.country.name '<img src="{}" /> {}', obj.country.flag, obj.country.name
) )
@@ -88,12 +88,6 @@ class RollingStockTypeAdmin(SortableAdminMixin, admin.ModelAdmin):
@admin.register(Shop) @admin.register(Shop)
class ShopAdmin(admin.ModelAdmin): class ShopAdmin(admin.ModelAdmin):
list_display = ("name", "on_line", "active", "country_flag_name") list_display = ("name", "on_line", "active")
list_filter = ("on_line", "active") list_filter = ("on_line", "active")
search_fields = ("name",) search_fields = ("name",)
@admin.display(description="Country")
def country_flag_name(self, obj):
return format_html(
'<img src="{}" /> {}', obj.country.flag, obj.country.name
)

View File

@@ -1,5 +1,4 @@
import os import os
from urllib.parse import urlparse
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.conf import settings from django.conf import settings
@@ -7,12 +6,11 @@ from django.dispatch.dispatcher import receiver
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django_countries.fields import CountryField from django_countries.fields import CountryField
from ram.models import SimpleBaseModel
from ram.utils import DeduplicatedStorage, get_image_preview, slugify from ram.utils import DeduplicatedStorage, get_image_preview, slugify
from ram.managers import PublicManager from ram.managers import PublicManager
class Property(SimpleBaseModel): class Property(models.Model):
name = models.CharField(max_length=128, unique=True) name = models.CharField(max_length=128, unique=True)
private = models.BooleanField( private = models.BooleanField(
default=False, default=False,
@@ -29,7 +27,7 @@ class Property(SimpleBaseModel):
objects = PublicManager() objects = PublicManager()
class Manufacturer(SimpleBaseModel): class Manufacturer(models.Model):
name = models.CharField(max_length=128, unique=True) name = models.CharField(max_length=128, unique=True)
slug = models.CharField(max_length=128, unique=True, editable=False) slug = models.CharField(max_length=128, unique=True, editable=False)
category = models.CharField( category = models.CharField(
@@ -59,17 +57,13 @@ class Manufacturer(SimpleBaseModel):
}, },
) )
def website_short(self):
if self.website:
return urlparse(self.website).netloc.replace("www.", "")
def logo_thumbnail(self): def logo_thumbnail(self):
return get_image_preview(self.logo.url) return get_image_preview(self.logo.url)
logo_thumbnail.short_description = "Preview" logo_thumbnail.short_description = "Preview"
class Company(SimpleBaseModel): class Company(models.Model):
name = models.CharField(max_length=64, unique=True) name = models.CharField(max_length=64, unique=True)
slug = models.CharField(max_length=64, unique=True, editable=False) slug = models.CharField(max_length=64, unique=True, editable=False)
extended_name = models.CharField(max_length=128, blank=True) extended_name = models.CharField(max_length=128, blank=True)
@@ -107,7 +101,7 @@ class Company(SimpleBaseModel):
logo_thumbnail.short_description = "Preview" logo_thumbnail.short_description = "Preview"
class Decoder(SimpleBaseModel): class Decoder(models.Model):
name = models.CharField(max_length=128, unique=True) name = models.CharField(max_length=128, unique=True)
manufacturer = models.ForeignKey( manufacturer = models.ForeignKey(
Manufacturer, Manufacturer,
@@ -143,7 +137,7 @@ def calculate_ratio(ratio):
raise ValidationError("Invalid ratio format") raise ValidationError("Invalid ratio format")
class Scale(SimpleBaseModel): class Scale(models.Model):
scale = models.CharField(max_length=32, unique=True) scale = models.CharField(max_length=32, unique=True)
slug = models.CharField(max_length=32, unique=True, editable=False) slug = models.CharField(max_length=32, unique=True, editable=False)
ratio = models.CharField(max_length=16, validators=[calculate_ratio]) ratio = models.CharField(max_length=16, validators=[calculate_ratio])
@@ -178,7 +172,7 @@ def scale_save(sender, instance, **kwargs):
instance.ratio_int = calculate_ratio(instance.ratio) instance.ratio_int = calculate_ratio(instance.ratio)
class RollingStockType(SimpleBaseModel): class RollingStockType(models.Model):
type = models.CharField(max_length=64) type = models.CharField(max_length=64)
order = models.PositiveSmallIntegerField() order = models.PositiveSmallIntegerField()
category = models.CharField( category = models.CharField(
@@ -208,7 +202,7 @@ class RollingStockType(SimpleBaseModel):
return "{0} {1}".format(self.type, self.category) return "{0} {1}".format(self.type, self.category)
class Tag(SimpleBaseModel): class Tag(models.Model):
name = models.CharField(max_length=128, unique=True) name = models.CharField(max_length=128, unique=True)
slug = models.CharField(max_length=128, unique=True) slug = models.CharField(max_length=128, unique=True)
@@ -228,7 +222,7 @@ class Tag(SimpleBaseModel):
) )
class Shop(SimpleBaseModel): class Shop(models.Model):
name = models.CharField(max_length=128, unique=True) name = models.CharField(max_length=128, unique=True)
country = CountryField(blank=True) country = CountryField(blank=True)
website = models.URLField(blank=True) website = models.URLField(blank=True)

View File

@@ -2,23 +2,23 @@
{% load dynamic_url %} {% load dynamic_url %}
{% block header %} {% block header %}
{% if data.tags.all %} {% if book.tags.all %}
<p><small>Tags:</small> <p><small>Tags:</small>
{% for t in data.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary"> {% for t in book.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary">
{{ t.name }}</a>{# new line is required #} {{ t.name }}</a>{# new line is required #}
{% endfor %} {% endfor %}
</p> </p>
{% endif %} {% endif %}
{% if not data.published %} {% if not book.published %}
<span class="badge text-bg-warning">Unpublished</span> | <span class="badge text-bg-warning">Unpublished</span> |
{% endif %} {% endif %}
<small class="text-body-secondary">Updated {{ data.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 %} {% endblock %}
{% block carousel %} {% block carousel %}
<div class="row"> <div class="row">
<div id="carouselControls" class="carousel carousel-dark slide" data-bs-ride="carousel" data-bs-interval="10000"> <div id="carouselControls" class="carousel carousel-dark slide" data-bs-ride="carousel" data-bs-interval="10000">
<div class="carousel-inner"> <div class="carousel-inner">
{% for t in data.image.all %} {% for t in book.image.all %}
{% if forloop.first %} {% if forloop.first %}
<div class="carousel-item active"> <div class="carousel-item active">
{% else %} {% else %}
@@ -28,7 +28,7 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% if data.image.count > 1 %} {% if book.image.count > 1 %}
<button class="carousel-control-prev" type="button" data-bs-target="#carouselControls" data-bs-slide="prev"> <button class="carousel-control-prev" type="button" data-bs-target="#carouselControls" data-bs-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span> <span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="visually-hidden"><i class="bi bi-chevron-left"></i></span> <span class="visually-hidden"><i class="bi bi-chevron-left"></i></span>
@@ -61,86 +61,57 @@
<thead> <thead>
<tr> <tr>
<th colspan="2" scope="row"> <th colspan="2" scope="row">
{{ data.obj_label|capfirst }} {% if type == "catalog" %}Catalog
{% elif type == "book" %}Book{% endif %}
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody class="table-group-divider"> <tbody class="table-group-divider">
{% if data.obj_type == "catalog" %} {% if type == "catalog" %}
<tr> <tr>
<th class="w-33" scope="row">Manufacturer</th> <th class="w-33" scope="row">Manufacturer</th>
<td> <td>{{ book.manufacturer }}</td>
<a href="{% url 'filtered' _filter="manufacturer" search=data.manufacturer.slug %}">{{ data.manufacturer }}{% if data.manufacturer.website %}</a> <a href="{{ data.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Scales</th> <th class="w-33" scope="row">Scales</th>
<td>{{ data.get_scales }}</td> <td>{{ book.get_scales }}</td>
</tr> </tr>
{% elif data.obj_type == "book" %} {% elif type == "book" %}
<tr> <tr>
<th class="w-33" scope="row">Title</th> <th class="w-33" scope="row">Title</th>
<td>{{ data.title }}</td> <td>{{ book.title }}</td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Authors</th> <th class="w-33" scope="row">Authors</th>
<td> <td>
<ul class="mb-0 list-unstyled">{% for a in data.authors.all %}<li>{{ a }}</li>{% endfor %}</ul> <ul class="mb-0 list-unstyled">{% for a in book.authors.all %}<li>{{ a }}</li>{% endfor %}</ul>
</td> </td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Publisher</th> <th class="w-33" scope="row">Publisher</th>
<td> <td>{{ book.publisher }}</td>
<img src="{{ data.publisher.country.flag }}" alt="{{ data.publisher.country }}"> {{ data.publisher }}
{% if data.publisher.website %} <a href="{{ data.publisher.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr>
{% elif data.obj_type == "magazineissue" %}
<tr>
<th class="w-33" scope="row">Magazine</th>
<td>
<a href="{% url 'magazine' data.magazine.pk %}">{{ data.magazine }}</a>
{% if data.magazine.website %} <a href="{{ data.magazine.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr>
<tr>
<th class="w-33" scope="row">Publisher</th>
<td>
<img src="{{ data.publisher.country.flag }}" alt="{{ data.publisher.country }}"> {{ data.publisher }}
{% if data.publisher.website %} <a href="{{ data.publisher.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr>
<tr>
<th class="w-33" scope="row">Issue</th>
<td>{{ data.issue_number }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Date</th>
<td>{{ data.publication_year|default:"-" }} / {{ data.get_publication_month_display|default:"-" }}</td>
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
<th scope="row">ISBN</th> <th scope="row">ISBN</th>
<td>{{ data.ISBN|default:"-" }}</td> <td>{{ book.ISBN|default:"-" }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Language</th> <th scope="row">Language</th>
<td>{{ data.get_language_display }}</td> <td>{{ book.get_language_display }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Number of pages</th> <th scope="row">Number of pages</th>
<td>{{ data.number_of_pages|default:"-" }}</td> <td>{{ book.number_of_pages|default:"-" }}</td>
</tr> </tr>
{% if data.obj_type == "book" or data.obj_type == "catalog" %}
<tr> <tr>
<th scope="row">Publication year</th> <th scope="row">Publication year</th>
<td>{{ data.publication_year|default:"-" }}</td> <td>{{ book.publication_year|default:"-" }}</td>
</tr> </tr>
{% endif %} {% if book.description %}
{% if data.description %}
<tr> <tr>
<th class="w-33" scope="row">Description</th> <th class="w-33" scope="row">Description</th>
<td>{{ data.description | safe }}</td> <td>{{ book.description | safe }}</td>
</tr> </tr>
{% endif %} {% endif %}
</tbody> </tbody>
@@ -156,17 +127,17 @@
<tr> <tr>
<th class="w-33" scope="row">Shop</th> <th class="w-33" scope="row">Shop</th>
<td> <td>
{{ data.shop|default:"-" }} {{ book.shop|default:"-" }}
{% if data.shop.website %} <a href="{{ data.shop.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %} {% if book.shop.website %} <a href="{{ book.shop.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td> </td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Purchase date</th> <th class="w-33" scope="row">Purchase date</th>
<td>{{ data.purchase_date|default:"-" }}</td> <td>{{ book.purchase_date|default:"-" }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Price ({{ site_conf.currency }})</th> <th scope="row">Price ({{ site_conf.currency }})</th>
<td>{{ data.price|default:"-" }}</td> <td>{{ book.price|default:"-" }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -209,7 +180,7 @@
</div> </div>
</div> </div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end"> <div class="d-grid gap-2 d-md-flex justify-content-md-end">
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% dynamic_admin_url 'bookshelf' data.obj_type data.pk %}">Edit</a>{% endif %} {% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% dynamic_admin_url 'bookshelf' type book.pk %}">Edit</a>{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -10,9 +10,6 @@
{% if catalogs_menu %} {% if catalogs_menu %}
<li><a class="dropdown-item" href="{% url 'catalogs' %}">Catalogs</a></li> <li><a class="dropdown-item" href="{% url 'catalogs' %}">Catalogs</a></li>
{% endif %} {% endif %}
{% if magazines_menu %}
<li><a class="dropdown-item" href="{% url 'magazines' %}">Magazines</a></li>
{% endif %}
</ul> </ul>
</li> </li>
{% endif %} {% endif %}

View File

@@ -6,21 +6,19 @@
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3"> <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
{% block cards %} {% block cards %}
{% for d in data %} {% for d in data %}
{% if d.obj_type == "rollingstock" %} {% if d.type == "roster" %}
{% include "cards/roster.html" %} {% include "cards/roster.html" %}
{% elif d.obj_type == "company" %} {% elif d.type == "company" %}
{% include "cards/company.html" %} {% include "cards/company.html" %}
{% elif d.obj_type == "rollingstocktype" %} {% elif d.type == "rolling_stock_type" %}
{% include "cards/rolling_stock_type.html" %} {% include "cards/rolling_stock_type.html" %}
{% elif d.obj_type == "scale" %} {% elif d.type == "scale" %}
{% include "cards/scale.html" %} {% include "cards/scale.html" %}
{% elif d.obj_type == "consist" %} {% elif d.type == "consist" %}
{% include "cards/consist.html" %} {% include "cards/consist.html" %}
{% elif d.obj_type == "manufacturer" %} {% elif d.type == "manufacturer" %}
{% include "cards/manufacturer.html" %} {% include "cards/manufacturer.html" %}
{% elif d.obj_type == "magazine" or d.obj_type == "magazineissue" %} {% elif d.type == "book" or d.type == "catalog" %}
{% include "cards/magazine.html" %}
{% elif d.obj_type == "book" or d.obj_type == "catalog" %}
{% include "cards/book.html" %} {% include "cards/book.html" %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View File

@@ -2,31 +2,32 @@
{% load dynamic_url %} {% load dynamic_url %}
<div class="col"> <div class="col">
<div class="card shadow-sm"> <div class="card shadow-sm">
{% if d.image.exists %} {% if d.item.image.exists %}
<a href="{{d.get_absolute_url}}"><img class="card-img-top" src="{{ d.image.first.image.url }}" alt="{{ d }}"></a> <a href="{{d.item.get_absolute_url}}"><img class="card-img-top" src="{{ d.item.image.first.image.url }}" alt="{{ d.item }}"></a>
{% else %} {% else %}
<!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) --> <!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
<a href="{{d.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d }}"></a> <a href="{{d.item.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d.item }}"></a>
{% endif %} {% endif %}
<div class="card-body"> <div class="card-body">
<p class="card-text" style="position: relative;"> <p class="card-text" style="position: relative;">
<strong>{{ d }}</strong> <strong>{{ d.item }}</strong>
<a class="stretched-link" href="{{ d.get_absolute_url }}"></a> <a class="stretched-link" href="{{ d.item.get_absolute_url }}"></a>
</p> </p>
{% if d.item.tags.all %}
<p class="card-text"><small>Tags:</small> <p class="card-text"><small>Tags:</small>
{% for t in d.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary"> {% for t in d.item.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary">
{{ t.name }}</a>{# new line is required #} {{ t.name }}</a>{# new line is required #}
{% empty %}
<span class="badge rounded-pill bg-secondary"><i class="bi bi-ban"></i></span>
{% endfor %} {% endfor %}
</p> </p>
{% endif %}
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th colspan="2" scope="row"> <th colspan="2" scope="row">
{{ d.obj_label|capfirst }} {% if d.type == "catalog" %}Catalog
{% elif d.type == "book" %}Book{% endif %}
<div class="float-end"> <div class="float-end">
{% if not d.published %} {% if not d.item.published %}
<span class="badge text-bg-warning">Unpublished</span> <span class="badge text-bg-warning">Unpublished</span>
{% endif %} {% endif %}
</div> </div>
@@ -34,46 +35,44 @@
</tr> </tr>
</thead> </thead>
<tbody class="table-group-divider"> <tbody class="table-group-divider">
{% if d.obj_type == "catalog" %} {% if d.type == "catalog" %}
<tr> <tr>
<th class="w-33" scope="row">Manufacturer</th> <th class="w-33" scope="row">Manufacturer</th>
<td> <td>{{ d.item.manufacturer }}</td>
<a href="{% url 'filtered' _filter="manufacturer" search=d.manufacturer.slug %}">{{ d.manufacturer }}{% if d.manufacturer.website %}</a> <a href="{{ d.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Scales</th> <th class="w-33" scope="row">Scales</th>
<td>{{ d.get_scales }}</td> <td>{{ d.item.get_scales }}</td>
</tr> </tr>
{% elif d.obj_type == "book" %} {% elif d.type == "book" %}
<tr> <tr>
<th class="w-33" scope="row">Authors</th> <th class="w-33" scope="row">Authors</th>
<td> <td>
<ul class="mb-0 list-unstyled">{% for a in d.authors.all %}<li>{{ a }}</li>{% endfor %}</ul> <ul class="mb-0 list-unstyled">{% for a in d.item.authors.all %}<li>{{ a }}</li>{% endfor %}</ul>
</td> </td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Publisher</th> <th class="w-33" scope="row">Publisher</th>
<td><img src="{{ d.publisher.country.flag }}" alt="{{ d.publisher.country }}"> {{ d.publisher }}</td> <td>{{ d.item.publisher }}</td>
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
<th scope="row">Language</th> <th scope="row">Language</th>
<td>{{ d.get_language_display }}</td> <td>{{ d.item.get_language_display }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Pages</th> <th scope="row">Pages</th>
<td>{{ d.number_of_pages|default:"-" }}</td> <td>{{ d.item.number_of_pages|default:"-" }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Year</th> <th scope="row">Year</th>
<td>{{ d.publication_year|default:"-" }}</td> <td>{{ d.item.publication_year|default:"-" }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="d-grid gap-2 mb-1 d-md-block"> <div class="d-grid gap-2 mb-1 d-md-block">
<a class="btn btn-sm btn-outline-primary" href="{{ d.get_absolute_url }}">Show all data</a> <a class="btn btn-sm btn-outline-primary" href="{{ d.item.get_absolute_url }}">Show all data</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% dynamic_admin_url 'bookshelf' d.obj_type d.pk %}">Edit</a>{% endif %} {% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% dynamic_admin_url 'bookshelf' d.type d.item.pk %}">Edit</a>{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
<p class="card-text" style="position: relative;"> <p class="card-text" style="position: relative;">
<strong>{{ d.name }}</strong> <strong>{{ d.item.name }}</strong>
</p> </p>
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
@@ -10,7 +10,7 @@
<th colspan="2" scope="row"> <th colspan="2" scope="row">
Company Company
<div class="float-end"> <div class="float-end">
{% if d.freelance %} {% if d.item.freelance %}
<span class="badge text-bg-secondary">Freelance</span> <span class="badge text-bg-secondary">Freelance</span>
{% endif %} {% endif %}
</div> </div>
@@ -18,30 +18,30 @@
</tr> </tr>
</thead> </thead>
<tbody class="table-group-divider"> <tbody class="table-group-divider">
{% if d.logo %} {% if d.item.logo %}
<tr> <tr>
<th class="w-33" scope="row">Logo</th> <th class="w-33" scope="row">Logo</th>
<td><img class="logo" src="{{ d.logo.url }}" alt="{{ d.name }} logo"></td> <td><img class="logo" src="{{ d.item.logo.url }}" alt="{{ d.item.name }} logo"></td>
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
<th class="w-33" scope="row">Name</th> <th class="w-33" scope="row">Name</th>
<td>{{ d.extended_name }}</td> <td>{{ d.item.extended_name }}</td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Abbreviation</th> <th class="w-33" scope="row">Abbreviation</th>
<td>{{ d.name }}</td> <td>{{ d.item.name }}</td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Country</th> <th class="w-33" scope="row">Country</th>
<td><img src="{{ d.country.flag }}" alt="{{ d.country }}"> {{ d.country.name }}</td> <td><img src="{{ d.item.country.flag }}" alt="{{ d.item.country }}"> {{ d.item.country.name }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="d-grid gap-2 mb-1 d-md-block"> <div class="d-grid gap-2 mb-1 d-md-block">
{% with items=d.num_items %} {% with items=d.item.num_items %}
<a class="btn btn-sm btn-outline-primary{% if items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="company" search=d.slug %}">Show {{ items }} item{{ items | pluralize}}</a> <a class="btn btn-sm btn-outline-primary{% if items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="company" search=d.item.slug %}">Show {{ items }} item{{ items | pluralize}}</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_company_change' d.pk %}">Edit</a>{% endif %} {% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_company_change' d.item.pk %}">Edit</a>{% endif %}
{% endwith %} {% endwith %}
</div> </div>
</div> </div>

View File

@@ -1,36 +1,36 @@
<div class="col"> <div class="col">
<div class="card shadow-sm"> <div class="card shadow-sm">
<a href="{{ d.get_absolute_url }}"> <a href="{{ d.item.get_absolute_url }}">
{% if d.image %} {% if d.item.image %}
<img class="card-img-top" src="{{ d.image.url }}" alt="{{ d }}"> <img class="card-img-top" src="{{ d.item.image.url }}" alt="{{ d.item }}">
{% else %} {% else %}
{% with d.consist_item.first.rolling_stock as r %} {% with d.item.consist_item.first.rolling_stock as r %}
<img class="card-img-top" src="{{ r.image.first.image.url }}" alt="{{ d }}"> <img class="card-img-top" src="{{ r.image.first.image.url }}" alt="{{ d.item }}">
{% endwith %} {% endwith %}
{% endif %} {% endif %}
</a> </a>
<div class="card-body"> <div class="card-body">
<p class="card-text" style="position: relative;"> <p class="card-text" style="position: relative;">
<strong>{{ d }}</strong> <strong>{{ d.item }}</strong>
<a class="stretched-link" href="{{ d.get_absolute_url }}"></a> <a class="stretched-link" href="{{ d.item.get_absolute_url }}"></a>
</p> </p>
{% if d.item.tags.all %}
<p class="card-text"><small>Tags:</small> <p class="card-text"><small>Tags:</small>
{% for t in d.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary"> {% for t in d.item.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary">
{{ t.name }}</a>{# new line is required #} {{ t.name }}</a>{# new line is required #}
{% empty %}
<span class="badge rounded-pill bg-secondary"><i class="bi bi-ban"></i></span>
{% endfor %} {% endfor %}
</p> </p>
{% endif %}
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th colspan="2" scope="row"> <th colspan="2" scope="row">
Consist Consist
<div class="float-end"> <div class="float-end">
{% if not d.published %} {% if not d.item.published %}
<span class="badge text-bg-warning">Unpublished</span> <span class="badge text-bg-warning">Unpublished</span>
{% endif %} {% endif %}
{% if d.company.freelance %} {% if d.item.company.freelance %}
<span class="badge text-bg-secondary">Freelance</span> <span class="badge text-bg-secondary">Freelance</span>
{% endif %} {% endif %}
</div> </div>
@@ -38,32 +38,32 @@
</tr> </tr>
</thead> </thead>
<tbody class="table-group-divider"> <tbody class="table-group-divider">
{% if d.address %} {% if d.item.address %}
<tr> <tr>
<th class="w-33" scope="row">Address</th> <th class="w-33" scope="row">Address</th>
<td>{{ d.address }}</td> <td>{{ d.item.address }}</td>
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
<th class="w-33" scope="row">Company</th> <th class="w-33" scope="row">Company</th>
<td> <td>
<img src="{{ d.company.country.flag }}" alt="{{ d.company.country }}"> <img src="{{ d.item.company.country.flag }}" alt="{{ d.item.company.country }}">
<abbr title="{{ d.company.extended_name }}">{{ d.company }}</abbr> <abbr title="{{ d.item.company.extended_name }}">{{ d.item.company }}</abbr>
</td> </td>
</tr> </tr>
<tr> <tr>
<th scope="row">Era</th> <th scope="row">Era</th>
<td>{{ d.era }}</td> <td>{{ d.item.era }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Length</th> <th scope="row">Length</th>
<td>{{ d.length }}</td> <td>{{ d.item.length }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="d-grid gap-2 mb-1 d-md-block"> <div class="d-grid gap-2 mb-1 d-md-block">
<a class="btn btn-sm btn-outline-primary" href="{{ d.get_absolute_url }}">Show all data</a> <a class="btn btn-sm btn-outline-primary" href="{{ d.item.get_absolute_url }}">Show all data</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:consist_consist_change' d.pk %}">Edit</a>{% endif %} {% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:consist_consist_change' d.item.pk %}">Edit</a>{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,104 +0,0 @@
{% load static %}
{% load dynamic_url %}
<div class="col">
<div class="card shadow-sm">
{% if d.obj_type == "magazine" %}
<a href="{{ d.get_absolute_url }}">
{% if d.image and d.obj_type == "magazine" %}
<img class="card-img-top" src="{{ d.image.url }}" alt="{{ d }}">
{% elif d.issue.first.image.exists %}
{% with d.issue.first as i %}
<img class="card-img-top" src="{{ i.image.first.image.url }}" alt="{{ d }}">
{% endwith %}
{% else %}
<!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
<img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d }}">
{% endif %}
</a>
{% elif d.obj_type == "magazineissue" %}
<a href="{{ d.get_absolute_url }}">
{% if d.image.exists %}
<img class="card-img-top" src="{{ d.image.first.image.url }}" alt="{{ d }}">
{% else %}
<!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
<img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d }}">
{% endif %}
</a>
{% endif %}
</a>
<div class="card-body">
<p class="card-text" style="position: relative;">
<strong>{{ d }}</strong>
<a class="stretched-link" href="{{ d.get_absolute_url }}"></a>
</p>
<p class="card-text"><small>Tags:</small>
{% for t in d.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary">
{{ t.name }}</a>{# new line is required #}
{% empty %}
<span class="badge rounded-pill bg-secondary"><i class="bi bi-ban"></i></span>
{% endfor %}
</p>
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">
{{ d.obj_label|capfirst }}
<div class="float-end">
{% if not d.published %}
<span class="badge text-bg-warning">Unpublished</span>
{% endif %}
</div>
</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% if d.obj_type == "magazineissue" %}
<tr>
<th class="w-33" scope="row">Magazine</th>
<td>{{ d.magazine }}</td>
</tr>
{% else %}
<tr>
<th class="w-33" scope="row">Website</th>
<td>{% if d.website %}<a href="{{ d.website }}" target="_blank">{{ d.website_short }}</td>{% else %}-{% endif %}</td>
</tr>
{% endif %}
<tr>
<th class="w-33" scope="row">Publisher</th>
<td>
<img src="{{ d.publisher.country.flag }}" alt="{{ d.publisher.country }}"> {{ d.publisher }}
{% if d.publisher.website %} <a href="{{ d.publisher.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr>
{% if d.obj_type == "magazineissue" %}
<tr>
<th class="w-33" scope="row">Issue</th>
<td>{{ d.issue_number }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Date</th>
<td>{{ d.publication_year|default:"-" }} / {{ d.get_publication_month_display|default:"-" }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Pages</th>
<td>{{ d.number_of_pages|default:"-" }}</td>
</tr>
{% endif %}
<tr>
<th class="w-33" scope="row">Language</th>
<td>{{ d.get_language_display }}</td>
</tr>
</tbody>
</table>
<div class="d-grid gap-2 mb-1 d-md-block">
{% if d.obj_type == "magazine" %}
<a class="btn btn-sm btn-outline-primary{% if d.issues == 0 %} disabled{% endif %}" href="{{ d.get_absolute_url }}">Show {{ d.issues }} issue{{ d.issues|pluralize }}</a>
{% else %}
<a class="btn btn-sm btn-outline-primary" href="{{ d.get_absolute_url }}">Show all data</a>
{% endif %}
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% dynamic_admin_url 'bookshelf' d.obj_type d.pk %}">Edit</a>{% endif %}
</div>
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
<p class="card-text" style="position: relative;"> <p class="card-text" style="position: relative;">
<strong>{{ d.name }}</strong> <strong>{{ d.item.name }}</strong>
</p> </p>
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
@@ -11,26 +11,28 @@
</tr> </tr>
</thead> </thead>
<tbody class="table-group-divider"> <tbody class="table-group-divider">
{% if d.logo %} {% if d.item.logo %}
<tr> <tr>
<th class="w-33" scope="row">Logo</th> <th class="w-33" scope="row">Logo</th>
<td><img class="logo" src="{{ d.logo.url }}" alt="{{ d.name }} logo"></td> <td><img class="logo" src="{{ d.item.logo.url }}" alt="{{ d.item.name }} logo"></td>
</tr>
{% endif %}
{% if d.item.website %}
<tr>
<th class="w-33" scope="row">Website</th>
<td><a href="{{ d.item.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a></td>
</tr> </tr>
{% endif %} {% endif %}
<tr>
<th class="w-33" scope="row">Website</th>
<td>{% if d.website %}<a href="{{ d.website }}" target="_blank">{{ d.website_short }}</td>{% else %}-{% endif %}</td>
</tr>
<tr> <tr>
<th class="w-33" scope="row">Category</th> <th class="w-33" scope="row">Category</th>
<td>{{ d.category | title }}</td> <td>{{ d.item.category | title }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="d-grid gap-2 mb-1 d-md-block"> <div class="d-grid gap-2 mb-1 d-md-block">
{% with items=d.num_items %} {% with items=d.item.num_items %}
<a class="btn btn-sm btn-outline-primary{% if items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="manufacturer" search=d.slug %}">Show {{ items }} item{{ items|pluralize }}</a> <a class="btn btn-sm btn-outline-primary{% if items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="manufacturer" search=d.item.slug %}">Show {{ items }} item{{ items | pluralize}}</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_manufacturer_change' d.pk %}">Edit</a>{% endif %} {% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_manufacturer_change' d.item.pk %}">Edit</a>{% endif %}
{% endwith %} {% endwith %}
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
<div class="col"> <div class="col">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
<p class="card-text"><strong>{{ d }}</strong></p> <p class="card-text"><strong>{{ d.item }}</strong></p>
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
@@ -11,18 +11,18 @@
<tbody class="table-group-divider"> <tbody class="table-group-divider">
<tr> <tr>
<th class="w-33" scope="row">Type</th> <th class="w-33" scope="row">Type</th>
<td>{{ d.type }}</td> <td>{{ d.item.type }}</td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Category</th> <th class="w-33" scope="row">Category</th>
<td>{{ d.category | title}}</td> <td>{{ d.item.category | title}}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="d-grid gap-2 mb-1 d-md-block"> <div class="d-grid gap-2 mb-1 d-md-block">
{% with items=d.num_items %} {% with items=d.item.num_items %}
<a class="btn btn-sm btn-outline-primary{% if items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="type" search=d.slug %}">Show {{ items }} item{{ items | pluralize}}</a> <a class="btn btn-sm btn-outline-primary{% if items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="type" search=d.item.slug %}">Show {{ items }} item{{ items | pluralize}}</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_rollingstocktype_change' d.pk %}">Edit</a>{% endif %} {% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_rollingstocktype_change' d.item.pk %}">Edit</a>{% endif %}
{% endwith %} {% endwith %}
</div> </div>
</div> </div>

View File

@@ -3,34 +3,34 @@
<div class="col"> <div class="col">
<div class="card shadow-sm"> <div class="card shadow-sm">
{% if d.image.exists %} {% if d.item.image.exists %}
<a href="{{d.get_absolute_url}}"><img class="card-img-top" src="{{ d.image.first.image.url }}" alt="{{ d }}"></a> <a href="{{d.item.get_absolute_url}}"><img class="card-img-top" src="{{ d.item.image.first.image.url }}" alt="{{ d.item }}"></a>
{% else %} {% else %}
<!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) --> <!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
<a href="{{d.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d }}"></a> <a href="{{d.item.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d.item }}"></a>
{% endif %} {% endif %}
<div class="card-body"> <div class="card-body">
<p class="card-text" style="position: relative;"> <p class="card-text" style="position: relative;">
<strong>{{ d }}</strong> <strong>{{ d.item }}</strong>
<a class="stretched-link" href="{{ d.get_absolute_url }}"></a> <a class="stretched-link" href="{{ d.item.get_absolute_url }}"></a>
</p> </p>
{% if d.item.tags.all %}
<p class="card-text"><small>Tags:</small> <p class="card-text"><small>Tags:</small>
{% for t in d.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary"> {% for t in d.item.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary">
{{ t.name }}</a>{# new line is required #} {{ t.name }}</a>{# new line is required #}
{% empty %}
<span class="badge rounded-pill bg-secondary"><i class="bi bi-ban"></i></span>
{% endfor %} {% endfor %}
</p> </p>
{% endif %}
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th colspan="2" scope="row"> <th colspan="2" scope="row">
Rolling stock Rolling stock
<div class="float-end"> <div class="float-end">
{% if not d.published %} {% if not d.item.published %}
<span class="badge text-bg-warning">Unpublished</span> <span class="badge text-bg-warning">Unpublished</span>
{% endif %} {% endif %}
{% if d.company.freelance %} {% if d.item.company.freelance %}
<span class="badge text-bg-secondary">Freelance</span> <span class="badge text-bg-secondary">Freelance</span>
{% endif %} {% endif %}
</div> </div>
@@ -40,50 +40,50 @@
<tbody class="table-group-divider"> <tbody class="table-group-divider">
<tr> <tr>
<th class="w-33" scope="row">Type</th> <th class="w-33" scope="row">Type</th>
<td>{{ d.rolling_class.type }}</td> <td>{{ d.item.rolling_class.type }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Company</th> <th scope="row">Company</th>
<td> <td>
<img src="{{ d.company.country.flag }}" alt="{{ d.company.country }}"> <img src="{{ d.item.company.country.flag }}" alt="{{ d.item.company.country }}">
<a href="{% url 'filtered' _filter="company" search=d.company.slug %}"><abbr title="{{ d.company.extended_name }}">{{ d.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> </td>
</tr> </tr>
<tr> <tr>
<th scope="row">Class</th> <th scope="row">Class</th>
<td>{{ d.rolling_class.identifier }}</td> <td>{{ d.item.rolling_class.identifier }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Road number</th> <th scope="row">Road number</th>
<td>{{ d.road_number }}</td> <td>{{ d.item.road_number }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Era</th> <th scope="row">Era</th>
<td>{{ d.era }}</td> <td>{{ d.item.era }}</td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Manufacturer</th> <th class="w-33" scope="row">Manufacturer</th>
<td>{%if d.manufacturer %} <td>{%if d.item.manufacturer %}
<a href="{% url 'filtered' _filter="manufacturer" search=d.manufacturer.slug %}">{{ d.manufacturer }}{% if d.manufacturer.website %}</a> <a href="{{ d.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %} <a href="{% url 'filtered' _filter="manufacturer" search=d.item.manufacturer.slug %}">{{ d.item.manufacturer }}{% if d.item.manufacturer.website %}</a> <a href="{{ d.item.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
{% endif %}</td> {% endif %}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Scale</th> <th scope="row">Scale</th>
<td><a href="{% url 'filtered' _filter="scale" search=d.scale.slug %}"><abbr title="{{ d.scale.ratio }} - {{ d.scale.tracks }} mm">{{ d.scale }}</abbr></a></td> <td><a href="{% url 'filtered' _filter="scale" search=d.item.scale.slug %}"><abbr title="{{ d.item.scale.ratio }} - {{ d.item.scale.tracks }} mm">{{ d.item.scale }}</abbr></a></td>
</tr> </tr>
<tr> <tr>
<th scope="row">Item number</th> <th scope="row">Item number</th>
<td>{{ d.item_number }}{%if d.set %} | <a class="badge text-bg-primary" href="{% url 'manufacturer' manufacturer=d.manufacturer.slug search=d.item_number_slug %}">SET</a>{% endif %}</td> <td>{{ d.item.item_number }}{%if d.item.set %} | <a class="badge text-bg-primary" href="{% url 'manufacturer' manufacturer=d.item.manufacturer.slug search=d.item.item_number_slug %}">SET</a>{% endif %}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">DCC</th> <th scope="row">DCC</th>
<td><a class="text-reset text-decoration-none" title="Symbols" href="" data-bs-toggle="modal" data-bs-target="#symbolsModal">{% dcc d %}</a></td> <td><a class="text-reset text-decoration-none" title="Symbols" href="" data-bs-toggle="modal" data-bs-target="#symbolsModal">{% dcc d.item %}</a></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="d-grid gap-2 mb-1 d-md-block"> <div class="d-grid gap-2 mb-1 d-md-block">
<a class="btn btn-sm btn-outline-primary" href="{{d.get_absolute_url}}">Show all data</a> <a class="btn btn-sm btn-outline-primary" href="{{d.item.get_absolute_url}}">Show all data</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:roster_rollingstock_change' d.pk %}">Edit</a>{% endif %} {% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:roster_rollingstock_change' d.item.pk %}">Edit</a>{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
<div class="col"> <div class="col">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
<p class="card-text"><strong>{{ d }}</strong></p> <p class="card-text"><strong>{{ d.item }}</strong></p>
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
@@ -11,26 +11,26 @@
<tbody> <tbody>
<tr> <tr>
<th class="w-33" scope="row">Name</th> <th class="w-33" scope="row">Name</th>
<td>{{ d.scale }}</td> <td>{{ d.item.scale }}</td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Ratio</th> <th class="w-33" scope="row">Ratio</th>
<td>{{ d.ratio }}</td> <td>{{ d.item.ratio }}</td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Tracks</th> <th class="w-33" scope="row">Tracks</th>
<td>{{ d.tracks }} mm</td> <td>{{ d.item.tracks }} mm</td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Gauge</th> <th class="w-33" scope="row">Gauge</th>
<td>{{ d.gauge }}</td> <td>{{ d.item.gauge }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="d-grid gap-2 mb-1 d-md-block"> <div class="d-grid gap-2 mb-1 d-md-block">
{% with items=d.num_items %} {% with items=d.item.num_items %}
<a class="btn btn-sm btn-outline-primary{% if items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="scale" search=d.slug %}">Show {{ items }} item{{ items | pluralize}}</a> <a class="btn btn-sm btn-outline-primary{% if items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="scale" search=d.item.slug %}">Show {{ items }} item{{ items | pluralize}}</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_scale_change' d.pk %}">Edit</a>{% endif %} {% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_scale_change' d.item.pk %}">Edit</a>{% endif %}
{% endwith %} {% endwith %}
</div> </div>
</div> </div>

View File

@@ -32,7 +32,7 @@
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0"> <ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %} {% if data.has_previous %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url 'consist' uuid=consist.uuid page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a> <a class="page-link" href="{% url 'consist_pagination' uuid=consist.uuid page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">
@@ -48,13 +48,13 @@
{% if i == data.paginator.ELLIPSIS %} {% if i == data.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li> <li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %} {% else %}
<li class="page-item"><a class="page-link" href="{% url 'consist' uuid=consist.uuid page=i %}#main-content">{{ i }}</a></li> <li class="page-item"><a class="page-link" href="{% url 'consist_pagination' uuid=consist.uuid page=i %}#main-content">{{ i }}</a></li>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if data.has_next %} {% if data.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url 'consist' uuid=consist.uuid page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a> <a class="page-link" href="{% url 'consist_pagination' uuid=consist.uuid page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">

View File

@@ -5,7 +5,7 @@
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0"> <ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %} {% if data.has_previous %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url 'filtered' _filter=filter search=search page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a> <a class="page-link" href="{% url 'filtered_pagination' _filter=filter search=search page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">
@@ -21,13 +21,13 @@
{% if i == data.paginator.ELLIPSIS %} {% if i == data.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li> <li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %} {% else %}
<li class="page-item"><a class="page-link" href="{% url 'filtered' _filter=filter search=search page=i %}#main-content">{{ i }}</a></li> <li class="page-item"><a class="page-link" href="{% url 'filtered_pagination' _filter=filter search=search page=i %}#main-content">{{ i }}</a></li>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if data.has_next %} {% if data.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url 'filtered' _filter=filter search=search page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a> <a class="page-link" href="{% url 'filtered_pagination' _filter=filter search=search page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">

View File

@@ -3,18 +3,3 @@
{% block header %} {% block header %}
<div class="text-body-secondary">{{ site_conf.about | safe }}</div> <div class="text-body-secondary">{{ site_conf.about | safe }}</div>
{% endblock %} {% endblock %}
{% block cards %}
{% for d in data %}
{% include "cards/roster.html" %}
{% endfor %}
{% endblock %}
{% block pagination %}
<nav aria-label="Page navigation">
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
<li class="page-item">
<a class="page-link" href="{% url "roster" %}#main-content" tabindex="-1">Go to the roster <i class="bi bi-chevron-right"></i></a>
</li>
</ul>
</nav>
{% endblock %}

View File

@@ -11,10 +11,9 @@
<ul class="dropdown-menu" aria-labelledby="dropdownLogin"> <ul class="dropdown-menu" aria-labelledby="dropdownLogin">
<li><a class="dropdown-item" href="{% url 'admin:roster_rollingstock_changelist' %}">Rolling stock</a></li> <li><a class="dropdown-item" href="{% url 'admin:roster_rollingstock_changelist' %}">Rolling stock</a></li>
<li><a class="dropdown-item" href="{% url 'admin:consist_consist_changelist' %}">Consists</a></li> <li><a class="dropdown-item" href="{% url 'admin:consist_consist_changelist' %}">Consists</a></li>
<li><a class="dropdown-item" href="{% url 'admin:app_list' 'bookshelf' %}">Bookshelf</a></li>
<li><a class="dropdown-item" href="{% url 'admin:portal_flatpage_changelist' %}">Pages</a></li>
<li><a class="dropdown-item" href="{% url 'admin:app_list' 'repository' %}">Repository</a></li>
<li><a class="dropdown-item" href="{% url 'admin:app_list' 'metadata' %}">Metadata</a></li> <li><a class="dropdown-item" href="{% url 'admin:app_list' 'metadata' %}">Metadata</a></li>
<li><a class="dropdown-item" href="{% url 'admin:portal_flatpage_changelist' %}">Pages</a></li>
<li><a class="dropdown-item" href="{% url 'admin:app_list' 'bookshelf' %}">Bookshelf</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'admin:index' %}">Admin</a></li> <li><a class="dropdown-item" href="{% url 'admin:index' %}">Admin</a></li>
<li><a class="dropdown-item" href="{% url 'admin:portal_siteconfiguration_changelist' %}">Site configuration</a></li> <li><a class="dropdown-item" href="{% url 'admin:portal_siteconfiguration_changelist' %}">Site configuration</a></li>

View File

@@ -1,128 +0,0 @@
{% extends "cards.html" %}
{% block header %}
{{ block.super }}
{% if magazine.tags.all %}
<p><small>Tags:</small>
{% for t in magazine.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary">
{{ t.name }}</a>{# new line is required #}
{% endfor %}
</p>
{% if not magazine.published %}
<span class="badge text-bg-warning">Unpublished</span> |
{% endif %}
<small class="text-body-secondary">Updated {{ magazine.updated_time | date:"M d, Y H:i" }}</small>
{% endif %}
{% endblock %}
{% block carousel %}
{% if magazine.image %}
<div class="row pb-4">
<div id="carouselControls" class="carousel carousel-dark slide" data-bs-ride="carousel">
<div class="carousel-inner">
<div class="carousel-item active">
<img src="{{ magazine.image.url }}" class="d-block w-100 rounded img-thumbnail" alt="magazine cover">
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block pagination %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation">
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'magazine_pagination' uuid=magazine.uuid page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-left"></i></span>
</li>
{% endif %}
{% for i in page_range %}
{% if data.number == i %}
<li class="page-item active">
<span class="page-link">{{ i }}</span>
</li>
{% else %}
{% if i == data.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %}
<li class="page-item"><a class="page-link" href="{% url 'magazine_pagination' uuid=magazine.uuid page=i %}#main-content">{{ i }}</a></li>
{% endif %}
{% endif %}
{% endfor %}
{% if data.has_next %}
<li class="page-item">
<a class="page-link" href="{% url 'magazine_pagination' uuid=magazine.uuid page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-right"></i></span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}
{% block extra_content %}
<section class="py-4 text-start container">
<div class="row">
<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>
</nav>
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
<option value="nav-summary" selected>Summary</option>
</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">
Magazine
</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Name</th>
<td>{{ magazine }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Publisher</th>
<td>
<img src="{{ magazine.publisher.country.flag }}" alt="{{ magazine.publisher.country }}"> {{ magazine.publisher }}
{% if magazine.publisher.website %} <a href="{{ magazine.publisher.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr>
<tr>
<th class="w-33" scope="row">Website</th>
<td>{% if magazine.website %}<a href="{{ magazine.website }}" target="_blank">{{ magazine.website_short }}</td>{% else %}-{% endif %}</td>
</tr>
<tr>
<th class="w-33" scope="row">Language</th>
<td>{{ magazine.get_language_display }}</td>
</tr>
<tr>
<th scope="row">ISBN</th>
<td>{{ magazine.ISBN | default:"-" }}</td>
</tr>
{% if magazine.description %}
<tr>
<th scope="row">Description</th>
<td>{{ magazine.description | safe }}</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:bookshelf_magazine_change' magazine.pk %}">Edit</a>{% endif %}
</div>
</div>
</div>
</section>
{% endblock %}

View File

@@ -5,7 +5,7 @@
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0"> <ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %} {% if data.has_previous %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url 'manufacturer' manufacturer=manufacturer.slug search=search page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a> <a class="page-link" href="{% url 'manufacturer_pagination' manufacturer=manufacturer.slug search=search page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">
@@ -21,13 +21,13 @@
{% if i == data.paginator.ELLIPSIS %} {% if i == data.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li> <li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %} {% else %}
<li class="page-item"><a class="page-link" href="{% url 'manufacturer' manufacturer=manufacturer.slug search=search page=i %}#main-content">{{ i }}</a></li> <li class="page-item"><a class="page-link" href="{% url 'manufacturer_pagination' manufacturer=manufacturer.slug search=search page=i %}#main-content">{{ i }}</a></li>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if data.has_next %} {% if data.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url 'manufacturer' manufacturer=manufacturer.slug search=search page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a> <a class="page-link" href="{% url 'manufacturer_pagination' manufacturer=manufacturer.slug search=search page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">

View File

@@ -7,7 +7,7 @@
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0"> <ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %} {% if data.has_previous %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url request.resolver_match.url_name page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a> <a class="page-link" href="{% dynamic_pagination type page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">
@@ -23,13 +23,13 @@
{% if i == data.paginator.ELLIPSIS %} {% if i == data.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li> <li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %} {% else %}
<li class="page-item"><a class="page-link" href="{% url request.resolver_match.url_name page=i %}#main-content">{{ i }}</a></li> <li class="page-item"><a class="page-link" href="{% dynamic_pagination type page=i %}#main-content">{{ i }}</a></li>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if data.has_next %} {% if data.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url request.resolver_match.url_name page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a> <a class="page-link" href="{% dynamic_pagination type page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">

View File

@@ -1,12 +1,12 @@
{% extends "cards.html" %} {% extends "cards.html" %}
{% block pagination %} {% block pagination %}
{% if data.has_other_pages %} {% if data.has_other_pages %}
{% with data.0.category as c %} {% with data.0.item.category as c %}
<nav aria-label="Page navigation"> <nav aria-label="Page navigation">
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0"> <ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %} {% if data.has_previous %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url 'manufacturers' category=c page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a> <a class="page-link" href="{% url 'manufacturers_pagination' category=c page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">
@@ -22,13 +22,13 @@
{% if i == data.paginator.ELLIPSIS %} {% if i == data.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li> <li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %} {% else %}
<li class="page-item"><a class="page-link" href="{% url 'manufacturers' category=c page=i %}#main-content">{{ i }}</a></li> <li class="page-item"><a class="page-link" href="{% url 'manufacturers_pagination' category=c page=i %}#main-content">{{ i }}</a></li>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if data.has_next %} {% if data.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url 'manufacturers' category=c page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a> <a class="page-link" href="{% url 'manufacturers_pagination' category=c page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">

View File

@@ -6,7 +6,7 @@
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0"> <ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %} {% if data.has_previous %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url 'search' search=encoded_search page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a> <a class="page-link" href="{% url 'search_pagination' search=encoded_search page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">
@@ -22,13 +22,13 @@
{% if i == data.paginator.ELLIPSIS %} {% if i == data.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li> <li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %} {% else %}
<li class="page-item"><a class="page-link" href="{% url 'search' search=encoded_search page=i %}#main-content">{{ i }}</a></li> <li class="page-item"><a class="page-link" href="{% url 'search_pagination' search=encoded_search page=i %}#main-content">{{ i }}</a></li>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if data.has_next %} {% if data.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url 'search' search=encoded_search page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a> <a class="page-link" href="{% url 'search_pagination' search=encoded_search page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">

View File

@@ -12,3 +12,10 @@ def dynamic_admin_url(app_name, model_name, object_id=None):
args=[object_id] args=[object_id]
) )
return reverse(f'admin:{app_name}_{model_name}_changelist') return reverse(f'admin:{app_name}_{model_name}_changelist')
@register.simple_tag
def dynamic_pagination(reverse_name, page):
if reverse_name.endswith('y'):
return reverse(f'{reverse_name[:-1]}ies_pagination', args=[page])
return reverse(f'{reverse_name}s_pagination', args=[page])

View File

@@ -1,6 +1,6 @@
from django import template from django import template
from portal.models import Flatpage from portal.models import Flatpage
from bookshelf.models import Book, Catalog, Magazine from bookshelf.models import Book, Catalog
register = template.Library() register = template.Library()
@@ -8,14 +8,10 @@ register = template.Library()
@register.inclusion_tag('bookshelf/bookshelf_menu.html') @register.inclusion_tag('bookshelf/bookshelf_menu.html')
def show_bookshelf_menu(): def show_bookshelf_menu():
# FIXME: Filter out unpublished books and catalogs? # FIXME: Filter out unpublished books and catalogs?
books = Book.objects.exists()
catalogs = Catalog.objects.exists()
magazines = Magazine.objects.exists()
return { return {
"bookshelf_menu": (books or catalogs or magazines), "bookshelf_menu": (Book.objects.exists() or Catalog.objects.exists()),
"books_menu": books, "books_menu": Book.objects.exists(),
"catalogs_menu": catalogs, "catalogs_menu": Catalog.objects.exists(),
"magazines_menu": magazines,
} }

View File

@@ -1,11 +0,0 @@
import random
from django import template
register = template.Library()
@register.filter
def shuffle(items):
shuffled_items = list(items)
random.shuffle(shuffled_items)
return shuffled_items

View File

@@ -1,7 +1,7 @@
from django.urls import path from django.urls import path
from portal.views import ( from portal.views import (
GetHome, GetData,
GetRoster, GetRoster,
GetObjectsFiltered, GetObjectsFiltered,
GetManufacturerItem, GetManufacturerItem,
@@ -15,83 +15,103 @@ from portal.views import (
Types, Types,
Books, Books,
Catalogs, Catalogs,
Magazines,
GetMagazine,
GetMagazineIssue,
GetBookCatalog, GetBookCatalog,
SearchObjects, SearchObjects,
) )
urlpatterns = [ urlpatterns = [
path("", GetHome.as_view(), name="index"), path("", GetData.as_view(template="home.html"), name="index"),
path("roster", GetRoster.as_view(), name="roster"), path("roster", GetRoster.as_view(), name="roster"),
path("roster/page/<int:page>", GetRoster.as_view(), name="roster"), path(
"roster/page/<int:page>",
GetRoster.as_view(),
name="rosters_pagination"
),
path( path(
"page/<str:flatpage>", "page/<str:flatpage>",
GetFlatpage.as_view(), GetFlatpage.as_view(),
name="flatpage", name="flatpage",
), ),
path("consists", Consists.as_view(), name="consists"), path(
path("consists/page/<int:page>", Consists.as_view(), name="consists"), "consists",
Consists.as_view(),
name="consists"
),
path(
"consists/page/<int:page>",
Consists.as_view(),
name="consists_pagination"
),
path("consist/<uuid:uuid>", GetConsist.as_view(), name="consist"), path("consist/<uuid:uuid>", GetConsist.as_view(), name="consist"),
path( path(
"consist/<uuid:uuid>/page/<int:page>", "consist/<uuid:uuid>/page/<int:page>",
GetConsist.as_view(), GetConsist.as_view(),
name="consist", name="consist_pagination",
),
path(
"companies",
Companies.as_view(),
name="companies"
), ),
path("companies", Companies.as_view(), name="companies"),
path( path(
"companies/page/<int:page>", "companies/page/<int:page>",
Companies.as_view(), Companies.as_view(),
name="companies", name="companies_pagination",
), ),
path( path(
"manufacturers/<str:category>", "manufacturers/<str:category>",
Manufacturers.as_view(template="pagination_manufacturers.html"), Manufacturers.as_view(template="pagination_manufacturers.html"),
name="manufacturers", name="manufacturers"
), ),
path( path(
"manufacturers/<str:category>/page/<int:page>", "manufacturers/<str:category>/page/<int:page>",
Manufacturers.as_view(template="pagination_manufacturers.html"), Manufacturers.as_view(template="pagination_manufacturers.html"),
name="manufacturers", name="manufacturers_pagination",
),
path("scales", Scales.as_view(), name="scales"),
path("scales/page/<int:page>", Scales.as_view(), name="scales"),
path("types", Types.as_view(), name="rolling_stock_types"),
path("types/page/<int:page>", Types.as_view(), name="rolling_stock_types"),
path("bookshelf/books", Books.as_view(), name="books"),
path("bookshelf/books/page/<int:page>", Books.as_view(), name="books"),
path(
"bookshelf/magazine/<uuid:uuid>",
GetMagazine.as_view(),
name="magazine",
), ),
path( path(
"bookshelf/magazine/<uuid:uuid>/page/<int:page>", "scales",
GetMagazine.as_view(), Scales.as_view(),
name="magazine", name="scales"
), ),
path( path(
"bookshelf/magazine/<uuid:magazine>/issue/<uuid:uuid>", "scales/page/<int:page>",
GetMagazineIssue.as_view(), Scales.as_view(),
name="issue", name="scales_pagination"
), ),
path("bookshelf/magazines", Magazines.as_view(), name="magazines"),
path( path(
"bookshelf/magazines/page/<int:page>", "types",
Magazines.as_view(), Types.as_view(),
name="magazines", name="rolling_stock_types"
),
path(
"types/page/<int:page>",
Types.as_view(),
name="rolling_stock_types_pagination"
),
path(
"bookshelf/books",
Books.as_view(),
name="books"
),
path(
"bookshelf/books/page/<int:page>",
Books.as_view(),
name="books_pagination"
), ),
path( path(
"bookshelf/<str:selector>/<uuid:uuid>", "bookshelf/<str:selector>/<uuid:uuid>",
GetBookCatalog.as_view(), GetBookCatalog.as_view(),
name="bookshelf_item", name="bookshelf_item"
),
path(
"bookshelf/catalogs",
Catalogs.as_view(),
name="catalogs"
), ),
path("bookshelf/catalogs", Catalogs.as_view(), name="catalogs"),
path( path(
"bookshelf/catalogs/page/<int:page>", "bookshelf/catalogs/page/<int:page>",
Catalogs.as_view(), Catalogs.as_view(),
name="catalogs", name="catalogs_pagination"
), ),
path( path(
"search", "search",
@@ -101,7 +121,7 @@ urlpatterns = [
path( path(
"search/<str:search>/page/<int:page>", "search/<str:search>/page/<int:page>",
SearchObjects.as_view(), SearchObjects.as_view(),
name="search", name="search_pagination",
), ),
path( path(
"manufacturer/<str:manufacturer>", "manufacturer/<str:manufacturer>",
@@ -111,7 +131,7 @@ urlpatterns = [
path( path(
"manufacturer/<str:manufacturer>/page/<int:page>", "manufacturer/<str:manufacturer>/page/<int:page>",
GetManufacturerItem.as_view(), GetManufacturerItem.as_view(),
name="manufacturer", name="manufacturer_pagination",
), ),
path( path(
"manufacturer/<str:manufacturer>/<str:search>", "manufacturer/<str:manufacturer>/<str:search>",
@@ -121,7 +141,7 @@ urlpatterns = [
path( path(
"manufacturer/<str:manufacturer>/<str:search>/page/<int:page>", "manufacturer/<str:manufacturer>/<str:search>/page/<int:page>",
GetManufacturerItem.as_view(), GetManufacturerItem.as_view(),
name="manufacturer", name="manufacturer_pagination",
), ),
path( path(
"<str:_filter>/<str:search>", "<str:_filter>/<str:search>",
@@ -131,7 +151,7 @@ urlpatterns = [
path( path(
"<str:_filter>/<str:search>/page/<int:page>", "<str:_filter>/<str:search>/page/<int:page>",
GetObjectsFiltered.as_view(), GetObjectsFiltered.as_view(),
name="filtered", name="filtered_pagination",
), ),
path("<uuid:uuid>", GetRollingStock.as_view(), name="rolling_stock"), path("<uuid:uuid>", GetRollingStock.as_view(), name="rolling_stock"),
] ]

View File

@@ -4,12 +4,10 @@ from itertools import chain
from functools import reduce from functools import reduce
from urllib.parse import unquote from urllib.parse import unquote
from django.conf import settings
from django.views import View from django.views import View
from django.http import Http404, HttpResponseBadRequest from django.http import Http404, HttpResponseBadRequest
from django.db.utils import OperationalError, ProgrammingError from django.db.utils import OperationalError, ProgrammingError
from django.db.models import F, Q, Count from django.db.models import F, Q, Count
from django.db.models.functions import Lower
from django.shortcuts import render, get_object_or_404, get_list_or_404 from django.shortcuts import render, get_object_or_404, get_list_or_404
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator from django.core.paginator import Paginator
@@ -18,7 +16,7 @@ from portal.utils import get_site_conf
from portal.models import Flatpage from portal.models import Flatpage
from roster.models import RollingStock from roster.models import RollingStock
from consist.models import Consist from consist.models import Consist
from bookshelf.models import Book, Catalog, Magazine, MagazineIssue from bookshelf.models import Book, Catalog
from metadata.models import ( from metadata.models import (
Company, Company,
Manufacturer, Manufacturer,
@@ -33,7 +31,7 @@ def get_items_per_page():
items_per_page = get_site_conf().items_per_page items_per_page = get_site_conf().items_per_page
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError):
items_per_page = 6 items_per_page = 6
return int(items_per_page) return items_per_page
def get_order_by_field(): def get_order_by_field():
@@ -63,8 +61,9 @@ class Render404(View):
class GetData(View): class GetData(View):
title = None title = "Home"
template = "pagination.html" template = "pagination.html"
item_type = "roster"
filter = Q() # empty filter by default filter = Q() # empty filter by default
def get_data(self, request): def get_data(self, request):
@@ -75,10 +74,9 @@ class GetData(View):
) )
def get(self, request, page=1): def get(self, request, page=1):
if self.title is None or self.template is None: data = []
raise Exception("title and template must be defined") for item in self.get_data(request):
data.append({"type": self.item_type, "item": item})
data = list(self.get_data(request))
paginator = Paginator(data, get_items_per_page()) paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page) data = paginator.get_page(page)
@@ -91,6 +89,7 @@ class GetData(View):
self.template, self.template,
{ {
"title": self.title, "title": self.title,
"type": self.item_type,
"data": data, "data": data,
"matches": paginator.count, "matches": paginator.count,
"page_range": page_range, "page_range": page_range,
@@ -98,36 +97,18 @@ class GetData(View):
) )
class GetHome(GetData):
title = "Home"
template = "home.html"
def get_data(self, request):
max_items = min(settings.FEATURED_ITEMS_MAX, get_items_per_page())
return (
RollingStock.objects.get_published(request.user)
.filter(featured=True)
.order_by(*get_order_by_field())[:max_items]
) or super().get_data(request)
class GetRoster(GetData): class GetRoster(GetData):
title = "The Roster" title = "The Roster"
item_type = "roster"
def get_data(self, request):
return RollingStock.objects.get_published(request.user).order_by(
*get_order_by_field()
)
class SearchObjects(View): class SearchObjects(View):
def run_search(self, request, search, _filter, page=1): def run_search(self, request, search, _filter, page=1):
"""
Run the search query on the database and return the results.
param request: HTTP request
param search: search string
param _filter: filter to apply (type, company, manufacturer, scale)
param page: page number for pagination
return: tuple (data, matches, page_range)
1. data: list of dicts with keys "type" and "item"
2. matches: total number of matches
3. page_range: elided page range for pagination
"""
if _filter is None: if _filter is None:
query = reduce( query = reduce(
operator.or_, operator.or_,
@@ -170,13 +151,15 @@ class SearchObjects(View):
# FIXME duplicated code! # FIXME duplicated code!
# FIXME see if it makes sense to filter calatogs and books by scale # FIXME see if it makes sense to filter calatogs and books by scale
# and manufacturer as well # and manufacturer as well
data = []
roster = ( roster = (
RollingStock.objects.get_published(request.user) RollingStock.objects.get_published(request.user)
.filter(query) .filter(query)
.distinct() .distinct()
.order_by(*get_order_by_field()) .order_by(*get_order_by_field())
) )
data = list(roster) for item in roster:
data.append({"type": "roster", "item": item})
if _filter is None: if _filter is None:
consists = ( consists = (
@@ -189,39 +172,20 @@ class SearchObjects(View):
) )
.distinct() .distinct()
) )
data = list(chain(data, consists)) for item in consists:
data.append({"type": "consist", "item": item})
books = ( books = (
Book.objects.get_published(request.user) Book.objects.get_published(request.user)
.filter( .filter(title__icontains=search)
Q(
Q(title__icontains=search)
| Q(description__icontains=search)
)
)
.distinct() .distinct()
) )
catalogs = ( catalogs = (
Catalog.objects.get_published(request.user) Catalog.objects.get_published(request.user)
.filter( .filter(manufacturer__name__icontains=search)
Q(
Q(manufacturer__name__icontains=search)
| Q(description__icontains=search)
)
)
.distinct() .distinct()
) )
data = list(chain(data, books, catalogs)) for item in list(chain(books, catalogs)):
magazine_issues = ( data.append({"type": "book", "item": item})
MagazineIssue.objects.get_published(request.user)
.filter(
Q(
Q(magazine__name__icontains=search)
| Q(description__icontains=search)
)
)
.distinct()
)
data = list(chain(data, magazine_issues))
paginator = Paginator(data, get_items_per_page()) paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page) data = paginator.get_page(page)
@@ -277,25 +241,10 @@ class SearchObjects(View):
class GetManufacturerItem(View): class GetManufacturerItem(View):
def get(self, request, manufacturer, search="all", page=1): def get(self, request, manufacturer, search="all", page=1):
"""
Get all items from a specific manufacturer. If `search` is not "all",
filter by item number as well, for example to get all itmes from the
same set.
The view returns both rolling stock and catalogs.
param request: HTTP request
param manufacturer: Manufacturer slug
param search: item number slug or "all"
param page: page number for pagination
return: rendered template
1. manufacturer: Manufacturer object
2. search: item number slug or "all"
3. data: list of dicts with keys "type" and "item"
4. matches: total number of matches
5. page_range: elided page range for pagination
"""
manufacturer = get_object_or_404( manufacturer = get_object_or_404(
Manufacturer, slug__iexact=manufacturer Manufacturer, slug__iexact=manufacturer
) )
if search != "all": if search != "all":
roster = get_list_or_404( roster = get_list_or_404(
RollingStock.objects.get_published(request.user).order_by( RollingStock.objects.get_published(request.user).order_by(
@@ -306,7 +255,6 @@ class GetManufacturerItem(View):
& Q(item_number_slug__exact=search) & Q(item_number_slug__exact=search)
), ),
) )
catalogs = [] # no catalogs when searching for a specific item
title = "{0}: {1}".format( title = "{0}: {1}".format(
manufacturer, manufacturer,
# all returned records must have the same `item_number``; # all returned records must have the same `item_number``;
@@ -323,12 +271,12 @@ class GetManufacturerItem(View):
.distinct() .distinct()
.order_by(*get_order_by_field()) .order_by(*get_order_by_field())
) )
catalogs = Catalog.objects.get_published(request.user).filter(
manufacturer=manufacturer
)
title = "Manufacturer: {0}".format(manufacturer) title = "Manufacturer: {0}".format(manufacturer)
data = list(chain(roster, catalogs)) data = []
for item in roster:
data.append({"type": "roster", "item": item})
paginator = Paginator(data, get_items_per_page()) paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page) data = paginator.get_page(page)
page_range = paginator.get_elided_page_range( page_range = paginator.get_elided_page_range(
@@ -377,15 +325,9 @@ class GetObjectsFiltered(View):
.order_by(*get_order_by_field()) .order_by(*get_order_by_field())
) )
data = list(roster) data = []
for item in roster:
if _filter == "scale": data.append({"type": "roster", "item": item})
catalogs = (
Catalog.objects.get_published(request.user)
.filter(scales__slug=search)
.distinct()
)
data = list(chain(data, catalogs))
try: # Execute only if query_2nd is defined try: # Execute only if query_2nd is defined
consists = ( consists = (
@@ -393,24 +335,23 @@ class GetObjectsFiltered(View):
.filter(query_2nd) .filter(query_2nd)
.distinct() .distinct()
) )
data = list(chain(data, consists)) for item in consists:
data.append({"type": "consist", "item": item})
if _filter == "tag": # Books can be filtered only by tag if _filter == "tag": # Books can be filtered only by tag
books = ( books = (
Book.objects.get_published(request.user) Book.objects.get_published(request.user)
.filter(query_2nd) .filter(query_2nd)
.distinct() .distinct()
) )
for item in books:
data.append({"type": "book", "item": item})
catalogs = ( catalogs = (
Catalog.objects.get_published(request.user) Catalog.objects.get_published(request.user)
.filter(query_2nd) .filter(query_2nd)
.distinct() .distinct()
) )
magazine_issues = ( for item in catalogs:
MagazineIssue.objects.get_published(request.user) data.append({"type": "catalog", "item": item})
.filter(query_2nd)
.distinct()
)
data = list(chain(data, books, catalogs, magazine_issues))
except NameError: except NameError:
pass pass
@@ -464,14 +405,16 @@ class GetRollingStock(View):
request.user request.user
) )
consists = list( consists = [
Consist.objects.get_published(request.user).filter( {"type": "consist", "item": c}
for c in Consist.objects.get_published(request.user).filter(
consist_item__rolling_stock=rolling_stock consist_item__rolling_stock=rolling_stock
) )
) ] # A dict with "item" is required by the consists card
trainset = list( set = [
RollingStock.objects.get_published(request.user) {"type": "set", "item": s}
for s in RollingStock.objects.get_published(request.user)
.filter( .filter(
Q( Q(
Q(item_number__exact=rolling_stock.item_number) Q(item_number__exact=rolling_stock.item_number)
@@ -479,7 +422,7 @@ class GetRollingStock(View):
) )
) )
.order_by(*get_order_by_field()) .order_by(*get_order_by_field())
) ]
return render( return render(
request, request,
@@ -492,7 +435,7 @@ class GetRollingStock(View):
"decoder_documents": decoder_documents, "decoder_documents": decoder_documents,
"documents": documents, "documents": documents,
"journal": journal, "journal": journal,
"set": trainset, "set": set,
"consists": consists, "consists": consists,
}, },
) )
@@ -500,6 +443,7 @@ class GetRollingStock(View):
class Consists(GetData): class Consists(GetData):
title = "Consists" title = "Consists"
item_type = "consist"
def get_data(self, request): def get_data(self, request):
return Consist.objects.get_published(request.user).all() return Consist.objects.get_published(request.user).all()
@@ -513,13 +457,16 @@ class GetConsist(View):
) )
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise Http404 raise Http404
data = [
data = list( {
RollingStock.objects.get_published(request.user).get( "type": "roster",
"item": RollingStock.objects.get_published(request.user).get(
uuid=r.rolling_stock_id uuid=r.rolling_stock_id
) ),
}
for r in consist.consist_item.all() for r in consist.consist_item.all()
) ]
paginator = Paginator(data, get_items_per_page()) paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page) data = paginator.get_page(page)
page_range = paginator.get_elided_page_range( page_range = paginator.get_elided_page_range(
@@ -540,11 +487,11 @@ class GetConsist(View):
class Manufacturers(GetData): class Manufacturers(GetData):
title = "Manufacturers" title = "Manufacturers"
item_type = "manufacturer"
def get_data(self, request): def get_data(self, request):
return ( return (
Manufacturer.objects.filter(self.filter) Manufacturer.objects.filter(self.filter).annotate(
.annotate(
num_rollingstock=( num_rollingstock=(
Count( Count(
"rollingstock", "rollingstock",
@@ -574,26 +521,7 @@ class Manufacturers(GetData):
) )
) )
) )
.annotate( .annotate(num_items=F("num_rollingstock") + F("num_rollingclass"))
num_catalogs=(
Count(
"catalogs",
filter=Q(
catalogs__in=(
Catalog.objects.get_published(request.user)
),
),
distinct=True,
)
)
)
.annotate(
num_items=(
F("num_rollingstock")
+ F("num_rollingclass")
+ F("num_catalogs")
)
)
.order_by("name") .order_by("name")
) )
@@ -608,6 +536,7 @@ class Manufacturers(GetData):
class Companies(GetData): class Companies(GetData):
title = "Companies" title = "Companies"
item_type = "company"
def get_data(self, request): def get_data(self, request):
return ( return (
@@ -646,6 +575,7 @@ class Companies(GetData):
class Scales(GetData): class Scales(GetData):
title = "Scales" title = "Scales"
item_type = "scale"
def get_data(self, request): def get_data(self, request):
return ( return (
@@ -662,25 +592,21 @@ class Scales(GetData):
num_consists=Count( num_consists=Count(
"consist", "consist",
filter=Q( filter=Q(
consist__in=Consist.objects.get_published(request.user) consist__in=Consist.objects.get_published(
request.user
)
), ),
distinct=True, distinct=True,
), ),
num_catalogs=Count("catalogs", distinct=True),
)
.annotate(
num_items=(
F("num_rollingstock")
+ F("num_consists")
+ F("num_catalogs")
)
) )
.annotate(num_items=F("num_rollingstock") + F("num_consists"))
.order_by("-ratio_int", "-tracks", "scale") .order_by("-ratio_int", "-tracks", "scale")
) )
class Types(GetData): class Types(GetData):
title = "Types" title = "Types"
item_type = "rolling_stock_type"
def get_data(self, request): def get_data(self, request):
return RollingStockType.objects.annotate( return RollingStockType.objects.annotate(
@@ -697,6 +623,7 @@ class Types(GetData):
class Books(GetData): class Books(GetData):
title = "Books" title = "Books"
item_type = "book"
def get_data(self, request): def get_data(self, request):
return Book.objects.get_published(request.user).all() return Book.objects.get_published(request.user).all()
@@ -704,82 +631,12 @@ class Books(GetData):
class Catalogs(GetData): class Catalogs(GetData):
title = "Catalogs" title = "Catalogs"
item_type = "catalog"
def get_data(self, request): def get_data(self, request):
return Catalog.objects.get_published(request.user).all() return Catalog.objects.get_published(request.user).all()
class Magazines(GetData):
title = "Magazines"
def get_data(self, request):
return (
Magazine.objects.get_published(request.user)
.order_by(Lower("name"))
.annotate(
issues=Count(
"issue",
filter=Q(
issue__in=(
MagazineIssue.objects.get_published(request.user)
)
),
)
)
)
class GetMagazine(View):
def get(self, request, uuid, page=1):
try:
magazine = Magazine.objects.get_published(request.user).get(
uuid=uuid
)
except ObjectDoesNotExist:
raise Http404
data = list(magazine.issue.get_published(request.user).all())
paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page)
page_range = paginator.get_elided_page_range(
data.number, on_each_side=1, on_ends=1
)
return render(
request,
"magazine.html",
{
"title": magazine,
"magazine": magazine,
"data": data,
"matches": paginator.count,
"page_range": page_range,
},
)
class GetMagazineIssue(View):
def get(self, request, uuid, magazine, page=1):
try:
issue = MagazineIssue.objects.get_published(request.user).get(
uuid=uuid,
magazine__uuid=magazine,
)
except ObjectDoesNotExist:
raise Http404
properties = issue.property.get_public(request.user)
documents = issue.document.get_public(request.user)
return render(
request,
"bookshelf/book.html",
{
"title": issue,
"data": issue,
"documents": documents,
"properties": properties,
},
)
class GetBookCatalog(View): class GetBookCatalog(View):
def get_object(self, request, uuid, selector): def get_object(self, request, uuid, selector):
if selector == "book": if selector == "book":
@@ -802,9 +659,10 @@ class GetBookCatalog(View):
"bookshelf/book.html", "bookshelf/book.html",
{ {
"title": book, "title": book,
"data": book, "book": book,
"documents": documents, "documents": documents,
"properties": properties, "properties": properties,
"type": selector,
}, },
) )

View File

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

32
ram/ram/db_router.py Normal file
View File

@@ -0,0 +1,32 @@
class TelemetryRouter:
db_table = "telemetry_10secs"
def db_for_read(self, model, **hints):
"""Send read operations to the correct database."""
if model._meta.db_table == self.db_table:
return "telemetry" # Replace with your database name
return None # Default database
def db_for_write(self, model, **hints):
"""Send write operations to the correct database."""
if model._meta.db_table == self.db_table:
return False # Prevent Django from writing RO tables
return None
def allow_relation(self, obj1, obj2, **hints):
"""
Allow relations if a model in the auth or contenttypes apps is
involved.
"""
if (
obj1._meta.db_table == self.db_table
or obj2._meta.db_table == self.db_table
):
return True
return None
def allow_migrate(self, db, app_label, model_name=None, **hints):
"""Prevent Django from migrating this model if it's using a specific database."""
if db == "telemetry":
return False # Prevent Django from creating/modifying tables
return None

View File

@@ -9,19 +9,6 @@ from ram.utils import DeduplicatedStorage, get_image_preview
from ram.managers import PublicManager from ram.managers import PublicManager
class SimpleBaseModel(models.Model):
class Meta:
abstract = True
@property
def obj_type(self):
return self._meta.model_name
@property
def obj_label(self):
return self._meta.object_name
class BaseModel(models.Model): class BaseModel(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False) uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False)
description = tinymce.HTMLField(blank=True) description = tinymce.HTMLField(blank=True)
@@ -33,14 +20,6 @@ class BaseModel(models.Model):
class Meta: class Meta:
abstract = True abstract = True
@property
def obj_type(self):
return self._meta.model_name
@property
def obj_label(self):
return self._meta.object_name
objects = PublicManager() objects = PublicManager()

View File

@@ -95,8 +95,16 @@ DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.sqlite3", "ENGINE": "django.db.backends.sqlite3",
"NAME": STORAGE_DIR / "db.sqlite3", "NAME": STORAGE_DIR / "db.sqlite3",
},
"telemetry": {
"ENGINE": "django.db.backends.postgresql",
"HOST": "127.0.0.1",
"NAME": "dccmonitor",
"USER": "dccmonitor",
"PASSWORD": "dccmonitor",
},
} }
} DATABASE_ROUTERS = ["ram.db_router.TelemetryRouter"]
# Password validation # Password validation
@@ -150,7 +158,7 @@ REST_FRAMEWORK = {
} }
TINYMCE_DEFAULT_CONFIG = { TINYMCE_DEFAULT_CONFIG = {
"height": "300px", "height": "500px",
"menubar": False, "menubar": False,
"plugins": "autolink lists link image charmap preview anchor " "plugins": "autolink lists link image charmap preview anchor "
"searchreplace visualblocks code fullscreen insertdatetime media " "searchreplace visualblocks code fullscreen insertdatetime media "
@@ -204,8 +212,6 @@ ROLLING_STOCK_TYPES = [
("other", "Other"), ("other", "Other"),
] ]
FEATURED_ITEMS_MAX = 6
try: try:
from ram.local_settings import * from ram.local_settings import *
except ImportError: except ImportError:

View File

@@ -1,65 +0,0 @@
# Generated by Django 6.0 on 2025-12-08 17:47
import django.db.models.deletion
import ram.utils
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0025_magazine_magazineissue"),
(
"repository",
"0003_alter_bookdocument_file_alter_catalogdocument_file_and_more",
),
]
operations = [
migrations.CreateModel(
name="MagazineIssueDocument",
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/"
),
),
("creation_time", models.DateTimeField(auto_now_add=True)),
("updated_time", models.DateTimeField(auto_now=True)),
(
"private",
models.BooleanField(
default=False,
help_text="Document will be visible only to logged users",
),
),
(
"issue",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="document",
to="bookshelf.magazineissue",
),
),
],
options={
"verbose_name_plural": "Magazines documents",
"constraints": [
models.UniqueConstraint(
fields=("issue", "file"), name="unique_issue_file"
)
],
},
),
]

View File

@@ -1,11 +1,12 @@
from django.db import models from django.db import models
from django.core.exceptions import ValidationError
from tinymce import models as tinymce from tinymce import models as tinymce
from ram.models import PrivateDocument from ram.models import PrivateDocument
from metadata.models import Decoder, Shop, Tag from metadata.models import Decoder, Shop, Tag
from roster.models import RollingStock from roster.models import RollingStock
from bookshelf.models import Book, Catalog, MagazineIssue from bookshelf.models import Book, Catalog
class GenericDocument(PrivateDocument): class GenericDocument(PrivateDocument):
@@ -76,20 +77,6 @@ class CatalogDocument(PrivateDocument):
] ]
class MagazineIssueDocument(PrivateDocument):
issue = models.ForeignKey(
MagazineIssue, on_delete=models.CASCADE, related_name="document"
)
class Meta:
verbose_name_plural = "Magazines documents"
constraints = [
models.UniqueConstraint(
fields=["issue", "file"], name="unique_issue_file"
)
]
class RollingStockDocument(PrivateDocument): class RollingStockDocument(PrivateDocument):
rolling_stock = models.ForeignKey( rolling_stock = models.ForeignKey(
RollingStock, on_delete=models.CASCADE, related_name="document" RollingStock, on_delete=models.CASCADE, related_name="document"

View File

@@ -17,6 +17,7 @@ from roster.models import (
RollingStockImage, RollingStockImage,
RollingStockProperty, RollingStockProperty,
RollingStockJournal, RollingStockJournal,
RollingStockTelemetry,
) )
@@ -44,9 +45,7 @@ class RollingClass(admin.ModelAdmin):
@admin.display(description="Country") @admin.display(description="Country")
def country_flag(self, obj): def country_flag(self, obj):
return format_html( return format_html(
'<img src="{}" title="{}" />', '<img src="{}" /> {}', obj.country.flag, obj.country.name
obj.company.country.flag,
obj.company.country.name,
) )
@@ -130,12 +129,9 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
"item_number", "item_number",
"company", "company",
"country_flag", "country_flag",
"featured",
"published", "published",
) )
list_filter = ( list_filter = (
"featured",
"published",
"rolling_class__type__category", "rolling_class__type__category",
"rolling_class__type", "rolling_class__type",
"rolling_class__company__name", "rolling_class__company__name",
@@ -157,7 +153,7 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
@admin.display(description="Country") @admin.display(description="Country")
def country_flag(self, obj): def country_flag(self, obj):
return format_html( return format_html(
'<img src="{}" title="{}" />', obj.country.flag, obj.country.name '<img src="{}" /> {}', obj.country.flag, obj.country.name
) )
fieldsets = ( fieldsets = (
@@ -167,7 +163,6 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
"fields": ( "fields": (
"preview", "preview",
"published", "published",
"featured",
"rolling_class", "rolling_class",
"road_number", "road_number",
"scale", "scale",
@@ -230,8 +225,8 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
if obj.invoice.exists(): if obj.invoice.exists():
html = format_html_join( html = format_html_join(
"<br>", "<br>",
'<a href="{}" target="_blank">{}</a>', "<a href=\"{}\" target=\"_blank\">{}</a>",
((i.file.url, i) for i in obj.invoice.all()), ((i.file.url, i) for i in obj.invoice.all())
) )
else: else:
html = "-" html = "-"
@@ -302,38 +297,30 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
return generate_csv(header, data, "rolling_stock.csv") return generate_csv(header, data, "rolling_stock.csv")
download_csv.short_description = "Download selected items as CSV" download_csv.short_description = "Download selected items as CSV"
actions = [publish, unpublish, download_csv]
def set_featured(modeladmin, request, queryset):
count = queryset.count()
if count > settings.FEATURED_ITEMS_MAX:
modeladmin.message_user(
request,
"You can only mark up to {} items as featured.".format(
settings.FEATURED_ITEMS_MAX
),
level="error",
)
return
featured = RollingStock.objects.filter(featured=True).count()
if featured + count > settings.FEATURED_ITEMS_MAX:
modeladmin.message_user(
request,
"There are already {} featured items. You can only mark {} more items as featured.".format( # noqa: E501
featured,
settings.FEATURED_ITEMS_MAX - featured,
),
level="error",
)
return
queryset.update(featured=True)
set_featured.short_description = "Mark selected rolling stock as featured" @admin.register(RollingStockTelemetry)
class RollingTelemtryAdmin(admin.ModelAdmin):
list_filter = ("bucket", "cab")
list_display = ("bucket_highres", "cab", "max_speed", "avg_speed")
def unset_featured(modeladmin, request, queryset): def bucket_highres(self, obj):
queryset.update(featured=False) return obj.bucket.strftime("%Y-%m-%d %H:%M:%S")
unset_featured.short_description = ( bucket_highres.admin_order_field = "bucket" # Enable sorting
"Unmark selected rolling stock as featured" bucket_highres.short_description = "Bucket" # Column name in admin
)
actions = [publish, unpublish, set_featured, unset_featured, download_csv] def get_changelist_instance(self, request):
changelist = super().get_changelist_instance(request)
changelist.list_display_links = None # Disable links
return changelist
def has_add_permission(self, request):
return False # Disable adding new objects
def has_change_permission(self, request, obj=None):
return False # Disable editing objects
def has_delete_permission(self, request, obj=None):
return False # Disable deleting objects

View File

@@ -1,21 +0,0 @@
# Generated by Django 6.0 on 2025-12-24 13:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("roster", "0038_alter_rollingstock_rolling_class"),
]
operations = [
migrations.AddField(
model_name="rollingstock",
name="featured",
field=models.BooleanField(
default=False,
help_text="Featured rolling stock will appear on the homepage",
),
),
]

View File

@@ -0,0 +1,32 @@
# Generated by Django 6.0 on 2025-12-07 18:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("roster", "0038_alter_rollingstock_rolling_class"),
]
operations = [
migrations.CreateModel(
name="RollingStockTelemetry",
fields=[
(
"bucket",
models.DateTimeField(
editable=False, primary_key=True, serialize=False
),
),
("cab", models.PositiveIntegerField(editable=False)),
("avg_speed", models.FloatField(editable=False)),
("max_speed", models.PositiveIntegerField(editable=False)),
],
options={
"verbose_name_plural": "Telemetries",
"db_table": "telemetry_10secs",
"ordering": ["cab", "bucket"],
},
),
]

View File

@@ -5,7 +5,6 @@ from django.db import models
from django.urls import reverse from django.urls import reverse
from django.conf import settings from django.conf import settings
from django.dispatch import receiver from django.dispatch import receiver
from django.core.exceptions import ValidationError
from tinymce import models as tinymce from tinymce import models as tinymce
@@ -83,7 +82,9 @@ class RollingStock(BaseModel):
help_text="Catalog item number or code", help_text="Catalog item number or code",
) )
item_number_slug = models.CharField( item_number_slug = models.CharField(
max_length=32, blank=True, editable=False max_length=32,
blank=True,
editable=False
) )
set = models.BooleanField( set = models.BooleanField(
default=False, default=False,
@@ -112,10 +113,6 @@ class RollingStock(BaseModel):
null=True, null=True,
blank=True, blank=True,
) )
featured = models.BooleanField(
default=False,
help_text="Featured rolling stock will appear on the homepage",
)
tags = models.ManyToManyField( tags = models.ManyToManyField(
Tag, related_name="rolling_stock", blank=True Tag, related_name="rolling_stock", blank=True
) )
@@ -168,18 +165,10 @@ class RollingStock(BaseModel):
os.path.join( os.path.join(
settings.MEDIA_ROOT, "images", "rollingstock", str(self.uuid) settings.MEDIA_ROOT, "images", "rollingstock", str(self.uuid)
), ),
ignore_errors=True, ignore_errors=True
) )
super(RollingStock, self).delete(*args, **kwargs) super(RollingStock, self).delete(*args, **kwargs)
def clean(self, *args, **kwargs):
if self.featured:
MAX = settings.FEATURED_ITEMS_MAX
if RollingStock.objects.filter(featured=True).count() > MAX - 1:
raise ValidationError(
"There are already {} featured items".format(MAX)
)
@receiver(models.signals.pre_save, sender=RollingStock) @receiver(models.signals.pre_save, sender=RollingStock)
def pre_save_internal_fields(sender, instance, *args, **kwargs): def pre_save_internal_fields(sender, instance, *args, **kwargs):
@@ -196,7 +185,10 @@ def pre_save_internal_fields(sender, instance, *args, **kwargs):
def rolling_stock_image_upload(instance, filename): def rolling_stock_image_upload(instance, filename):
return os.path.join( return os.path.join(
"images", "rollingstock", str(instance.rolling_stock.uuid), filename "images",
"rollingstock",
str(instance.rolling_stock.uuid),
filename
) )
@@ -246,6 +238,20 @@ class RollingStockJournal(models.Model):
objects = PublicManager() objects = PublicManager()
# trick: this is technically an abstract class
# it is made readonly via db_router and admin to avoid any unwanted change
class RollingStockTelemetry(models.Model):
bucket = models.DateTimeField(primary_key=True, editable=False)
cab = models.PositiveIntegerField(editable=False)
avg_speed = models.FloatField(editable=False)
max_speed = models.PositiveIntegerField(editable=False)
class Meta:
db_table = "telemetry_10secs"
ordering = ["cab", "bucket"]
verbose_name_plural = "Telemetries"
# @receiver(models.signals.post_delete, sender=Cab) # @receiver(models.signals.post_delete, sender=Cab)
# def post_save_image(sender, instance, *args, **kwargs): # def post_save_image(sender, instance, *args, **kwargs):
# try: # try:

View File

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

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -1,12 +0,0 @@
#!/bin/bash
mkdir -p output
for img in input/*.{jpg,png}; do
[ -e "$img" ] || continue # skip if no files
name=$(basename "${img%.*}").jpg
magick convert background.png \
\( "$img" -resize x820 \) \
-gravity center -composite \
-quality 85 -sampling-factor 4:4:4 \
"output/$name"
done