Compare commits

...

27 Commits

Author SHA1 Message Date
8c216c7e56 Fix search form validation 2026-01-15 15:17:20 +01:00
d1e741ebfd Remove the need of inline scripting 2026-01-15 12:42:52 +01:00
650a93676e Implement CSP via Django 6.0 2026-01-15 10:36:07 +01:00
265aed56fe Further hardening 2026-01-15 10:06:52 +01:00
167a0593de Cookies hardening 2026-01-15 10:02:57 +01:00
a254786ddc Fix a bug with deduplicated file download 2026-01-14 11:36:09 +01:00
8d899e4d9f Minor improvements 2026-01-09 13:08:47 +01:00
40df9eb376 Make the js versioned 2026-01-08 18:49:34 +01:00
226f0b32ba Fix order in the DCC interfaces 2026-01-08 13:20:34 +01:00
3c121a60a4 Extend DCC definitions and clean-up config. Fix a typo in site name (!!) 2026-01-08 12:22:22 +01:00
ab606859d1 Fix a small regression in the admin introduced with Django 6 2026-01-08 12:21:39 +01:00
a16801eb4b Fix a bug in tab's javascript and cleanup the code 2026-01-07 23:24:16 +01:00
b8d10a68ca Minor fix to site description exposed in the html header 2026-01-07 18:33:46 +01:00
e690ded04f Include npm dependencies 2026-01-07 18:29:58 +01:00
15a7ffaf4f Implement deep links for tabs and template cleanup 2026-01-07 18:28:25 +01:00
a11f97bcad Reduce number of clicks to add images or documents to objects 2026-01-06 18:15:47 +01:00
3c854bda1b Fix a bug in consists admin filtering 2026-01-05 18:02:14 +01:00
564416b3d5 Bump to v0.19.8 2026-01-05 15:46:35 +01:00
967ea5d495 Hide accordion in consists if no load 2026-01-05 15:45:52 +01:00
7656aa8b68 Simplify consist cards cover generator 2026-01-05 15:38:51 +01:00
1be102b9d4 Better 404 handling 2026-01-05 14:54:38 +01:00
4ec7b8fc18 Fix support for X-Accel-Redirect 2026-01-05 14:39:45 +01:00
9a469378df Add support for X-Accel-Redirect 2026-01-05 00:04:44 +01:00
ede8741473 Enforce file access permissions 2026-01-04 23:48:52 +01:00
49c8d804d6 Implement support for rolling stock load in consists 2026-01-03 14:18:46 +01:00
2ab2d00585 Improve ordering 2026-01-03 00:54:21 +01:00
c95064ddec More templates modularization 2026-01-02 23:19:18 +01:00
49 changed files with 769 additions and 374 deletions

5
.gitignore vendored
View File

@@ -127,6 +127,11 @@ dmypy.json
# Pyre type checker # Pyre type checker
.pyre/ .pyre/
# node.js / npm stuff
node_modules
package-lock.json
# our own stuff
*.swp *.swp
ram/storage/ ram/storage/
!ram/storage/.gitignore !ram/storage/.gitignore

43
docs/nginx/nginx.conf Normal file
View File

@@ -0,0 +1,43 @@
server {
listen [::]:443 ssl;
listen 443 ssl;
server_name myhost;
# ssl_certificate ...;
add_header X-Xss-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=15768000";
add_header Permissions-Policy "geolocation=(),midi=(),sync-xhr=(),microphone=(),camera=(),magnetometer=(),gyroscope=(),fullscreen=(self),payment=()";
add_header Content-Security-Policy "child-src 'none'; object-src 'none'";
client_max_body_size 250M;
error_page 403 404 https://$server_name/404;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect http:// https://;
proxy_connect_timeout 1800;
proxy_read_timeout 1800;
proxy_max_temp_file_size 8192m;
}
# static files
location /static {
root /myroot/ram/storage;
}
# media files
location ~ ^/media/(images|uploads) {
root /myroot/ram/storage;
}
# protected filed to be served via X-Accel-Redirect
location /private {
internal;
alias /myroot/ram/storage/media;
}
}

6
package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"dependencies": {
"clean-css-cli": "^5.6.3",
"terser": "^5.44.1"
}
}

View File

