29 Commits

Author SHA1 Message Date
955397acd5 Update site icon 2025-06-01 21:51:51 +02:00
672cadd7e1 Introduce symbols legend 2025-05-28 23:38:02 +02:00
464fe57536 Fix an abbr in DCC 2025-05-27 23:57:21 +02:00
bd16c7eee7 Still improve the DCC section 2025-05-27 23:49:22 +02:00
cc2e374558 Simplify cards, use icons for DCC 2025-05-26 00:04:07 +02:00
1c25ac9b14 Minor UI improvements 2025-05-25 19:13:28 +02:00
de126a735d Reverse field renaming 2025-05-24 15:00:52 +02:00
18b5ab8053 Bump DCC-EX submodule 2025-05-24 14:52:36 +02:00
3acc80e2ad Fix another counting issue 2025-05-24 14:44:42 +02:00
552ba39970 Upgrade bootstrap to 5.3.6 and icons to 1.13.1 2025-05-12 14:06:37 +02:00
222e2075ec Change the behavior of the modal 2025-05-12 13:51:31 +02:00
b5c57dcd94 Rely on slugs to have a more natural sorting 2025-05-04 22:46:23 +02:00
b81c63898f Add more information in consist_item rows 2025-05-04 22:05:47 +02:00
76b266b1f9 Extend search on company to slug field to better manage accented and utf names 2025-05-04 19:13:43 +02:00
86657a3b9f Manually fix a migration to correctly bootsrap a new DB 2025-05-04 19:12:50 +02:00
d0d25424fb Fix road_number_int field sizing 2025-05-04 19:12:05 +02:00
292b95b8ed Fix a bug in roster search via scale 2025-05-03 21:02:44 +02:00
dea7a594bc Implement CSV export for cosists 2025-05-02 22:25:59 +02:00
60195bc99f Simplify the logic about scales in the consist and remove async updates 2025-05-02 13:40:09 +02:00
7673f0514a Fix filter by scale counters and add consist constrains
Still to be improved, see FIXME
2025-05-01 23:49:22 +02:00
40f42a9ee9 Reformat portal/views.py 2025-04-30 22:52:51 +02:00
2e06e94fde Add counters to cards 2025-04-30 22:50:43 +02:00
ece8d1ad94 Minor UI improvement 2025-04-29 22:34:46 +02:00
e9ec126ada Fix a bug in the consist admin search 2025-04-27 22:22:47 +02:00
1222116874 Improve consist counter, fix a bug with unpublished stock 2025-04-27 22:12:43 +02:00
85741f090c Provide consist composition 2025-04-27 18:22:13 +02:00
88d718fa94 Minor footer improvement on large screens 2025-04-26 00:16:09 +02:00
a2c857a3cd Fix a couple of bugs 2025-04-25 23:14:10 +02:00
647894bca7 Add country flag to cards 2025-03-20 22:07:12 +01:00
54 changed files with 667 additions and 600 deletions

View File

