Compare commits

..

17 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
33 changed files with 415 additions and 247 deletions

5
.gitignore vendored
View File

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

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.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 ram.admin import publish, unpublish
@@ -29,7 +34,7 @@ from bookshelf.models import (
class BookImageInline(SortableInlineAdminMixin, admin.TabularInline):
model = BaseBookImage
min_num = 0
extra = 0
extra = 1
readonly_fields = ("image_thumbnail",)
classes = ["collapse"]
verbose_name = "Image"
@@ -47,7 +52,7 @@ class BookPropertyInline(admin.TabularInline):
class BookDocInline(admin.TabularInline):
model = BookDocument
min_num = 0
extra = 0
extra = 1
classes = ["collapse"]
@@ -149,7 +154,7 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
def invoices(self, obj):
if obj.invoice.exists():
html = format_html_join(
"<br>",
mark_safe("<br>"),
'<a href="{}" target="_blank">{}</a>',
((i.file.url, i) for i in obj.invoice.all()),
)
@@ -317,7 +322,7 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
def invoices(self, obj):
if obj.invoice.exists():
html = format_html_join(
"<br>",
mark_safe("<br>"),
'<a href="{}" target="_blank">{}</a>',
((i.file.url, i) for i in obj.invoice.all()),
)

View File

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

View File

@@ -24,7 +24,7 @@ class PropertyAdmin(admin.ModelAdmin):
class DecoderDocInline(admin.TabularInline):
model = DecoderDocument
min_num = 0
extra = 0
extra = 1
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):
name = models.CharField(max_length=128, unique=True)
name = models.CharField(max_length=128)
slug = models.CharField(max_length=128, unique=True, editable=False)
category = models.CharField(
max_length=64, choices=settings.MANUFACTURER_TYPES
@@ -46,6 +46,12 @@ class Manufacturer(SimpleBaseModel):
class Meta:
ordering = ["category", "slug"]
constraints = [
models.UniqueConstraint(
fields=["name", "category"],
name="unique_name_category"
)
]
def __str__(self):
return self.name

View File

@@ -35,7 +35,8 @@ class SiteConfigurationAdmin(SingletonModelAdmin):
"fields": (
"show_version",
"use_cdn",
"extra_head",
"extra_html",
"extra_js",
"rest_api",
"version",
),

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

@@ -39,7 +39,14 @@ class SiteConfiguration(SingletonModel):
disclaimer = tinymce.HTMLField(blank=True)
show_version = 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:
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

@@ -10,21 +10,3 @@
<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

@@ -9,7 +9,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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="generator" content="Django Framework">
<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-icons@1.13.1/font/bootstrap-icons.css" %}" rel="stylesheet">
{% endif %}
<link href="{% static "css/main.css" %}?v={{ site_conf.version }}" rel="stylesheet">
<style>
.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>
<link href="{% static "css/main.min.css" %}?v={{ site_conf.version }}" rel="stylesheet">
<script src="{% static "js/main.min.js" %}?v={{ site_conf.version }}"></script>
{% 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 %}
</head>
<body>

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 %}
</nav>
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
<option value="nav-summary" selected>Summary</option>
{% if data.toc.all %}<option value="nav-toc">Table of contents</option>{% endif %}
{% if documents %}<option value="nav-documents">Documents</option>{% endif %}
<option value="#nav-summary" selected>Summary</option>
{% if data.toc.all %}<option value="#nav-toc">Table of contents</option>{% endif %}
{% if documents %}<option value="#nav-documents">Documents</option>{% endif %}
</select>
<div class="tab-content" id="nav-tabContent">
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">

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>
</nav>
<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>
<div class="tab-content" id="nav-tabContent">
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">

View File