@@ -2,7 +2,12 @@ import html
from django.conf import settings from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.utils.html import format_html, format_html_join, strip_tags from django.utils.html import (
format_html,
format_html_join,
strip_tags,
mark_safe,
)
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
from ram.admin import publish, unpublish from ram.admin import publish, unpublish
@@ -29,7 +34,7 @@ from bookshelf.models import (
class BookImageInline(SortableInlineAdminMixin, admin.TabularInline): class BookImageInline(SortableInlineAdminMixin, admin.TabularInline):
model = BaseBookImage model = BaseBookImage
min_num = 0 min_num = 0
extra = 0 extra = 1
readonly_fields = ("image_thumbnail",) readonly_fields = ("image_thumbnail",)
classes = ["collapse"] classes = ["collapse"]
verbose_name = "Image" verbose_name = "Image"
@@ -47,7 +52,7 @@ class BookPropertyInline(admin.TabularInline):
class BookDocInline(admin.TabularInline): class BookDocInline(admin.TabularInline):
model = BookDocument model = BookDocument
min_num = 0 min_num = 0
extra = 0 extra = 1
classes = ["collapse"] classes = ["collapse"]
@@ -149,7 +154,7 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
def invoices(self, obj): def invoices(self, obj):
if obj.invoice.exists(): if obj.invoice.exists():
html = format_html_join( html = format_html_join(
"<br>", mark_safe("<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()),
) )
@@ -317,7 +322,7 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
def invoices(self, obj): def invoices(self, obj):
if obj.invoice.exists(): if obj.invoice.exists():
html = format_html_join( html = format_html_join(
"<br>", mark_safe("<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()),
) )

View File

@@ -47,7 +47,7 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
"creation_time", "creation_time",
"updated_time", "updated_time",
) )
list_filter = ("published", "company__name", "era", "scale") list_filter = ("published", "company__name", "era", "scale__scale")
list_display = ( list_display = (
"__str__", "__str__",
"company__name", "company__name",

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-01-03 12:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("consist", "0018_alter_consist_scale"),
]
operations = [
migrations.AddField(
model_name="consistitem",
name="load",
field=models.BooleanField(default=False),
),
]

View File

@@ -43,10 +43,10 @@ class Consist(BaseModel):
@property @property
def length(self): def length(self):
return self.consist_item.count() return self.consist_item.filter(load=False).count()
def get_type_count(self): def get_type_count(self):
return self.consist_item.annotate( return self.consist_item.filter(load=False).annotate(
type=models.F("rolling_stock__rolling_class__type__type") type=models.F("rolling_stock__rolling_class__type__type")
).values( ).values(
"type" "type"
@@ -56,6 +56,15 @@ class Consist(BaseModel):
order=models.Max("order"), order=models.Max("order"),
).order_by("order") ).order_by("order")
def get_cover(self):
if self.image:
return self.image
else:
consist_item = self.consist_item.first()
if consist_item and consist_item.rolling_stock.image.exists():
return consist_item.rolling_stock.image.first().image
return None
@property @property
def country(self): def country(self):
return self.company.country return self.company.country
@@ -69,6 +78,7 @@ class ConsistItem(models.Model):
Consist, on_delete=models.CASCADE, related_name="consist_item" Consist, on_delete=models.CASCADE, related_name="consist_item"
) )
rolling_stock = models.ForeignKey(RollingStock, on_delete=models.CASCADE) rolling_stock = models.ForeignKey(RollingStock, on_delete=models.CASCADE)
load = models.BooleanField(default=False)
order = models.PositiveIntegerField(blank=False, null=False) order = models.PositiveIntegerField(blank=False, null=False)
class Meta: class Meta:
@@ -92,10 +102,15 @@ class ConsistItem(models.Model):
# because the consist is not saved yet and it must be moved # because the consist is not saved yet and it must be moved
# to the admin form validation via InlineFormSet.clean() # to the admin form validation via InlineFormSet.clean()
consist = self.consist consist = self.consist
if rolling_stock.scale != consist.scale: # Scale must match, but allow loads of any scale
if rolling_stock.scale != consist.scale and not self.load:
raise ValidationError( raise ValidationError(
"The rolling stock and consist must be of the same scale." "The rolling stock and consist must be of the same scale."
) )
if self.load and rolling_stock.scale.ratio != consist.scale.ratio:
raise ValidationError(
"The load and consist must be of the same scale ratio."
)
if self.consist.published and not rolling_stock.published: if self.consist.published and not rolling_stock.published:
raise ValidationError( raise ValidationError(
"You must unpublish the the consist before using this item." "You must unpublish the the consist before using this item."

View File

@@ -24,7 +24,7 @@ class PropertyAdmin(admin.ModelAdmin):
class DecoderDocInline(admin.TabularInline): class DecoderDocInline(admin.TabularInline):
model = DecoderDocument model = DecoderDocument
min_num = 0 min_num = 0
extra = 0 extra = 1
classes = ["collapse"] classes = ["collapse"]

View File

@@ -0,0 +1,24 @@
# Generated by Django 6.0 on 2026-01-09 12:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("metadata", "0025_alter_company_options_alter_manufacturer_options_and_more"),
]
operations = [
migrations.AlterField(
model_name="manufacturer",
name="name",
field=models.CharField(max_length=128),
),
migrations.AddConstraint(
model_name="manufacturer",
constraint=models.UniqueConstraint(
fields=("name", "category"), name="unique_name_category"
),
),
]

View File

@@ -30,7 +30,7 @@ class Property(SimpleBaseModel):
class Manufacturer(SimpleBaseModel): class Manufacturer(SimpleBaseModel):
name = models.CharField(max_length=128, unique=True) name = models.CharField(max_length=128)
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(
max_length=64, choices=settings.MANUFACTURER_TYPES max_length=64, choices=settings.MANUFACTURER_TYPES
@@ -46,6 +46,12 @@ class Manufacturer(SimpleBaseModel):
class Meta: class Meta:
ordering = ["category", "slug"] ordering = ["category", "slug"]
constraints = [
models.UniqueConstraint(
fields=["name", "category"],
name="unique_name_category"
)
]
def __str__(self): def __str__(self):
return self.name return self.name

View File

@@ -20,6 +20,7 @@ class SiteConfigurationAdmin(SingletonModelAdmin):
"about", "about",
"items_per_page", "items_per_page",
"items_ordering", "items_ordering",
"featured_items_ordering",
"currency", "currency",
"footer", "footer",
"footer_extended", "footer_extended",
@@ -34,7 +35,8 @@ class SiteConfigurationAdmin(SingletonModelAdmin):
"fields": ( "fields": (
"show_version", "show_version",
"use_cdn", "use_cdn",
"extra_head", "extra_html",
"extra_js",
"rest_api", "rest_api",
"version", "version",
), ),

View File

@@ -0,0 +1,43 @@
# Generated by Django 6.0 on 2026-01-02 23:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("portal", "0020_alter_flatpage_options"),
]
operations = [
migrations.AddField(
model_name="siteconfiguration",
name="featured_items_ordering",
field=models.CharField(
choices=[
("type", "By rolling stock type and company"),
("class", "By rolling stock type and class"),
("company", "By company and type"),
("country", "By country and type"),
("cou+com", "By country and company"),
],
default="type",
max_length=11,
),
),
migrations.AlterField(
model_name="siteconfiguration",
name="items_ordering",
field=models.CharField(
choices=[
("type", "By rolling stock type and company"),
("class", "By rolling stock type and class"),
("company", "By company and type"),
("country", "By country and type"),
("cou+com", "By country and company"),
],
default="type",
max_length=11,
),
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 6.0.1 on 2026-01-15 11:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("portal", "0021_siteconfiguration_featured_items_ordering_and_more"),
]
operations = [
migrations.RenameField(
model_name="siteconfiguration",
old_name="extra_head",
new_name="extra_html",
),
migrations.AlterField(
model_name="siteconfiguration",
name="extra_html",
field=models.TextField(
blank=True,
help_text="Extra HTML to be dinamically loaded into the site.",
),
),
migrations.AddField(
model_name="siteconfiguration",
name="extra_js",
field=models.TextField(
blank=True,
help_text="Extra JS to be dinamically loaded into the site."
),
),
]

View File

@@ -22,21 +22,31 @@ class SiteConfiguration(SingletonModel):
default="6", default="6",
) )
items_ordering = models.CharField( items_ordering = models.CharField(
max_length=10, max_length=11,
choices=[ choices=[
("type", "By rolling stock type"), ("type", "By rolling stock type and company"),
("company", "By company name"), ("class", "By rolling stock type and class"),
("identifier", "By rolling stock class"), ("company", "By company and type"),
("country", "By country and type"),
("cou+com", "By country and company"),
], ],
default="type", default="type",
) )
featured_items_ordering = items_ordering.clone()
currency = models.CharField(max_length=3, default="EUR") currency = models.CharField(max_length=3, default="EUR")
footer = tinymce.HTMLField(blank=True) footer = tinymce.HTMLField(blank=True)
footer_extended = tinymce.HTMLField(blank=True) footer_extended = tinymce.HTMLField(blank=True)
disclaimer = tinymce.HTMLField(blank=True) disclaimer = tinymce.HTMLField(blank=True)
show_version = models.BooleanField(default=True) show_version = models.BooleanField(default=True)
use_cdn = models.BooleanField(default=True) use_cdn = models.BooleanField(default=True)
extra_head = models.TextField(blank=True) extra_html = models.TextField(
blank=True,
help_text="Extra HTML to be dinamically loaded into the site.",
)
extra_js = models.TextField(
blank=True,
help_text="Extra JS to be dinamically loaded into the site.",
)
class Meta: class Meta:
verbose_name = "Site Configuration" verbose_name = "Site Configuration"

1
ram/portal/static/css/main.min.css vendored Normal file
View File