@@ -1,129 +0,0 @@
# 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 the `<l cab reg speedByte functMap>` one 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);
```

View File

@@ -1,36 +0,0 @@
# -*- 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

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

View File

@@ -1,107 +0,0 @@
#!/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()

View File

@@ -1,87 +0,0 @@
#!/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

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

View File

@@ -29,6 +29,10 @@ class Migration(migrations.Migration):
]
operations = [
migrations.RemoveConstraint(
model_name="basebookdocument",
name="unique_book_file",
),
migrations.AddField(
model_name="basebook",
name="shop",

View File

@@ -137,6 +137,10 @@ class Catalog(BaseBook):
ordering = ["manufacturer", "publication_year"]
def __str__(self):
# if the object is new, return an empty string to avoid
# calling self.scales.all() which would raise a infinite recursion
if self.pk is None:
return str() # empty string
scales = self.get_scales()
return "%s %s %s" % (self.manufacturer.name, self.years, scales)

View File

@@ -1,11 +1,26 @@
import html
from django.conf import settings
from django.contrib import admin
from django.utils.html import format_html
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
# from django.forms import BaseInlineFormSet # for future reference
from django.utils.html import format_html, strip_tags
from adminsortable2.admin import (
SortableAdminBase,
SortableInlineAdminMixin,
# CustomInlineFormSetMixin, # for future reference
)
from ram.admin import publish, unpublish
from ram.utils import generate_csv
from consist.models import Consist, ConsistItem
# for future reference
# class ConsistItemInlineFormSet(CustomInlineFormSetMixin, BaseInlineFormSet):
# def clean(self):
# super().clean()
class ConsistItemInline(SortableInlineAdminMixin, admin.TabularInline):
model = ConsistItem
min_num = 1
@@ -14,10 +29,13 @@ class ConsistItemInline(SortableInlineAdminMixin, admin.TabularInline):
readonly_fields = (
"preview",
"published",
"address",
"type",
"scale",
"manufacturer",
"item_number",
"company",
"type",
"era",
"address",
)
@@ -28,7 +46,7 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
"creation_time",
"updated_time",
)
list_filter = ("company", "era", "published")
list_filter = ("company__name", "era", "scale", "published")
list_display = ("__str__",) + list_filter + ("country_flag",)
search_fields = ("identifier",) + list_filter
save_as = True
@@ -46,9 +64,10 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
"fields": (
"published",
"identifier",
"consist_address",
"company",
"scale",
"era",
"consist_address",
"description",
"image",
"tags",
@@ -70,4 +89,55 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
},
),
)
actions = [publish, unpublish]
def download_csv(modeladmin, request, queryset):
header = [
"ID",
"Name",
"Published",
"Company",
"Country",
"Address",
"Scale",
"Era",
"Description",
"Tags",
"Length",
"Composition",
"Item name",
"Item type",
"Item ID",
]
data = []
for obj in queryset:
for item in obj.consist_item.all():
types = " + ".join(
"{}x {}".format(t["count"], t["type"])
for t in obj.get_type_count()
)
data.append(
[
obj.uuid,
obj.__str__(),
"X" if obj.published else "",
obj.company.name,
obj.company.country,
obj.consist_address,
obj.scale.scale,
obj.era,
html.unescape(strip_tags(obj.description)),
settings.CSV_SEPARATOR_ALT.join(
t.name for t in obj.tags.all()
),
obj.length,
types,
item.rolling_stock.__str__(),
item.type,
item.rolling_stock.uuid,
]
)
return generate_csv(header, data, "consists.csv")
download_csv.short_description = "Download selected items as CSV"
actions = [publish, unpublish, download_csv]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-04-27 19:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("consist", "0015_consist_description"),
]
operations = [
migrations.AlterField(
model_name="consistitem",
name="order",
field=models.PositiveIntegerField(),
),
]

View File

@@ -0,0 +1,42 @@
# Generated by Django 5.1.4 on 2025-05-01 09:51
import django.db.models.deletion
from django.db import migrations, models
def set_scale(apps, schema_editor):
Consist = apps.get_model("consist", "Consist")
for consist in Consist.objects.all():
try:
consist.scale = consist.consist_item.first().rolling_stock.scale
consist.save()
except AttributeError:
pass
class Migration(migrations.Migration):
dependencies = [
("consist", "0016_alter_consistitem_order"),
(
"metadata",
"0024_remove_genericdocument_tags_delete_decoderdocument_and_more",
),
]
operations = [
migrations.AddField(
model_name="consist",
name="scale",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="metadata.scale",
),
),
migrations.RunPython(
set_scale,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.1.4 on 2025-05-02 11:33
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("consist", "0017_consist_scale"),
(
"metadata",
"0024_remove_genericdocument_tags_delete_decoderdocument_and_more",
),
]
operations = [
migrations.AlterField(
model_name="consist",
name="scale",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="metadata.scale"
),
),
]

View File

@@ -2,12 +2,13 @@ import os
from django.db import models
from django.urls import reverse
from django.utils.text import Truncator
from django.dispatch import receiver
from django.core.exceptions import ValidationError
from ram.models import BaseModel
from ram.utils import DeduplicatedStorage
from metadata.models import Company, Tag
from metadata.models import Company, Scale, Tag
from roster.models import RollingStock
@@ -26,6 +27,7 @@ class Consist(BaseModel):
blank=True,
help_text="Era or epoch of the consist",
)
scale = models.ForeignKey(Scale, on_delete=models.CASCADE)
image = models.ImageField(
upload_to=os.path.join("images", "consists"),
storage=DeduplicatedStorage,
@@ -39,16 +41,25 @@ class Consist(BaseModel):
def get_absolute_url(self):
return reverse("consist", kwargs={"uuid": self.uuid})
@property
def length(self):
return self.consist_item.count()
def get_type_count(self):
return self.consist_item.annotate(
type=models.F("rolling_stock__rolling_class__type__type")
).values(
"type"
).annotate(
count=models.Count("rolling_stock"),
category=models.F("rolling_stock__rolling_class__type__category"),
order=models.Max("order"),
).order_by("order")
@property
def country(self):
return self.company.country
def clean(self):
if self.consist_item.filter(rolling_stock__published=False).exists():
raise ValidationError(
"You must publish all items in the consist before publishing the consist." # noqa: E501
)
class Meta:
ordering = ["company", "-creation_time"]
@@ -58,11 +69,7 @@ class ConsistItem(models.Model):
Consist, on_delete=models.CASCADE, related_name="consist_item"
)
rolling_stock = models.ForeignKey(RollingStock, on_delete=models.CASCADE)
order = models.PositiveIntegerField(
default=1000, # make sure it is always added at the end
blank=False,
null=False
)
order = models.PositiveIntegerField(blank=False, null=False)
class Meta:
ordering = ["order"]
@@ -76,6 +83,24 @@ class ConsistItem(models.Model):
def __str__(self):
return "{0}".format(self.rolling_stock)
def clean(self):
rolling_stock = getattr(self, "rolling_stock", False)
if not rolling_stock:
return # exit if no inline are present
# FIXME this does not work when creating a new consist,
# because the consist is not saved yet and it must be moved
# to the admin form validation via InlineFormSet.clean()
consist = self.consist
if rolling_stock.scale != consist.scale:
raise ValidationError(
"The rolling stock and consist must be of the same scale."
)
if self.consist.published and not rolling_stock.published:
raise ValidationError(
"You must unpublish the the consist before using this item."
)
def published(self):
return self.rolling_stock.published
published.boolean = True
@@ -83,9 +108,21 @@ class ConsistItem(models.Model):
def preview(self):
return self.rolling_stock.image.first().image_thumbnail(100)
@property
def manufacturer(self):
return Truncator(self.rolling_stock.manufacturer).chars(10)
@property
def item_number(self):
return self.rolling_stock.item_number
@property
def scale(self):
return self.rolling_stock.scale
@property
def type(self):
return self.rolling_stock.rolling_class.type
return self.rolling_stock.rolling_class.type.type
@property
def address(self):

View File

@@ -0,0 +1,28 @@
# Generated by Django 5.1.4 on 2025-05-04 20:45
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
(
"metadata",
"0024_remove_genericdocument_tags_delete_decoderdocument_and_more",
),
]
operations = [
migrations.AlterModelOptions(
name="company",
options={"ordering": ["slug"], "verbose_name_plural": "Companies"},
),
migrations.AlterModelOptions(
name="manufacturer",
options={"ordering": ["category", "slug"]},
),
migrations.AlterModelOptions(
name="tag",
options={"ordering": ["slug"]},
),
]

View File

@@ -43,7 +43,7 @@ class Manufacturer(models.Model):
)
class Meta:
ordering = ["category", "name"]
ordering = ["category", "slug"]
def __str__(self):
return self.name
@@ -78,7 +78,7 @@ class Company(models.Model):
class Meta:
verbose_name_plural = "Companies"
ordering = ["name"]
ordering = ["slug"]
def __str__(self):
return self.name
@@ -207,7 +207,7 @@ class Tag(models.Model):
slug = models.CharField(max_length=128, unique=True)
class Meta:
ordering = ["name"]
ordering = ["slug"]
def __str__(self):
return self.name

View File

@@ -1,5 +1,5 @@
/*!
* Bootstrap Icons v1.11.3 (https://icons.getbootstrap.com/)
* Bootstrap Icons v1.13.1 (https://icons.getbootstrap.com/)
* Copyright 2019-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE)
*/
@@ -7,8 +7,8 @@
@font-face {
font-display: block;
font-family: "bootstrap-icons";
src: url("./fonts/bootstrap-icons.woff2?dd67030699838ea613ee6dbda90effa6") format("woff2"),
url("./fonts/bootstrap-icons.woff?dd67030699838ea613ee6dbda90effa6") format("woff");
src: url("./fonts/bootstrap-icons.woff2?e34853135f9e39acf64315236852cd5a") format("woff2"),
url("./fonts/bootstrap-icons.woff?e34853135f9e39acf64315236852cd5a") format("woff");
}
.bi::before,
@@ -2076,3 +2076,31 @@ url("./fonts/bootstrap-icons.woff?dd67030699838ea613ee6dbda90effa6") format("wof
.bi-suitcase2-fill::before { content: "\f901"; }
.bi-suitcase2::before { content: "\f902"; }
.bi-vignette::before { content: "\f903"; }
.bi-bluesky::before { content: "\f7f9"; }
.bi-tux::before { content: "\f904"; }
.bi-beaker-fill::before { content: "\f905"; }
.bi-beaker::before { content: "\f906"; }
.bi-flask-fill::before { content: "\f907"; }
.bi-flask-florence-fill::before { content: "\f908"; }
.bi-flask-florence::before { content: "\f909"; }
.bi-flask::before { content: "\f90a"; }
.bi-leaf-fill::before { content: "\f90b"; }
.bi-leaf::before { content: "\f90c"; }
.bi-measuring-cup-fill::before { content: "\f90d"; }
.bi-measuring-cup::before { content: "\f90e"; }
.bi-unlock2-fill::before { content: "\f90f"; }
.bi-unlock2::before { content: "\f910"; }
.bi-battery-low::before { content: "\f911"; }
.bi-anthropic::before { content: "\f912"; }
.bi-apple-music::before { content: "\f913"; }
.bi-claude::before { content: "\f914"; }
.bi-openai::before { content: "\f915"; }
.bi-perplexity::before { content: "\f916"; }
.bi-css::before { content: "\f917"; }
.bi-javascript::before { content: "\f918"; }
.bi-typescript::before { content: "\f919"; }
.bi-fork-knife::before { content: "\f91a"; }
.bi-globe-americas-fill::before { content: "\f91b"; }
.bi-globe-asia-australia-fill::before { content: "\f91c"; }
.bi-globe-central-south-asia-fill::before { content: "\f91d"; }
.bi-globe-europe-africa-fill::before { content: "\f91e"; }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -1,9 +1,7 @@
<svg width="26" height="16" enable-background="new 0 0 26 26" version="1" viewBox="0 0 26 16" xmlns="http://www.w3.org/2000/svg">
<path d="m2.8125 0.0010991a1.0001 1.0001 0 0 0-0.8125 1c0 0.55455-0.44545 1-1 1a1.0001 1.0001 0 0 0-1 1v10a1.0001 1.0001 0 0 0 1 1c0.55455 0 1 0.44546 1 1a1.0001 1.0001 0 0 0 1 1h20a1.0001 1.0001 0 0 0 1-1c0-0.55454 0.44546-1 1-1a1.0001 1.0001 0 0 0 1-1v-10a1.0001 1.0001 0 0 0-1-1c-0.55454 0-1-0.44545-1-1a1.0001 1.0001 0 0 0-1-1h-20a1.0001 1.0001 0 0 0-0.09375 0 1.0001 1.0001 0 0 0-0.09375 0zm0.78125 2h14.406v1h2v-1h2.4062c0.30628 0.76906 0.82469 1.2875 1.5938 1.5938v8.8125c-0.76906 0.30628-1.2875 0.82469-1.5938 1.5938h-2.4062v-1h-2v1h-14.406c-0.30628-0.76906-0.82469-1.2875-1.5938-1.5938v-8.8125c0.76906-0.30628 1.2875-0.82469 1.5938-1.5938zm14.406 2v2h2v-2zm0 3v2h2v-2zm0 3v2h2v-2z" enable-background="accumulate" overflow="visible" stroke-width="2" />
<style>
path {
text-indent:0;
text-transform:none;
}
</style>
</svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="32" height="16" preserveAspectRatio="xMidYMid" version="1.0" viewBox="0 0 24 12" xmlns="http://www.w3.org/2000/svg">
<metadata>Created by potrace 1.15, written by Peter Selinger 2001-2017</metadata>
<g transform="matrix(.0039261 0 0 -.0039261 -1.4249 18.53)">
<path d="m813 4723-103-4v-309h-355l14-330h369l6-42c39-273 39-1414 0-1659l-7-39h-368l-14-330h355v-318l41-7c23-4 126-7 229-7s206 3 229 7l41 7v318h670v-318l41-7c23-4 126-7 229-7s206 3 229 7l41 7v318h670v-318l37-7c48-9 432-9 472 0l31 7v318h680v-318l31-7c39-9 423-9 469 0l35 6v314l338 3 337 2v-318l38-7c48-9 416-9 465 0l37 7v318h335v2400h-335v307l-135 6c-74 3-196 3-270 0l-135-6v-307l-337 2-338 3v302l-132 6c-73 3-194 3-268 0l-135-6v-307h-680v307l-135 6c-74 3-196 3-270 0l-135-6v-307h-670v307l-135 6c-74 3-196 3-270 0l-135-6v-307h-670v310h-63c-35 0-111 2-168 4s-150 1-206-1zm1141-666c3-12 11-97 18-187 24-309 11-1402-18-1507l-6-23h-725l-7 32c-39 197-39 1454 0 1676l6 32h726zm1218-42c20-182 30-569 25-940-6-371-21-707-33-727-3-4-169-8-368-8h-363l-7 48c-38 277-38 1365 1 1647l6 45 366-2 366-3zm1203 53c39-103 45-1264 9-1660l-7-68h-735l-6 68c-35 381-35 1263 0 1610l6 62h364c283 0 366-3 369-12zm1219-42c37-316 37-1287 0-1628l-7-58h-734l-6 73c-37 424-31 1544 8 1655 3 9 86 12 368 12h364zm841-1686c-336 0-363 1-370 18-3 9-13 152-22 317-21 431-7 1292 23 1388 5 16 31 17 369 17z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -16,11 +16,11 @@
<link rel="icon" href="{% static "favicon.png" %}" sizes="any">
<link rel="icon" href="{% static "favicon.svg" %}" type="image/svg+xml">
{% if site_conf.use_cdn %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.css" rel="stylesheet">
{% else %}
<link href="{% static "bootstrap@5.3.3/dist/css/bootstrap.min.css" %}" rel="stylesheet">
<link href="{% static "bootstrap-icons@1.11.3/font/bootstrap-icons.css" %}" rel="stylesheet">
<link href="{% static "bootstrap@5.3.6/dist/css/bootstrap.min.css" %}" rel="stylesheet">
<link href="{% static "bootstrap-icons@1.13.1/font/bootstrap-icons.css" %}" rel="stylesheet">
{% endif %}
<link href="{% static "css/main.css" %}?v={{ site_conf.version }}" rel="stylesheet">
<style>
@@ -140,14 +140,10 @@
<div class="container d-flex">
<div class="me-auto">
<a href="{% url 'index' %}" class="navbar-brand d-flex align-items-center">
<svg class="me-2" width="26" height="16" viewBox="0 0 26 16" xmlns="http://www.w3.org/2000/svg">
<path d="m2.8125 0.0010991a1.0001 1.0001 0 0 0-0.8125 1c0 0.55455-0.44545 1-1 1a1.0001 1.0001 0 0 0-1 1v10a1.0001 1.0001 0 0 0 1 1c0.55455 0 1 0.44546 1 1a1.0001 1.0001 0 0 0 1 1h20a1.0001 1.0001 0 0 0 1-1c0-0.55454 0.44546-1 1-1a1.0001 1.0001 0 0 0 1-1v-10a1.0001 1.0001 0 0 0-1-1c-0.55454 0-1-0.44545-1-1a1.0001 1.0001 0 0 0-1-1h-20a1.0001 1.0001 0 0 0-0.09375 0 1.0001 1.0001 0 0 0-0.09375 0zm0.78125 2h14.406v1h2v-1h2.4062c0.30628 0.76906 0.82469 1.2875 1.5938 1.5938v8.8125c-0.76906 0.30628-1.2875 0.82469-1.5938 1.5938h-2.4062v-1h-2v1h-14.406c-0.30628-0.76906-0.82469-1.2875-1.5938-1.5938v-8.8125c0.76906-0.30628 1.2875-0.82469 1.5938-1.5938zm14.406 2v2h2v-2zm0 3v2h2v-2zm0 3v2h2v-2z" stroke-width="2" />
<style>
path {
text-indent:0;
text-transform:none;
}
</style>
<svg class="me-2" width="32" height="16" version="1.0" viewBox="0 0 24 12" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(.0039261 0 0 -.0039261 -1.4249 18.53)">
<path d="m813 4723-103-4v-309h-355l14-330h369l6-42c39-273 39-1414 0-1659l-7-39h-368l-14-330h355v-318l41-7c23-4 126-7 229-7s206 3 229 7l41 7v318h670v-318l41-7c23-4 126-7 229-7s206 3 229 7l41 7v318h670v-318l37-7c48-9 432-9 472 0l31 7v318h680v-318l31-7c39-9 423-9 469 0l35 6v314l338 3 337 2v-318l38-7c48-9 416-9 465 0l37 7v318h335v2400h-335v307l-135 6c-74 3-196 3-270 0l-135-6v-307l-337 2-338 3v302l-132 6c-73 3-194 3-268 0l-135-6v-307h-680v307l-135 6c-74 3-196 3-270 0l-135-6v-307h-670v307l-135 6c-74 3-196 3-270 0l-135-6v-307h-670v310h-63c-35 0-111 2-168 4s-150 1-206-1zm1141-666c3-12 11-97 18-187 24-309 11-1402-18-1507l-6-23h-725l-7 32c-39 197-39 1454 0 1676l6 32h726zm1218-42c20-182 30-569 25-940-6-371-21-707-33-727-3-4-169-8-368-8h-363l-7 48c-38 277-38 1365 1 1647l6 45 366-2 366-3zm1203 53c39-103 45-1264 9-1660l-7-68h-735l-6 68c-35 381-35 1263 0 1610l6 62h364c283 0 366-3 369-12zm1219-42c37-316 37-1287 0-1628l-7-58h-734l-6 73c-37 424-31 1544 8 1655 3 9 86 12 368 12h364zm841-1686c-336 0-363 1-370 18-3 9-13 152-22 317-21 431-7 1292 23 1388 5 16 31 17 369 17z"/>
</g>
</svg>
<strong>{{ site_conf.site_name }}</strong>
</a>
@@ -215,12 +211,13 @@
<div class="container">{% block pagination %}{% endblock %}</div>
</div>
{% block extra_content %}{% endblock %}
{% include 'includes/symbols.html' %}
</main>
{% include 'includes/footer.html' %}
{% if site_conf.use_cdn %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js"></script>
{% else %}
<script src="{% static "bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" %}"></script>
<script src="{% static "bootstrap@5.3.6/dist/js/bootstrap.bundle.min.js" %}"></script>
{% endif %}
</body>
</html>

View File

@@ -9,6 +9,9 @@
{% endfor %}
</p>
{% endif %}
{% if not book.published %}
<span class="badge text-bg-warning">Unpublished</span> |
{% endif %}
<small class="text-body-secondary">Updated {{ book.updated_time | date:"M d, Y H:i" }}</small>
{% endblock %}
{% block carousel %}
@@ -60,11 +63,6 @@
<th colspan="2" scope="row">
{% if type == "catalog" %}Catalog
{% elif type == "book" %}Book{% endif %}
<div class="float-end">
{% if not book.published %}
<span class="badge text-bg-warning">Draft</span>
{% endif %}
</div>
</th>
</tr>
</thead>

View File

@@ -28,7 +28,7 @@
{% elif d.type == "book" %}Book{% endif %}
<div class="float-end">
{% if not d.item.published %}
<span class="badge text-bg-warning">Draft</span>
<span class="badge text-bg-warning">Unpublished</span>
{% endif %}
</div>
</th>

View File

@@ -39,8 +39,10 @@
</tbody>
</table>
<div class="d-grid gap-2 mb-1 d-md-block">
<a class="btn btn-sm btn-outline-primary" href="{% url 'filtered' _filter="company" search=d.item.slug %}">Show all rolling stock</a>
{% with items=d.item.num_items %}
<a class="btn btn-sm btn-outline-primary{% if items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="company" search=d.item.slug %}">Show {{ items }} item{{ items | pluralize}}</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_company_change' d.item.pk %}">Edit</a>{% endif %}
{% endwith %}
</div>
</div>
</div>

View File

@@ -27,12 +27,12 @@
<th colspan="2" scope="row">
Consist
<div class="float-end">
{% if not d.item.published %}
<span class="badge text-bg-warning">Unpublished</span>
{% endif %}
{% if d.item.company.freelance %}
<span class="badge text-bg-secondary">Freelance</span>
{% endif %}
{% if not d.item.published %}
<span class="badge text-bg-warning">Draft</span>
{% endif %}
</div>
</th>
</tr>
@@ -46,7 +46,10 @@
{% endif %}
<tr>
<th class="w-33" scope="row">Company</th>
<td><abbr title="{{ d.item.company.extended_name }}">{{ d.item.company }}</abbr></td>
<td>
<img src="{{ d.item.company.country.flag }}" alt="{{ d.item.company.country }}">
<abbr title="{{ d.item.company.extended_name }}">{{ d.item.company }}</abbr>
</td>
</tr>
<tr>
<th scope="row">Era</th>
@@ -54,7 +57,7 @@
</tr>
<tr>
<th scope="row">Length</th>
<td>{{ d.item.consist_item.count }}</td>
<td>{{ d.item.length }}</td>
</tr>
</tbody>
</table>

View File

@@ -30,8 +30,10 @@
</tbody>
</table>
<div class="d-grid gap-2 mb-1 d-md-block">
<a class="btn btn-sm btn-outline-primary" href="{% url 'filtered' _filter="manufacturer" search=d.item.slug %}">Show all rolling stock</a>
{% with items=d.item.num_items %}
<a class="btn btn-sm btn-outline-primary{% if items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="manufacturer" search=d.item.slug %}">Show {{ items }} item{{ items | pluralize}}</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_manufacturer_change' d.item.pk %}">Edit</a>{% endif %}
{% endwith %}
</div>
</div>
</div>

View File

@@ -20,8 +20,10 @@
</tbody>
</table>
<div class="d-grid gap-2 mb-1 d-md-block">
<a class="btn btn-sm btn-outline-primary" href="{% url 'filtered' _filter="type" search=d.item.slug %}">Show all rolling stock</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_scale_change' d.item.pk %}">Edit</a>{% endif %}
{% with items=d.item.num_items %}
<a class="btn btn-sm btn-outline-primary{% if items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="type" search=d.item.slug %}">Show {{ items }} item{{ items | pluralize}}</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_rollingstocktype_change' d.item.pk %}">Edit</a>{% endif %}
{% endwith %}
</div>
</div>
</div>

View File

@@ -1,4 +1,6 @@
{% load static %}
{% load dcc %}
<div class="col">
<div class="card shadow-sm">
{% if d.item.image.exists %}
@@ -25,12 +27,12 @@
<th colspan="2" scope="row">
Rolling stock
<div class="float-end">
{% if not d.item.published %}
<span class="badge text-bg-warning">Unpublished</span>
{% endif %}
{% if d.item.company.freelance %}
<span class="badge text-bg-secondary">Freelance</span>
{% endif %}
{% if not d.item.published %}
<span class="badge text-bg-warning">Draft</span>
{% endif %}
</div>
</th>
</tr>
@@ -43,6 +45,7 @@
<tr>
<th scope="row">Company</th>
<td>
<img src="{{ d.item.company.country.flag }}" alt="{{ d.item.company.country }}">
<a href="{% url 'filtered' _filter="company" search=d.item.company.slug %}"><abbr title="{{ d.item.company.extended_name }}">{{ d.item.company }}</abbr></a>
</td>
</tr>
@@ -72,33 +75,12 @@
<th scope="row">Item number</th>
<td>{{ d.item.item_number }}{%if d.item.set %} | <a class="badge text-bg-primary" href="{% url 'manufacturer' manufacturer=d.item.manufacturer.slug search=d.item.item_number_slug %}">SET</a>{% endif %}</td>
</tr>
<tr>
<th scope="row">DCC</th>
<td><a class="text-reset text-decoration-none" title="Symbols" href="" data-bs-toggle="modal" data-bs-target="#symbolsModal">{% dcc d.item %}</a></td>
</tr>
</tbody>
</table>
{% if d.item.decoder or d.item.decoder_interface %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">DCC data</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Interface</th>
<td>{{ d.item.get_decoder_interface }}</td>
</tr>
{% if d.item.decoder %}
<tr>
<th scope="row">Decoder</th>
<td>{{ d.item.decoder }}</td>
</tr>
<tr>
<th scope="row">Address</th>
<td>{{ d.item.address }}</td>
</tr>
{% endif %}
</tbody>
</table>
{% endif %}
<div class="d-grid gap-2 mb-1 d-md-block">
<a class="btn btn-sm btn-outline-primary" href="{{d.item.get_absolute_url}}">Show all data</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:roster_rollingstock_change' d.item.pk %}">Edit</a>{% endif %}

View File

@@ -28,8 +28,10 @@
</tbody>
</table>
<div class="d-grid gap-2 mb-1 d-md-block">
<a class="btn btn-sm btn-outline-primary{% if d.item.num_items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="scale" search=d.item.slug %}">Show all rolling stock</a>
{% with items=d.item.num_items %}
<a class="btn btn-sm btn-outline-primary{% if items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="scale" search=d.item.slug %}">Show {{ items }} item{{ items | pluralize}}</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_scale_change' d.item.pk %}">Edit</a>{% endif %}
{% endwith %}
</div>
</div>
</div>

View File

@@ -7,6 +7,9 @@
{{ t.name }}</a>{# new line is required #}
{% endfor %}
</p>
{% if not consist.published %}
<span class="badge text-bg-warning">Unpublished</span> |
{% endif %}
<small class="text-body-secondary">Updated {{ consist.updated_time | date:"M d, Y H:i" }}</small>
{% endif %}
{% endblock %}
@@ -83,9 +86,6 @@
{% if consist.company.freelance %}
<span class="badge text-bg-secondary">Freelance</span>
{% endif %}
{% if not consist.published %}
<span class="badge text-bg-warning">Draft</span>
{% endif %}
</div>
</th>
</tr>
@@ -109,7 +109,11 @@
{% endif %}
<tr>
<th scope="row">Length</th>
<td>{{ data | length }}</td>
<td>{{ consist.length }}</td>
</tr>
<tr>
<th scope="row">Composition</th>
<td>{% for t in consist.get_type_count %}{{ t.count }}x {{ t.type }} {{t.category }}{% if not forloop.last %} &raquo; {% endif %}{% endfor %}</td>
</tr>
</tbody>
</table>

View File

@@ -1,6 +1,9 @@
{% extends 'base.html' %}
{% block header %}
{% if not flatpage.published %}
<span class="badge text-bg-warning">Unpublished</span> |
{% endif %}
<small class="text-body-secondary">Updated {{ flatpage.updated_time | date:"M d, Y H:i" }}</small>
{% endblock %}
{% block carousel %}
@@ -9,11 +12,6 @@
<section class="py-4 text-start container">
<div class="row">
<div class="mx-auto">
{% if not flatpage.published %}
<div class="alert alert-warning" role="alert">
⚠️ This page is a <strong>draft</strong> and is not published.
</div>
{% endif %}
<div>{{ flatpage.content | safe }} </div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:portal_flatpage_change' flatpage.pk %}">Edit</a>{% endif %}

View File

@@ -12,14 +12,16 @@
<div class="container d-flex text-body-secondary">
<p class="flex-fill small">Made with ❤️ for 🚂 and <i class="bi bi-github"></i> <a href="https://github.com/daniviga/django-ram">django-ram</a>
{% if site_conf.show_version %}<br>Version {{ site_conf.version }}{% endif %}</p>
<p class="text-end fs-5">
{% if site_conf.disclaimer %}<a class="text-reset" title="Disclaimer" href="" data-bs-toggle="modal" data-bs-target="#disclaimerModal"><i class="bi bi-info-square-fill"></i></a> {% endif %}
<a class="text-reset" title="Back to top" href="#"><i class="bi bi-arrow-up-left-square-fill"></i></a>
<p class="text-end">
{% if site_conf.disclaimer %}
<a title="Disclaimer" href="" data-bs-toggle="modal" data-bs-target="#disclaimerModal"><i class="text-muted d-lg-none fs-5 bi bi-info-square-fill"></i><span class="d-none d-lg-inline small">Disclaimer</span></a><span class="d-none d-lg-inline small"> | </span>
{% endif %}
<a title="Back to top" href="#"><i class="text-muted d-lg-none fs-5 bi bi-arrow-up-left-square-fill"></i><span class="d-none d-lg-inline small">Back to top</span></a>
</p>
</div>
<!-- Modal -->
<div class="modal fade" id="disclaimerModal" tabindex="-1" aria-labelledby="disclaimerLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-dialog modal-dialog-scrollable modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="disclaimerLabel">Disclaimer</h1>

View File

@@ -0,0 +1,39 @@
<!-- Modal -->
<div class="modal fade" id="symbolsModal" tabindex="-1" aria-labelledby="symbolsLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="symbolsLabel">Symbols</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">DCC symbols</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<th scope="row" class="text-center"><i class="bi bi-ban small"></i></th>
<td>No socket</td>
</tr>
<tr>
<th scope="row" class="text-center"><i class="bi bi-dice-6 small"></i></th>
<td>Socket available</td>
</tr>
<tr>
<th scope="row" class="text-center"><i class="bi bi-arrow-bar-left"></i><i class="bi bi-cpu-fill small"></i></th>
<td>Decoder installed</td>
</tr>
<tr>
<th scope="row" class="text-center"><i class="bi bi-arrow-bar-left"></i><i class="bi bi-volume-up-fill small"></i></th>
<td>Sound decoder installed</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load dcc %}
{% block header %}
{% if rolling_stock.tags.all %}
@@ -8,6 +9,9 @@
{% endfor %}
</p>
{% endif %}
{% if not rolling_stock.published %}
<span class="badge text-bg-warning">Unpublished</span> |
{% endif %}
<small class="text-body-secondary">Updated {{ rolling_stock.updated_time | date:"M d, Y H:i" }}</small>
{% endblock %}
{% block carousel %}
@@ -48,7 +52,7 @@
<button class="nav-link" id="nav-model-tab" data-bs-toggle="tab" data-bs-target="#nav-model" type="button" role="tab" aria-controls="nav-model" aria-selected="false">Model</button>
<button class="nav-link" id="nav-class-tab" data-bs-toggle="tab" data-bs-target="#nav-class" type="button" role="tab" aria-controls="nav-class" aria-selected="false">Class</button>
<button class="nav-link" id="nav-company-tab" data-bs-toggle="tab" data-bs-target="#nav-company" type="button" role="tab" aria-controls="nav-company" aria-selected="false">Company</button>
{% if rolling_stock.decoder %}<button class="nav-link" id="nav-dcc-tab" data-bs-toggle="tab" data-bs-target="#nav-dcc" type="button" role="tab" aria-controls="nav-dcc" aria-selected="false">DCC</button>{% endif %}
{% if rolling_stock.decoder or rolling_stock.decoder_interface %}<button class="nav-link" id="nav-dcc-tab" data-bs-toggle="tab" data-bs-target="#nav-dcc" type="button" role="tab" aria-controls="nav-dcc" aria-selected="false">DCC</button>{% endif %}
{% if documents or decoder_documents %}<button class="nav-link" id="nav-documents-tab" data-bs-toggle="tab" data-bs-target="#nav-documents" type="button" role="tab" aria-controls="nav-documents" aria-selected="false">Documents</button>{% endif %}
{% if journal %}<button class="nav-link" id="nav-journal-tab" data-bs-toggle="tab" data-bs-target="#nav-journal" type="button" role="tab" aria-controls="nav-journal" aria-selected="false">Journal</button>{% endif %}
{% if set %}<button class="nav-link" id="nav-set-tab" data-bs-toggle="tab" data-bs-target="#nav-set" type="button" role="tab" aria-controls="nav-set" aria-selected="false">Set</button>{% endif %}
@@ -59,7 +63,7 @@
<option value="nav-model">Model</option>
<option value="nav-class">Class</option>
<option value="nav-company">Company</option>
{% if rolling_stock.decoder %}<option value="nav-dcc">DCC</option>{% endif %}
{% if rolling_stock.decoder or rolling_stock.decoder_interface %}<option value="nav-dcc">DCC</option>{% endif %}
{% if documents or decoder_documents %}<option value="nav-documents">Documents</option>{% endif %}
{% if journal %}<option value="nav-journal">Journal</option>{% endif %}
{% if set %}<option value="nav-set">Set</option>{% endif %}
@@ -77,9 +81,6 @@
{% if company.freelance %}
<span class="badge text-bg-secondary">Freelance</span>
{% endif %}
{% if not rolling_stock.published %}
<span class="badge text-bg-warning">Draft</span>
{% endif %}
</div>
</th>
</tr>
@@ -142,7 +143,9 @@
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">DCC data</th>
<th colspan="2" scope="row">DCC data
<a class="mt-1 float-end text-reset text-decoration-none" title="Symbols" href="" data-bs-toggle="modal" data-bs-target="#symbolsModal">{% dcc rolling_stock %}</a>
</th>
</tr>
</thead>
<tbody class="table-group-divider">
@@ -155,6 +158,16 @@
<th scope="row">Decoder</th>
<td>{{ rolling_stock.decoder }}</td>
</tr>
<tr>
<th scope="row">Sound</th>
<td>
{% if rolling_stock.decoder.sound %}
<i class="bi bi-check-circle-fill text-success"></i>
{% else %}
<i class="bi bi-x-circle-fill text-secondary"></i>
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Address</th>
<td>{{ rolling_stock.address }}</td>
@@ -339,36 +352,55 @@
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Decoder data</th>
<th colspan="2" scope="row">Decoder data
<a class="mt-1 float-end text-reset text-decoration-none" title="Symbols" href="" data-bs-toggle="modal" data-bs-target="#symbolsModal">{% dcc rolling_stock %}</a>
</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<th scope="row">Interface</th>
<th class="w-33" scope="row">Interface</th>
<td>{{ rolling_stock.get_decoder_interface }}</td>
</tr>
{% if rolling_stock.decoder %}
<tr>
<th class="w-33" scope="row">Address</th>
<td>{{ rolling_stock.address }}</td>
<th scope="row">Manufacturer</th>
<td>{{ rolling_stock.decoder.manufacturer | default:"-" }}</td>
</tr>
<tr>
<th scope="row">Name</th>
<td>{{ rolling_stock.decoder.name }}</td>
</tr>
<tr>
<th scope="row">Manufacturer</th>
<td>{{ rolling_stock.decoder.manufacturer | default:"-" }}</td>
</tr>
<tr>
<th scope="row">Version</th>
<td>{{ rolling_stock.decoder.version | default:"-"}}</td>
</tr>
<tr>
<th scope="row">Sound</th>
<td>{{ rolling_stock.decoder.sound | yesno:"Yes,No" }}</td>
<td>
{% if rolling_stock.decoder.sound %}
<i class="bi bi-check-circle-fill text-success"></i>
{% else %}
<i class="bi bi-x-circle-fill text-secondary"></i>
{% endif %}
</td>
</tr>
</tbody>
</table>
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Decoder configuration</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Address</th>
<td>{{ rolling_stock.address }}</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<div class="tab-pane" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
{% if documents %}

View File

@@ -0,0 +1,36 @@
from django import template
from django.utils.html import format_html
register = template.Library()
@register.simple_tag
def dcc(object):
socket = (
'<i class="bi bi-ban small"></i>'
)
decoder = ''
if object.decoder_interface is not None:
socket = (
f'<abbr title="{object.get_decoder_interface()}">'
f'<i class="bi bi-dice-6"></i></abbr>'
)
if object.decoder:
if object.decoder.sound:
decoder = (
f'<abbr title="{object.decoder}">'
'<i class="bi bi-volume-up-fill"></i></abbr>'
)
else:
decoder = (
f'<abbr title="{object.decoder}'
f'({object.get_decoder_interface()})">'
'<i class="bi bi-cpu-fill"></i></abbr>'
)
if decoder:
return format_html(
f'{socket} <i class="bi bi-arrow-bar-left"></i>{decoder}'
)
return format_html(socket)

View File

@@ -7,7 +7,7 @@ from urllib.parse import unquote
from django.views import View
from django.http import Http404, HttpResponseBadRequest
from django.db.utils import OperationalError, ProgrammingError
from django.db.models import Q, Count
from django.db.models import F, Q, Count
from django.shortcuts import render, get_object_or_404, get_list_or_404
from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator
@@ -490,7 +490,40 @@ class Manufacturers(GetData):
item_type = "manufacturer"
def get_data(self, request):
return Manufacturer.objects.filter(self.filter)
return (
Manufacturer.objects.filter(self.filter).annotate(
num_rollingstock=(
Count(
"rollingstock",
filter=Q(
rollingstock__in=(
RollingStock.objects.get_published(
request.user
)
)
),
distinct=True,
)
)
)
.annotate(
num_rollingclass=(
Count(
"rollingclass__rolling_stock",
filter=Q(
rollingclass__rolling_stock__in=(
RollingStock.objects.get_published(
request.user
)
),
),
distinct=True,
)
)
)
.annotate(num_items=F("num_rollingstock") + F("num_rollingclass"))
.order_by("name")
)
# overload get method to filter by category
def get(self, request, category, page=1):
@@ -506,18 +539,69 @@ class Companies(GetData):
item_type = "company"
def get_data(self, request):
return Company.objects.all()
return (
Company.objects.annotate(
num_rollingstock=(
Count(
"rollingclass__rolling_stock",
filter=Q(
rollingclass__rolling_stock__in=(
RollingStock.objects.get_published(
request.user
)
)
),
distinct=True,
)
)
)
.annotate(
num_consists=(
Count(
"consist",
filter=Q(
consist__in=(
Consist.objects.get_published(request.user)
),
),
distinct=True,
)
)
)
.annotate(num_items=F("num_rollingstock") + F("num_consists"))
.order_by("name")
)
class Scales(GetData):
title = "Scales"
item_type = "scale"
queryset = Scale.objects.all()
def get_data(self, request):
return Scale.objects.annotate(
num_items=Count("rollingstock")
) # .filter(num_items__gt=0) to filter data with no items
return (
Scale.objects.annotate(
num_rollingstock=Count(
"rollingstock",
filter=Q(
rollingstock__in=RollingStock.objects.get_published(
request.user
)
),
distinct=True,
),
num_consists=Count(
"consist",
filter=Q(
consist__in=Consist.objects.get_published(
request.user
)
),
distinct=True,
),
)
.annotate(num_items=F("num_rollingstock") + F("num_consists"))
.order_by("-ratio_int", "-tracks", "scale")
)
class Types(GetData):
@@ -525,7 +609,16 @@ class Types(GetData):
item_type = "rolling_stock_type"
def get_data(self, request):
return RollingStockType.objects.all()
return RollingStockType.objects.annotate(
num_items=Count(
"rollingclass__rolling_stock",
filter=Q(
rollingclass__rolling_stock__in=(
RollingStock.objects.get_published(request.user)
)
),
)
).order_by("order")
class Books(GetData):
@@ -569,7 +662,7 @@ class GetBookCatalog(View):
"book": book,
"documents": documents,
"properties": properties,
"type": selector
"type": selector,
},
)

View File

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

View File

@@ -1,32 +0,0 @@
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

@@ -95,16 +95,8 @@ DATABASES = {
"default": {
"ENGINE": "django.db.backends.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

View File

@@ -17,7 +17,6 @@ from roster.models import (
RollingStockImage,
RollingStockProperty,
RollingStockJournal,
RollingStockTelemetry,
)
@@ -37,6 +36,7 @@ class RollingClass(admin.ModelAdmin):
search_fields = (
"identifier",
"company__name",
"company__slug",
"type__type",
)
save_as = True
@@ -140,7 +140,9 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
search_fields = (
"rolling_class__identifier",
"rolling_class__company__name",
"rolling_class__company__slug",
"manufacturer__name",
"scale__scale",
"road_number",
"address",
"item_number",
@@ -230,9 +232,12 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
def download_csv(modeladmin, request, queryset):
header = [
"ID",
"Name",
"Class",
"Type",
"Company",
"Identifier",
"Country",
"Road Number",
"Manufacturer",
"Scale",
@@ -259,9 +264,12 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
)
data.append(
[
obj.uuid,
obj.__str__(),
obj.rolling_class.company.name,
obj.rolling_class.identifier,
obj.rolling_class.type,
obj.rolling_class.company.name,
obj.rolling_class.company.country,
obj.road_number,
obj.manufacturer.name,
obj.scale.scale,
@@ -274,11 +282,11 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
settings.CSV_SEPARATOR_ALT.join(
t.name for t in obj.tags.all()
),
obj.decoder_interface,
obj.get_decoder_interface_display(),
obj.decoder,
obj.address,
obj.purchase_date,
obj.shop,
obj.purchase_date,
obj.price,
properties,
]
@@ -288,29 +296,3 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
download_csv.short_description = "Download selected items as CSV"
actions = [publish, unpublish, download_csv]
@admin.register(RollingStockTelemetry)
class RollingTelemtryAdmin(admin.ModelAdmin):
list_filter = ("bucket", "cab")
list_display = ("bucket_highres", "cab", "max_speed", "avg_speed")
def bucket_highres(self, obj):
return obj.bucket.strftime("%Y-%m-%d %H:%M:%S")
bucket_highres.admin_order_field = "bucket" # Enable sorting
bucket_highres.short_description = "Bucket" # Column name in admin
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

@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-05-04 17:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("roster", "0036_delete_rollingstockdocument"),
]
operations = [
migrations.AlterField(
model_name="rollingstock",
name="road_number_int",
field=models.PositiveIntegerField(default=0),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.1.4 on 2025-05-24 12:56
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("roster", "0037_alter_rollingstock_road_number_int"),
]
operations = [
migrations.AlterField(
model_name="rollingstock",
name="rolling_class",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="rolling_stock",
to="roster.rollingclass",
verbose_name="Class",
),
),
]

View File

@@ -63,11 +63,11 @@ class RollingStock(BaseModel):
on_delete=models.CASCADE,
null=False,
blank=False,
related_name="rolling_class",
related_name="rolling_stock",
verbose_name="Class",
)
road_number = models.CharField(max_length=128, unique=False)
road_number_int = models.PositiveSmallIntegerField(default=0, unique=False)
road_number_int = models.PositiveIntegerField(default=0, unique=False)
manufacturer = models.ForeignKey(
Manufacturer,
on_delete=models.CASCADE,
@@ -135,9 +135,23 @@ class RollingStock(BaseModel):
def get_decoder_interface(self):
return str(
dict(settings.DECODER_INTERFACES).get(self.decoder_interface)
or "-"
or "No interface"
)
def dcc(self):
if self.decoder:
dcc = (
'<i class="bi bi-volume-up-fill"></i>'
if self.decoder.sound
else '<i class="bi bi-cpu-fill"></i>'
)
dcc = f'<abbr title="{self.decoder} ({self.get_decoder_interface()})">{dcc}</abbr>' # noqa: E501
elif self.decoder_interface:
dcc = f'<abbr title="{self.get_decoder_interface()}"><i class="bi bi-cpu"></i></abbr>' # noqa: E501
else:
dcc = f'<abbr title="{self.get_decoder_interface()}"><i class="bi bi-ban"></i></abbr>' # noqa: E501
return dcc
@property
def country(self):
return self.rolling_class.company.country
@@ -224,20 +238,6 @@ class RollingStockJournal(models.Model):
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)
# def post_save_image(sender, instance, *args, **kwargs):
# try:

View File

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