@@ -102,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>
</nav>
<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>
<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">

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 %}
</nav>
<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-model">Model</option>
<option value="nav-class">Class</option>
<option value="nav-company">Company</option>
{% 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 %}
{% if consists %}<option value="nav-consists">Consists</option>{% endif %}
<option value="#nav-summary" selected>Summary</option>
<option value="#nav-model">Model</option>
<option value="#nav-class">Class</option>
<option value="#nav-company">Company</option>
{% 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 %}
{% if consists %}<option value="#nav-consists">Consists</option>{% endif %}
</select>
{% with class=rolling_stock.rolling_class company=rolling_stock.rolling_class.company %}
<div class="tab-content" id="nav-tabContent">

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ from urllib.parse import unquote
from django.conf import settings
from django.views import View
from django.urls import Resolver404
from django.http import Http404, HttpResponseBadRequest
from django.http import Http404, HttpResponse, HttpResponseBadRequest
from django.db.utils import OperationalError, ProgrammingError
from django.db.models import F, Q, Count
from django.db.models.functions import Lower
@@ -78,6 +78,16 @@ class Render404(View):
)
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):
title = None
template = "pagination.html"
@@ -267,6 +277,9 @@ class SearchObjects(View):
return _filter, search
def get(self, request, search, page=1):
if not search:
return HttpResponseBadRequest()
try:
encoded_search = search
search = base64.b64decode(search.encode()).decode()
@@ -773,7 +786,7 @@ class GetMagazine(View):
return render(
request,
"magazine.html",
"bookshelf/magazine.html",
{
"title": 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
__version__ = "0.19.8"
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__)

View File

@@ -1,18 +1,9 @@
"""
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 django.utils.csp import CSP
# Build paths inside the project like this: BASE_DIR / 'subdir'.
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/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = (
"django-insecure-1fgtf05rwp0qp05@ef@a7%x#o+t6vk6063py=vhdmut0j!8s4u"
)
SECRET_KEY = "django-ram-insecure-Chang3m3-1n-Pr0duct10n!"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ["*"]
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
@@ -61,6 +47,7 @@ MIDDLEWARE = [
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.middleware.csp.ContentSecurityPolicyMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
@@ -87,10 +74,6 @@ TEMPLATES = [
WSGI_APPLICATION = "ram.wsgi.application"
# Database
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
DATABASES = {
"default": {
"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 = [
{
"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"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.0/howto/static-files/
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"
MEDIA_URL = "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_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", # noqa: E501
"PAGE_SIZE": 5,
}
@@ -171,7 +159,7 @@ COUNTRIES_OVERRIDE = {
"XX": "None",
}
SITE_NAME = "Railroad Assets Manger"
SITE_NAME = "Railroad Assets Manager"
# Image used on cards without a custom image uploaded.
# The file must be placed in the root of the 'static' folder
@@ -184,16 +172,17 @@ DECODER_INTERFACES = [
(0, "Built-in"),
(1, "NEM651"),
(2, "NEM652"),
(3, "PluX"),
(4, "21MTC"),
(5, "Next18/Next18S"),
(3, "NEM658 (Plux16)"),
(6, "NEM658 (Plux22)"),
(4, "NEM660 (21MTC)"),
(5, "NEM662 (Next18/Next18S)"),
]
MANUFACTURER_TYPES = [
("model", "Model"),
("real", "Real"),
("accessory", "Accessory"),
("other", "Other")
("other", "Other"),
]
ROLLING_STOCK_TYPES = [

View File

@@ -85,39 +85,42 @@ class DownloadFile(View):
# Find all models inheriting from PublishableFile
for model in apps.get_models():
if issubclass(model, PrivateDocument) and not model._meta.abstract:
try:
doc = model.objects.get(file__endswith=filename)
if doc.private and not request.user.is_staff:
break
file = doc.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
except model.DoesNotExist:
# 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.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 ram.utils import generate_csv
@@ -31,7 +35,7 @@ class RollingClassPropertyInline(admin.TabularInline):
class RollingClass(admin.ModelAdmin):
inlines = (RollingClassPropertyInline,)
autocomplete_fields = ("manufacturer",)
list_display = ("__str__", "type", "company", "country_flag")
list_display = ("__str__", "identifier", "type", "company", "country_flag")
list_filter = ("company", "type__category", "type")
search_fields = (
"identifier",
@@ -53,14 +57,14 @@ class RollingClass(admin.ModelAdmin):
class RollingStockDocInline(admin.TabularInline):
model = RollingStockDocument
min_num = 0
extra = 0
extra = 1
classes = ["collapse"]
class RollingStockImageInline(SortableInlineAdminMixin, admin.TabularInline):
model = RollingStockImage
min_num = 0
extra = 0
extra = 1
readonly_fields = ("image_thumbnail",)
classes = ["collapse"]
@@ -229,7 +233,7 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
def invoices(self, obj):
if obj.invoice.exists():
html = format_html_join(
"<br>",
mark_safe("<br>"),
'<a href="{}" target="_blank">{}</a>',
((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
pillow
markdown
Django
Django>=6.0
djangorestframework
django-solo
django-countries