@@ -0,0 +1 @@
html[data-bs-theme=dark] .navbar svg{fill:#fff}.card>a>img{width:100%}td>img.logo{max-width:200px;max-height:48px}td>img.logo-xl{max-width:400px;max-height:96px}td>p:last-child{margin-bottom:0}.btn>span{display:inline-block}a.badge,a.badge:hover{text-decoration:none;color:#fff}.img-thumbnail{padding:0}.w-33{width:33%!important}.table-group-divider{border-top:calc(var(--bs-border-width) * 3) solid var(--bs-border-color)}#nav-journal ol,#nav-journal ul{padding-left:1rem}#nav-journal ol:last-child,#nav-journal p:last-child,#nav-journal ul:last-child{margin-bottom:0}#footer>p{display:inline}

View File

@@ -0,0 +1,7 @@
# Compile main.min.css
```bash
$ npm install clean-css-cli
$ npx cleancss -o ../main.min.css main.css
```

6
ram/portal/static/js/main.min.js vendored Normal file
View File

@@ -0,0 +1,6 @@
/*!
* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
* Copyright 2011-2023 The Bootstrap Authors
* Licensed under the Creative Commons Attribution 3.0 Unported License.
*/
(()=>{"use strict";const e=()=>localStorage.getItem("theme"),t=()=>{const t=e();return t||(window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light")},a=e=>{"auto"===e&&window.matchMedia("(prefers-color-scheme: dark)").matches?document.documentElement.setAttribute("data-bs-theme","dark"):document.documentElement.setAttribute("data-bs-theme",e)};a(t());const r=(e,t=!1)=>{const a=document.querySelector("#bd-theme");if(!a)return;const r=document.querySelector(".theme-icon-active i"),o=document.querySelector(`[data-bs-theme-value="${e}"]`),s=o.querySelector(".theme-icon i").getAttribute("class");document.querySelectorAll("[data-bs-theme-value]").forEach(e=>{e.classList.remove("active"),e.setAttribute("aria-pressed","false")}),o.classList.add("active"),o.setAttribute("aria-pressed","true"),r.setAttribute("class",s),t&&a.focus()};window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{const r=e();"light"!==r&&"dark"!==r&&a(t())}),window.addEventListener("DOMContentLoaded",()=>{r(t()),document.querySelectorAll("[data-bs-theme-value]").forEach(e=>{e.addEventListener("click",()=>{const t=e.getAttribute("data-bs-theme-value");(e=>{localStorage.setItem("theme",e)})(t),a(t),r(t,!0)})})})})(),document.addEventListener("DOMContentLoaded",function(){"use strict";const e=document.getElementById("tabSelector"),t=window.location.hash.substring(1);if(t){const a=`#nav-${t}`,r=document.querySelector(`[data-bs-target="${a}"]`);r&&(bootstrap.Tab.getOrCreateInstance(r).show(),e.value=a)}document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(e=>{e.addEventListener("shown.bs.tab",e=>{const t=e.target.getAttribute("data-bs-target").replace("nav-","");history.replaceState(null,null,t)})}),e&&(e.addEventListener("change",function(){const e=this.value,t=document.querySelector(`[data-bs-target="${e}"]`);if(t){bootstrap.Tab.getOrCreateInstance(t).show()}}),document.querySelectorAll('[data-bs-toggle="tab"]').forEach(t=>{t.addEventListener("shown.bs.tab",t=>{const a=t.target.getAttribute("data-bs-target");e.value=a})}))}),document.addEventListener("DOMContentLoaded",function(){"use strict";const e=document.querySelectorAll(".needs-validation");Array.from(e).forEach(e=>{e.addEventListener("submit",t=>{e.checkValidity()||(t.preventDefault(),t.stopPropagation()),e.classList.add("was-validated")},!1)})});

View File

@@ -0,0 +1,7 @@
# Compile main.min.js
```bash
$ npm install terser
$ npx terser theme_selector.js tabs_selector.js -c -m -o ../main.min.js
```

View File

@@ -0,0 +1,43 @@
// use Bootstrap 5's Tab component to manage tab navigation and synchronize with URL hash
document.addEventListener("DOMContentLoaded", function () {
'use strict';
const selectElement = document.getElementById('tabSelector');
// code to handle tab selection and URL hash synchronization
const hash = window.location.hash.substring(1) // remove the '#' prefix
if (hash) {
const target = `#nav-${hash}`;
const trigger = document.querySelector(`[data-bs-target="${target}"]`);
if (trigger) {
bootstrap.Tab.getOrCreateInstance(trigger).show();
selectElement.value = target; // keep the dropdown in sync
}
}
// update the URL hash when a tab is shown
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(btn => {
btn.addEventListener('shown.bs.tab', event => {
const newHash = event.target.getAttribute('data-bs-target').replace('nav-', '');
history.replaceState(null, null, newHash);
});
});
// allow tab selection via a dropdown on small screens
if (!selectElement) return;
selectElement.addEventListener('change', function () {
const target = this.value;
const trigger = document.querySelector(`[data-bs-target="${target}"]`);
if (trigger) {
const tabInstance = bootstrap.Tab.getOrCreateInstance(trigger);
tabInstance.show();
}
});
// keep the dropdown in sync if the user clicks a tab button
document.querySelectorAll('[data-bs-toggle="tab"]').forEach(btn => {
btn.addEventListener('shown.bs.tab', event => {
const target = event.target.getAttribute('data-bs-target');
selectElement.value = target;
});
});
});

View File

@@ -0,0 +1,76 @@
/*!
* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
* Copyright 2011-2023 The Bootstrap Authors
* Licensed under the Creative Commons Attribution 3.0 Unported License.
*/
(() => {
'use strict'
const getStoredTheme = () => localStorage.getItem('theme')
const setStoredTheme = theme => localStorage.setItem('theme', theme)
const getPreferredTheme = () => {
const storedTheme = getStoredTheme()
if (storedTheme) {
return storedTheme
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const setTheme = theme => {
if (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-bs-theme', 'dark')
} else {
document.documentElement.setAttribute('data-bs-theme', theme)
}
}
setTheme(getPreferredTheme())
const showActiveTheme = (theme, focus = false) => {
const themeSwitcher = document.querySelector('#bd-theme')
if (!themeSwitcher) {
return
}
const activeThemeIcon = document.querySelector('.theme-icon-active i')
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`)
const biOfActiveBtn = btnToActive.querySelector('.theme-icon i').getAttribute('class')
document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
element.classList.remove('active')
element.setAttribute('aria-pressed', 'false')
})
btnToActive.classList.add('active')
btnToActive.setAttribute('aria-pressed', 'true')
activeThemeIcon.setAttribute('class', biOfActiveBtn)
if (focus) {
themeSwitcher.focus()
}
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const storedTheme = getStoredTheme()
if (storedTheme !== 'light' && storedTheme !== 'dark') {
setTheme(getPreferredTheme())
}
})
window.addEventListener('DOMContentLoaded', () => {
showActiveTheme(getPreferredTheme())
document.querySelectorAll('[data-bs-theme-value]')
.forEach(toggle => {
toggle.addEventListener('click', () => {
const theme = toggle.getAttribute('data-bs-theme-value')
setStoredTheme(theme)
setTheme(theme)
showActiveTheme(theme, true)
})
})
})
})()

View File

@@ -0,0 +1,15 @@
document.addEventListener("DOMContentLoaded", function () {
'use strict'
const forms = document.querySelectorAll('.needs-validation')
Array.from(forms).forEach(form => {
form.addEventListener('submit', event => {
if (!form.checkValidity()) {
event.preventDefault()
event.stopPropagation()
}
form.classList.add('was-validated')
}, false)
})
});

View File

@@ -0,0 +1,12 @@
<form class="d-flex needs-validation" action="{% url 'search' %}" method="post" novalidate>{% csrf_token %}
<div class="input-group has-validation">
<input class="form-control" type="search" list="datalistOptions" placeholder="Search" aria-label="Search" name="search" id="searchValidation" required>
<datalist id="datalistOptions">
<option value="company: ">
<option value="manufacturer: ">
<option value="scale: ">
<option value="type: ">
</datalist>
<button class="btn btn-outline-primary" type="submit">Search</button>
</div>
</form>

View File

@@ -0,0 +1,26 @@
{% if documents %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="3" scope="row">{{ header|default:"Documents" }}</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for d in documents.all %}
<tr>
<td class="w-33">{{ d.description }}</td>
<td class="text-nowrap">
{% if d.private %}
<i class="bi bi-file-earmark-lock2"></i>
{% else %}
<i class="bi bi-file-earmark-text"></i>
{% endif %}
<a href="{{ d.file.url }}" target="_blank">{{ d.filename }}</a>
</td>
<td class="text-end">{{ d.file.size | filesizeformat }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}

View File

@@ -0,0 +1,18 @@
{% if properties %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Properties</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for p in properties %}
<tr>
<th class="w-33" scope="row">{{ p.property }}</th>
<td>{{ p.value }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}

View File

@@ -0,0 +1,29 @@
{% if request.user.is_staff %}
{% if data.shop or data.purchase_date or data.price %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Purchase</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Shop</th>
<td>
{{ data.shop|default:"-" }}
{% if data.shop.website %} <a href="{{ data.shop.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr>
<tr>
<th class="w-33" scope="row">Purchase date</th>
<td>{{ data.purchase_date|default:"-" }}</td>
</tr>
<tr>
<th scope="row">Price ({{ site_conf.currency }})</th>
<td>{{ data.price|default:"-" }}</td>
</tr>
</tbody>
</table>
{% endif %}
{% endif %}

View File

@@ -9,7 +9,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="description" content="{{ site_conf.about}}"> <meta name="description" content="{{ site_conf.about|striptags }}">
<meta name="author" content="{{ site_conf.site_author }}"> <meta name="author" content="{{ site_conf.site_author }}">
<meta name="generator" content="Django Framework"> <meta name="generator" content="Django Framework">
<title>{% block title %}{{ title }}{% endblock %} - {{ site_conf.site_name }}</title> <title>{% block title %}{{ title }}{% endblock %} - {{ site_conf.site_name }}</title>
@@ -22,116 +22,11 @@
<link href="{% static "bootstrap@5.3.8/dist/css/bootstrap.min.css" %}" rel="stylesheet"> <link href="{% static "bootstrap@5.3.8/dist/css/bootstrap.min.css" %}" rel="stylesheet">
<link href="{% static "bootstrap-icons@1.13.1/font/bootstrap-icons.css" %}" rel="stylesheet"> <link href="{% static "bootstrap-icons@1.13.1/font/bootstrap-icons.css" %}" rel="stylesheet">
{% endif %} {% endif %}
<link href="{% static "css/main.css" %}?v={{ site_conf.version }}" rel="stylesheet"> <link href="{% static "css/main.min.css" %}?v={{ site_conf.version }}" rel="stylesheet">
<style> <script src="{% static "js/main.min.js" %}?v={{ site_conf.version }}"></script>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
</style>
<script>
/*!
* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
* Copyright 2011-2023 The Bootstrap Authors
* Licensed under the Creative Commons Attribution 3.0 Unported License.
*/
(() => {
'use strict'
const getStoredTheme = () => localStorage.getItem('theme')
const setStoredTheme = theme => localStorage.setItem('theme', theme)
const getPreferredTheme = () => {
const storedTheme = getStoredTheme()
if (storedTheme) {
return storedTheme
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const setTheme = theme => {
if (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-bs-theme', 'dark')
} else {
document.documentElement.setAttribute('data-bs-theme', theme)
}
}
setTheme(getPreferredTheme())
const showActiveTheme = (theme, focus = false) => {
const themeSwitcher = document.querySelector('#bd-theme')
if (!themeSwitcher) {
return
}
const activeThemeIcon = document.querySelector('.theme-icon-active i')
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`)
const biOfActiveBtn = btnToActive.querySelector('.theme-icon i').getAttribute('class')
document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
element.classList.remove('active')
element.setAttribute('aria-pressed', 'false')
})
btnToActive.classList.add('active')
btnToActive.setAttribute('aria-pressed', 'true')
activeThemeIcon.setAttribute('class', biOfActiveBtn)
if (focus) {
themeSwitcher.focus()
}
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const storedTheme = getStoredTheme()
if (storedTheme !== 'light' && storedTheme !== 'dark') {
setTheme(getPreferredTheme())
}
})
window.addEventListener('DOMContentLoaded', () => {
showActiveTheme(getPreferredTheme())
document.querySelectorAll('[data-bs-theme-value]')
.forEach(toggle => {
toggle.addEventListener('click', () => {
const theme = toggle.getAttribute('data-bs-theme-value')
setStoredTheme(theme)
setTheme(theme)
showActiveTheme(theme, true)
})
})
})
})()
</script>
<script>
document.addEventListener('DOMContentLoaded', function () {
var selectElement = document.getElementById('tabSelector');
try {
selectElement.addEventListener('change', function () {
var selectedTabId = this.value;
var tabs = document.querySelectorAll('.tab-pane');
tabs.forEach(function (tab) {
tab.classList.remove('show', 'active');
});
document.getElementById(selectedTabId).classList.add('show', 'active');
});
} catch (TypeError) { /* pass */ }
});
</script>
{% block extra_head %} {% block extra_head %}
{{ site_conf.extra_head | safe }} {% if site_conf.extra_html %}{{ site_conf.extra_html | safe }}{% endif %}
{% if site_conf.extra_js %}<script src="{% url 'extra_js' %}"></script>{% endif %}
{% endblock %} {% endblock %}
</head> </head>
<body> <body>
@@ -148,7 +43,7 @@
<strong>{{ site_conf.site_name }}</strong> <strong>{{ site_conf.site_name }}</strong>
</a> </a>
</div> </div>
{% include 'includes/login.html' %} {% include '_includes/login.html' %}
</div> </div>
</nav> </nav>
</header> </header>
@@ -186,7 +81,7 @@
{% show_bookshelf_menu %} {% show_bookshelf_menu %}
{% show_flatpages_menu user %} {% show_flatpages_menu user %}
</ul> </ul>
{% include 'includes/search.html' %} {% include '_includes/search.html' %}
</div> </div>
</div> </div>
</nav> </nav>
@@ -211,9 +106,9 @@
<div class="container">{% block pagination %}{% endblock %}</div> <div class="container">{% block pagination %}{% endblock %}</div>
</div> </div>
{% block extra_content %}{% endblock %} {% block extra_content %}{% endblock %}
{% include 'includes/symbols.html' %} {% include '_includes/symbols.html' %}
</main> </main>
{% include 'includes/footer.html' %} {% include '_includes/footer.html' %}
{% if site_conf.use_cdn %} {% if site_conf.use_cdn %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
{% else %} {% else %}

View File

@@ -53,9 +53,9 @@
{% if documents %}<button class="nav-link" id="nav-documents-tab" data-bs-toggle="tab" data-bs-target="#nav-documents" type="button" role="tab" aria-controls="nav-documents" aria-selected="false">Documents</button>{% endif %} {% if documents %}<button class="nav-link" id="nav-documents-tab" data-bs-toggle="tab" data-bs-target="#nav-documents" type="button" role="tab" aria-controls="nav-documents" aria-selected="false">Documents</button>{% endif %}
</nav> </nav>
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector"> <select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
<option value="nav-summary" selected>Summary</option> <option value="#nav-summary" selected>Summary</option>
{% if data.toc.all %}<option value="nav-toc">Table of contents</option>{% endif %} {% if data.toc.all %}<option value="#nav-toc">Table of contents</option>{% endif %}
{% if documents %}<option value="nav-documents">Documents</option>{% endif %} {% if documents %}<option value="#nav-documents">Documents</option>{% endif %}
</select> </select>
<div class="tab-content" id="nav-tabContent"> <div class="tab-content" id="nav-tabContent">
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab"> <div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
@@ -147,49 +147,8 @@
{% endif %} {% endif %}
</tbody> </tbody>
</table> </table>
{% if request.user.is_staff %} {% include "_modules/purchase_data.html" %}
<table class="table table-striped"> {% include "_modules/properties.html" %}
<thead>
<tr>
<th colspan="2" scope="row">Purchase</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Shop</th>
<td>
{{ data.shop|default:"-" }}
{% if data.shop.website %} <a href="{{ data.shop.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr>
<tr>
<th class="w-33" scope="row">Purchase date</th>
<td>{{ data.purchase_date|default:"-" }}</td>
</tr>
<tr>
<th scope="row">Price ({{ site_conf.currency }})</th>
<td>{{ data.price|default:"-" }}</td>
</tr>
</tbody>
</table>
{% endif %}
{% if properties %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Properties</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for p in properties %}
<tr>
<th class="w-33" scope="row">{{ p.property }}</th>
<td>{{ p.value }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div> </div>
<div class="tab-pane table-responsive" id="nav-toc" role="tabpanel" aria-labelledby="nav-toc-tab"> <div class="tab-pane table-responsive" id="nav-toc" role="tabpanel" aria-labelledby="nav-toc-tab">
<table class="table table-striped"> <table class="table table-striped">
@@ -216,7 +175,7 @@
</table> </table>
</div> </div>
<div class="tab-pane" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab"> <div class="tab-pane" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
{% include "includes/documents.html" %} {% include "_modules/documents.html" %}
</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">

View File

@@ -73,7 +73,7 @@
<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> <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> </nav>
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector"> <select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
<option value="nav-summary" selected>Summary</option> <option value="#nav-summary" selected>Summary</option>
</select> </select>
<div class="tab-content" id="nav-tabContent"> <div class="tab-content" id="nav-tabContent">
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab"> <div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">

View File

@@ -1,12 +1,13 @@
{% load static %}
<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.get_absolute_url }}">
{% if d.image %} {% if d.get_cover %}
<img class="card-img-top" src="{{ d.image.url }}" alt="{{ d }}"> <img class="card-img-top" src="{{ d.get_cover.url }}" alt="{{ d }}">
{% else %} {% else %}
{% with d.consist_item.first.rolling_stock as r %} <!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
<img class="card-img-top" src="{{ r.image.first.image.url }}" alt="{{ d }}"> <a href="{{d.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d }}"></a>
{% endwith %}
{% endif %} {% endif %}
</a> </a>
<div class="card-body"> <div class="card-body">

View File

@@ -7,11 +7,11 @@
{{ t.name }}</a>{# new line is required #} {{ t.name }}</a>{# new line is required #}
{% endfor %} {% endfor %}
</p> </p>
{% endif %}
{% if not consist.published %} {% if not consist.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 {{ consist.updated_time | date:"M d, Y H:i" }}</small> <small class="text-body-secondary">Updated {{ consist.updated_time | date:"M d, Y H:i" }}</small>
{% endif %}
{% endblock %} {% endblock %}
{% block carousel %} {% block carousel %}
{% if consist.image %} {% if consist.image %}
@@ -26,6 +26,35 @@
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block cards_layout %}
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
{% block cards %}
{% for d in data %}
{% include "cards/roster.html" %}
{% endfor %}
{% endblock %}
</div>
{% if loads %}
<div class="accordion shadow-sm mt-4" id="accordionLoads">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseLoads" aria-expanded="false" aria-controls="collapseLoads">
<i class="bi bi-download"></i>&nbsp;Rolling Stock loaded on freight cars
</button>
</h2>
<div id="collapseLoads" class="accordion-collapse collapse" data-bs-parent="#accordionLoads">
<div class="accordion-body">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
{% for l in loads %}
{% include "cards/roster.html" with d=l %}
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block pagination %} {% block pagination %}
{% if data.has_other_pages %} {% if data.has_other_pages %}
<nav aria-label="Page navigation"> <nav aria-label="Page navigation">
@@ -73,7 +102,7 @@
<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> <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> </nav>
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector"> <select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
<option value="nav-summary" selected>Summary</option> <option value="#nav-summary" selected>Summary</option>
</select> </select>
<div class="tab-content" id="nav-tabContent"> <div class="tab-content" id="nav-tabContent">
<div class="tab-pane show active table-responsive" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab"> <div class="tab-pane show active table-responsive" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
@@ -113,7 +142,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row">Composition</th> <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> <td>{% for t in consist.get_type_count %}{{ t.count }}x {{ t.type }} {{t.category }}{% if not forloop.last %} &raquo; {% endif %}{% endfor %}{% if loads %} | <i class="bi bi-download"></i> {{ loads|length }}x Load{{ loads|pluralize }}{% endif %}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -1,30 +0,0 @@
<form class="d-flex needs-validation" action="{% url 'search' %}" method="post" novalidate>{% csrf_token %}
<div class="input-group has-validation">
<input class="form-control" type="search" list="datalistOptions" placeholder="Search" aria-label="Search" name="search" id="searchValidation" required>
<datalist id="datalistOptions">
<option value="company: ">
<option value="manufacturer: ">
<option value="scale: ">
<option value="type: ">
</datalist>
<button class="btn btn-outline-primary" type="submit">Search</button>
</div>
</form>
<script>
(function () {
'use strict'
// Fetch all the forms we want to apply custom Bootstrap validation styles to
var forms = document.querySelectorAll('.needs-validation')
// Loop over them and prevent submission
Array.prototype.slice.call(forms)
.forEach(function (form) {
form.addEventListener('submit', function (event) {
if (!form.checkValidity()) {
form.classList.add('was-validated')
event.preventDefault()
event.stopPropagation()
}
}, false)
})
})()
</script>

View File

@@ -59,15 +59,15 @@
{% if consists %}<button class="nav-link" id="nav-consists-tab" data-bs-toggle="tab" data-bs-target="#nav-consists" type="button" role="tab" aria-controls="nav-consists" aria-selected="false">Consists</button>{% endif %} {% if consists %}<button class="nav-link" id="nav-consists-tab" data-bs-toggle="tab" data-bs-target="#nav-consists" type="button" role="tab" aria-controls="nav-consists" aria-selected="false">Consists</button>{% endif %}
</nav> </nav>
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector"> <select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
<option value="nav-summary" selected>Summary</option> <option value="#nav-summary" selected>Summary</option>
<option value="nav-model">Model</option> <option value="#nav-model">Model</option>
<option value="nav-class">Class</option> <option value="#nav-class">Class</option>
<option value="nav-company">Company</option> <option value="#nav-company">Company</option>
{% if rolling_stock.decoder or rolling_stock.decoder_interface %}<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 documents or decoder_documents %}<option value="#nav-documents">Documents</option>{% endif %}
{% if journal %}<option value="nav-journal">Journal</option>{% endif %} {% if journal %}<option value="#nav-journal">Journal</option>{% endif %}
{% if set %}<option value="nav-set">Set</option>{% endif %} {% if set %}<option value="#nav-set">Set</option>{% endif %}
{% if consists %}<option value="nav-consists">Consists</option>{% endif %} {% if consists %}<option value="#nav-consists">Consists</option>{% endif %}
</select> </select>
{% with class=rolling_stock.rolling_class company=rolling_stock.rolling_class.company %} {% with class=rolling_stock.rolling_class company=rolling_stock.rolling_class.company %}
<div class="tab-content" id="nav-tabContent"> <div class="tab-content" id="nav-tabContent">
@@ -217,49 +217,8 @@
{% endif %} {% endif %}
</tbody> </tbody>
</table> </table>
{% if request.user.is_staff %} {% include "_modules/purchase_data.html" with data=rolling_stock %}
<table class="table table-striped"> {% include "_modules/properties.html" %}
<thead>
<tr>
<th colspan="2" scope="row">Purchase</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Shop</th>
<td>
{{ rolling_stock.shop | default:"-" }}
{% if rolling_stock.shop.website %} <a href="{{ rolling_stock.shop.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr>
<tr>
<th class="w-33" scope="row">Purchase date</th>
<td>{{ rolling_stock.purchase_date | default:"-" }}</td>
</tr>
<tr>
<th scope="row">Price ({{ site_conf.currency }})</th>
<td>{{ rolling_stock.price | default:"-" }}</td>
</tr>
</tbody>
</table>
{% endif %}
{% if properties %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Properties</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for p in properties %}
<tr>
<th class="w-33" scope="row">{{ p.property }}</th>
<td>{{ p.value }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div> </div>
<div class="tab-pane" id="nav-class" role="tabpanel" aria-labelledby="nav-class-tab"> <div class="tab-pane" id="nav-class" role="tabpanel" aria-labelledby="nav-class-tab">
<table class="table table-striped"> <table class="table table-striped">
@@ -296,23 +255,7 @@
{% endif %} {% endif %}
</tbody> </tbody>
</table> </table>
{% if class_properties %} {% include "_modules/properties.html" with properties=class_properties %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Properties</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for p in class_properties %}
<tr>
<th class="w-33" scope="row">{{ p.property }}</th>
<td>{{ p.value }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div> </div>
<div class="tab-pane" id="nav-company" role="tabpanel" aria-labelledby="nav-company-tab"> <div class="tab-pane" id="nav-company" role="tabpanel" aria-labelledby="nav-company-tab">
<table class="table table-striped"> <table class="table table-striped">
@@ -403,8 +346,8 @@
</table> </table>
</div> </div>
<div class="tab-pane table-responsive" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab"> <div class="tab-pane table-responsive" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
{% include "includes/documents.html" %} {% include "_modules/documents.html" %}
{% include "includes/documents.html" with documents=decoder_documents header="Decoder documents" %} {% include "_modules/documents.html" with documents=decoder_documents header="Decoder documents" %}
</div> </div>
<div class="tab-pane" id="nav-journal" role="tabpanel" aria-labelledby="nav-journal-tab"> <div class="tab-pane" id="nav-journal" role="tabpanel" aria-labelledby="nav-journal-tab">
<table class="table table-striped"> <table class="table table-striped">

View File

@@ -17,15 +17,13 @@ def dcc(object):
f'<i class="bi bi-dice-6"></i></abbr>' f'<i class="bi bi-dice-6"></i></abbr>'
) )
if object.decoder: if object.decoder:
decoder = mark_safe(f'<abbr title="{object.decoder}">')
if object.decoder.sound: if object.decoder.sound:
decoder = mark_safe( decoder += mark_safe(
f'<abbr title="{object.decoder}">'
'<i class="bi bi-volume-up-fill"></i></abbr>' '<i class="bi bi-volume-up-fill"></i></abbr>'
) )
else: else:
decoder = mark_safe( decoder += mark_safe(
f'<abbr title="{object.decoder}'
f'({object.get_decoder_interface()})">'
'<i class="bi bi-cpu-fill"></i></abbr>' '<i class="bi bi-cpu-fill"></i></abbr>'
) )
if decoder: if decoder:

View File

@@ -1,6 +1,7 @@
from django.urls import path from django.urls import path
from portal.views import ( from portal.views import (
RenderExtraJS,
GetHome, GetHome,
GetRoster, GetRoster,
GetObjectsFiltered, GetObjectsFiltered,
@@ -24,6 +25,7 @@ from portal.views import (
urlpatterns = [ urlpatterns = [
path("", GetHome.as_view(), name="index"), path("", GetHome.as_view(), name="index"),
path("extra.js", RenderExtraJS.as_view(), name="extra_js"),
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="roster"),
path( path(

View File

@@ -6,7 +6,8 @@ from urllib.parse import unquote
from django.conf import settings from django.conf import settings
from django.views import View from django.views import View
from django.http import Http404, HttpResponseBadRequest from django.urls import Resolver404
from django.http import Http404, HttpResponse, 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.db.models.functions import Lower
@@ -36,30 +37,55 @@ def get_items_per_page():
return int(items_per_page) return int(items_per_page)
def get_order_by_field(): def get_items_ordering(config="items_ordering"):
try: try:
order_by = get_site_conf().items_ordering order_by = getattr(get_site_conf(), config)
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError):
order_by = "type" order_by = "type"
fields = [ fields = [
"rolling_class__type", "rolling_class__type", # 0
"rolling_class__company", "rolling_class__company", # 1
"rolling_class__identifier", "rolling_class__company__country", # 2
"road_number_int", "rolling_class__identifier", # 3
"road_number_int", # 4
] ]
if order_by == "type": order_map = {
return (fields[0], fields[1], fields[2], fields[3]) "type": (0, 1, 3, 4),
elif order_by == "company": "company": (1, 0, 3, 4),
return (fields[1], fields[0], fields[2], fields[3]) "country": (2, 0, 1, 3, 4),
elif order_by == "identifier": "cou+com": (2, 1, 0, 3, 4),
return (fields[2], fields[0], fields[1], fields[3]) "class": (0, 3, 1, 4),
}
return tuple(fields[i] for i in order_map.get(order_by, "type"))
class Render404(View): class Render404(View):
def get(self, request, exception): def get(self, request, exception):
return render(request, "base.html", {"title": "404 page not found"}) generic_message = "Page not found"
if isinstance(exception, Resolver404):
message = generic_message
else:
message = str(exception) if exception else generic_message
return render(
request,
"base.html",
{"title": message},
status=404,
)
class RenderExtraJS(View):
def get(self, request):
try:
extra_js = get_site_conf().extra_js
except (OperationalError, ProgrammingError):
extra_js = ""
return HttpResponse(extra_js, content_type="application/javascript")
class GetData(View): class GetData(View):
@@ -70,7 +96,7 @@ class GetData(View):
def get_data(self, request): def get_data(self, request):
return ( return (
RollingStock.objects.get_published(request.user) RollingStock.objects.get_published(request.user)
.order_by(*get_order_by_field()) .order_by(*get_items_ordering())
.filter(self.filter) .filter(self.filter)
) )
@@ -107,7 +133,9 @@ class GetHome(GetData):
return ( return (
RollingStock.objects.get_published(request.user) RollingStock.objects.get_published(request.user)
.filter(featured=True) .filter(featured=True)
.order_by(*get_order_by_field())[:max_items] .order_by(*get_items_ordering(config="featured_items_ordering"))[
:max_items
]
) or super().get_data(request) ) or super().get_data(request)
@@ -174,7 +202,7 @@ class SearchObjects(View):
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_items_ordering())
) )
data = list(roster) data = list(roster)
@@ -249,6 +277,9 @@ class SearchObjects(View):
return _filter, search return _filter, search
def get(self, request, search, page=1): def get(self, request, search, page=1):
if not search:
return HttpResponseBadRequest()
try: try:
encoded_search = search encoded_search = search
search = base64.b64decode(search.encode()).decode() search = base64.b64decode(search.encode()).decode()
@@ -301,7 +332,7 @@ class GetManufacturerItem(View):
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(
*get_order_by_field() *get_items_ordering()
), ),
Q( Q(
Q(manufacturer=manufacturer) Q(manufacturer=manufacturer)
@@ -323,7 +354,7 @@ class GetManufacturerItem(View):
| Q(rolling_class__manufacturer=manufacturer) | Q(rolling_class__manufacturer=manufacturer)
) )
.distinct() .distinct()
.order_by(*get_order_by_field()) .order_by(*get_items_ordering())
) )
catalogs = Catalog.objects.get_published(request.user).filter( catalogs = Catalog.objects.get_published(request.user).filter(
manufacturer=manufacturer manufacturer=manufacturer
@@ -376,7 +407,7 @@ class GetObjectsFiltered(View):
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_items_ordering())
) )
data = list(roster) data = list(roster)
@@ -480,7 +511,7 @@ class GetRollingStock(View):
& Q(set=True) & Q(set=True)
) )
) )
.order_by(*get_order_by_field()) .order_by(*get_items_ordering())
) )
return render( return render(
@@ -520,7 +551,13 @@ class GetConsist(View):
RollingStock.objects.get_published(request.user).get( 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.filter(load=False)
)
loads = list(
RollingStock.objects.get_published(request.user).get(
uuid=r.rolling_stock_id
)
for r in consist.consist_item.filter(load=True)
) )
paginator = Paginator(data, get_items_per_page()) paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page) data = paginator.get_page(page)
@@ -535,6 +572,7 @@ class GetConsist(View):
"title": consist, "title": consist,
"consist": consist, "consist": consist,
"data": data, "data": data,
"loads": loads,
"page_range": page_range, "page_range": page_range,
}, },
) )
@@ -748,7 +786,7 @@ class GetMagazine(View):
return render( return render(
request, request,
"magazine.html", "bookshelf/magazine.html",
{ {
"title": magazine, "title": magazine,
"magazine": magazine, "magazine": magazine,

View File

@@ -1,4 +1,13 @@
from django import VERSION as DJANGO_VERSION
from django.utils.termcolors import colorize
from ram.utils import git_suffix from ram.utils import git_suffix
__version__ = "0.19.5" if DJANGO_VERSION < (6, 0):
exit(
colorize(
"ERROR: This project requires Django 6.0 or higher.", fg="red"
)
)
__version__ = "0.19.10"
__version__ += git_suffix(__file__) __version__ += git_suffix(__file__)

View File

@@ -34,3 +34,4 @@ ALLOWED_HOSTS = ["127.0.0.1", "myhost"]
CSRF_TRUSTED_ORIGINS = ["https://myhost"] CSRF_TRUSTED_ORIGINS = ["https://myhost"]
STATIC_URL = "static/" STATIC_URL = "static/"
MEDIA_URL = "media/" MEDIA_URL = "media/"
USE_X_ACCEL_REDIRECT = True

View File

@@ -1,18 +1,9 @@
""" """
Django settings for ram project. Django settings for ram project.
Generated by 'django-admin startproject' using Django 4.0.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.0/ref/settings/
""" """
import os
import time
from pathlib import Path from pathlib import Path
from django.utils.csp import CSP
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@@ -22,18 +13,13 @@ STORAGE_DIR = BASE_DIR / "storage"
# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = ( SECRET_KEY = "django-ram-insecure-Chang3m3-1n-Pr0duct10n!"
"django-insecure-1fgtf05rwp0qp05@ef@a7%x#o+t6vk6063py=vhdmut0j!8s4u"
)
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
ALLOWED_HOSTS = ["*"] ALLOWED_HOSTS = ["*"]
# Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.admin",
"django.contrib.auth", "django.contrib.auth",
@@ -61,6 +47,7 @@ MIDDLEWARE = [
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.middleware.csp.ContentSecurityPolicyMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
@@ -87,10 +74,6 @@ TEMPLATES = [
WSGI_APPLICATION = "ram.wsgi.application" WSGI_APPLICATION = "ram.wsgi.application"
# Database
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.sqlite3", "ENGINE": "django.db.backends.sqlite3",
@@ -98,54 +81,59 @@ DATABASES = {
} }
} }
# Password validation
# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", # noqa: E501
}, },
{ {
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", # noqa: E501
}, },
{ {
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", # noqa: E501
}, },
{ {
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", # noqa: E501
}, },
] ]
# Internationalization
# https://docs.djangoproject.com/en/4.0/topics/i18n/
LANGUAGE_CODE = "en-us" LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC" TIME_ZONE = "UTC"
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.0/howto/static-files/
STATIC_URL = "static/" STATIC_URL = "static/"
# Default primary key field type
# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
MEDIA_URL = "media/" MEDIA_URL = "media/"
MEDIA_ROOT = STORAGE_DIR / "media" MEDIA_ROOT = STORAGE_DIR / "media"
# Enforce a CSP policy:
CDN_WHITELIST_CSP = ["https://cdn.jsdelivr.net/"]
SECURE_CSP = {
"default-src": [CSP.SELF] + CDN_WHITELIST_CSP,
"img-src": ["data:", "*"],
"script-src": [
CSP.SELF,
"https://www.googletagmanager.com/",
]
+ CDN_WHITELIST_CSP,
"style-src": [CSP.SELF, CSP.UNSAFE_INLINE] + CDN_WHITELIST_CSP,
}
# Cookies hardening
SESSION_COOKIE_NAME = "__Secure-sessionid"
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True
CSRF_COOKIE_NAME = "__Secure-csrftoken"
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_HTTPONLY = True
# django-ram REST API settings
REST_ENABLED = False # Set to True to enable the REST API REST_ENABLED = False # Set to True to enable the REST API
REST_FRAMEWORK = { REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", # noqa: E501
"PAGE_SIZE": 5, "PAGE_SIZE": 5,
} }
@@ -171,7 +159,7 @@ COUNTRIES_OVERRIDE = {
"XX": "None", "XX": "None",
} }
SITE_NAME = "Railroad Assets Manger" SITE_NAME = "Railroad Assets Manager"
# Image used on cards without a custom image uploaded. # Image used on cards without a custom image uploaded.
# The file must be placed in the root of the 'static' folder # The file must be placed in the root of the 'static' folder
@@ -184,16 +172,17 @@ DECODER_INTERFACES = [
(0, "Built-in"), (0, "Built-in"),
(1, "NEM651"), (1, "NEM651"),
(2, "NEM652"), (2, "NEM652"),
(3, "PluX"), (3, "NEM658 (Plux16)"),
(4, "21MTC"), (6, "NEM658 (Plux22)"),
(5, "Next18/Next18S"), (4, "NEM660 (21MTC)"),
(5, "NEM662 (Next18/Next18S)"),
] ]
MANUFACTURER_TYPES = [ MANUFACTURER_TYPES = [
("model", "Model"), ("model", "Model"),
("real", "Real"), ("real", "Real"),
("accessory", "Accessory"), ("accessory", "Accessory"),
("other", "Other") ("other", "Other"),
] ]
ROLLING_STOCK_TYPES = [ ROLLING_STOCK_TYPES = [
@@ -206,6 +195,19 @@ ROLLING_STOCK_TYPES = [
FEATURED_ITEMS_MAX = 6 FEATURED_ITEMS_MAX = 6
# If True, use X-Accel-Redirect (Nginx)
# when using X-Accel-Redirect, we don't serve the file
# directly from Django, but let Nginx handle it
# in Nginx config, we need to map /private/ to
# the actual media files location with internal directive
# eg:
# location /private {
# internal;
# alias /path/to/media;
# }
# make also sure that the entire /media is _not_ mapped directly in Nginx
USE_X_ACCEL_REDIRECT = False
try: try:
from ram.local_settings import * from ram.local_settings import *
except ImportError: except ImportError:

View File

@@ -21,17 +21,22 @@ from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from ram.views import UploadImage from ram.views import UploadImage, DownloadFile
from portal.views import Render404 from portal.views import Render404
handler404 = Render404.as_view() handler404 = Render404.as_view()
urlpatterns = [ urlpatterns = [
path("", lambda r: redirect("portal/")), path("", lambda r: redirect("portal/")),
path("admin/", admin.site.urls),
path("tinymce/", include("tinymce.urls")), path("tinymce/", include("tinymce.urls")),
path("tinymce/upload_image", UploadImage.as_view(), name="upload_image"), path("tinymce/upload_image", UploadImage.as_view(), name="upload_image"),
path(
"media/files/<path:filename>",
DownloadFile.as_view(),
name="download_file",
),
path("portal/", include("portal.urls")), path("portal/", include("portal.urls")),
path("admin/", admin.site.urls),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# Enable the "/dcc" routing only if the "driver" app is active # Enable the "/dcc" routing only if the "driver" app is active
@@ -55,6 +60,7 @@ if settings.DEBUG:
if settings.REST_ENABLED: if settings.REST_ENABLED:
from django.views.generic import TemplateView from django.views.generic import TemplateView
from rest_framework.schemas import get_schema_view from rest_framework.schemas import get_schema_view
urlpatterns += [ urlpatterns += [
path( path(
"swagger/", "swagger/",

View File

@@ -5,19 +5,26 @@ import posixpath
from pathlib import Path from pathlib import Path
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from django.views import View from django.apps import apps
from django.conf import settings from django.conf import settings
from django.http import ( from django.http import (
Http404,
HttpResponse,
HttpResponseBadRequest, HttpResponseBadRequest,
HttpResponseForbidden, HttpResponseForbidden,
FileResponse,
JsonResponse, JsonResponse,
) )
from django.views import View
from django.utils.text import slugify as slugify from django.utils.text import slugify as slugify
from django.utils.encoding import smart_str
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from rest_framework.pagination import LimitOffsetPagination from rest_framework.pagination import LimitOffsetPagination
from ram.models import PrivateDocument
class CustomLimitOffsetPagination(LimitOffsetPagination): class CustomLimitOffsetPagination(LimitOffsetPagination):
default_limit = 10 default_limit = 10
@@ -67,3 +74,53 @@ class UploadImage(View):
), ),
} }
) )
class DownloadFile(View):
def get(self, request, filename, disposition="inline"):
# Clean up the filename to prevent directory traversal attacks
filename = os.path.basename(filename)
# Find a document where the stored file name matches
# Find all models inheriting from PublishableFile
for model in apps.get_models():
if issubclass(model, PrivateDocument) and not model._meta.abstract:
# Due to deduplication, multiple documents may have
# the same file name; if any is private, use a failsafe
# approach enforce access control
docs = model.objects.filter(file__endswith=filename)
if not docs.exists():
continue
if (
any(doc.private for doc in docs)
and not request.user.is_staff
):
break
file = docs.first().file
if not os.path.exists(file.path):
break
# in Nginx config, we need to map /private/ to
# the actual media files location with internal directive
# eg:
# location /private {
# internal;
# alias /path/to/media;
# }
if getattr(settings, "USE_X_ACCEL_REDIRECT", False):
response = HttpResponse()
response["Content-Type"] = ""
response["X-Accel-Redirect"] = f"/private/{file.name}"
else:
response = FileResponse(
open(file.path, "rb"), as_attachment=True
)
response["Content-Disposition"] = '{}; filename="{}"'.format(
disposition, smart_str(os.path.basename(file.path))
)
return response
raise Http404("File not found")

View File

@@ -2,8 +2,12 @@ import html
from django.conf import settings from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.utils.html import format_html, format_html_join, strip_tags from django.utils.html import (
format_html,
format_html_join,
strip_tags,
mark_safe,
)
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
from ram.utils import generate_csv from ram.utils import generate_csv
@@ -31,7 +35,7 @@ class RollingClassPropertyInline(admin.TabularInline):
class RollingClass(admin.ModelAdmin): class RollingClass(admin.ModelAdmin):
inlines = (RollingClassPropertyInline,) inlines = (RollingClassPropertyInline,)
autocomplete_fields = ("manufacturer",) autocomplete_fields = ("manufacturer",)
list_display = ("__str__", "type", "company", "country_flag") list_display = ("__str__", "identifier", "type", "company", "country_flag")
list_filter = ("company", "type__category", "type") list_filter = ("company", "type__category", "type")
search_fields = ( search_fields = (
"identifier", "identifier",
@@ -53,14 +57,14 @@ class RollingClass(admin.ModelAdmin):
class RollingStockDocInline(admin.TabularInline): class RollingStockDocInline(admin.TabularInline):
model = RollingStockDocument model = RollingStockDocument
min_num = 0 min_num = 0
extra = 0 extra = 1
classes = ["collapse"] classes = ["collapse"]
class RollingStockImageInline(SortableInlineAdminMixin, admin.TabularInline): class RollingStockImageInline(SortableInlineAdminMixin, admin.TabularInline):
model = RollingStockImage model = RollingStockImage
min_num = 0 min_num = 0
extra = 0 extra = 1
readonly_fields = ("image_thumbnail",) readonly_fields = ("image_thumbnail",)
classes = ["collapse"] classes = ["collapse"]
@@ -229,7 +233,7 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
def invoices(self, obj): def invoices(self, obj):
if obj.invoice.exists(): if obj.invoice.exists():
html = format_html_join( html = format_html_join(
"<br>", mark_safe("<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()),
) )

View File

@@ -0,0 +1,30 @@
# Generated by Django 6.0 on 2026-01-08 12:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("roster", "0039_rollingstock_featured"),
]
operations = [
migrations.AlterField(
model_name="rollingstock",
name="decoder_interface",
field=models.PositiveSmallIntegerField(
blank=True,
choices=[
(0, "Built-in"),
(1, "NEM651"),
(2, "NEM652"),
(3, "NEM658 (Plux16)"),
(6, "NEM658 (Plux22)"),
(4, "NEM660 (21MTC)"),
(5, "NEM662 (Next18/Next18S)"),
],
null=True,
),
),
]

View File

@@ -1,7 +1,7 @@
pytz pytz
pillow pillow
markdown markdown
Django Django>=6.0
djangorestframework djangorestframework
django-solo django-solo
django-countries django-countries