Compare commits

...

14 Commits

26 changed files with 276 additions and 144 deletions

1
.gitignore vendored
View File

@@ -129,7 +129,6 @@ dmypy.json
# node.js / npm stuff # node.js / npm stuff
node_modules node_modules
package.json
package-lock.json package-lock.json
# our own stuff # our own stuff

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

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

@@ -35,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,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) 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"

View File

@@ -3,4 +3,4 @@
* Copyright 2011-2023 The Bootstrap Authors * Copyright 2011-2023 The Bootstrap Authors
* Licensed under the Creative Commons Attribution 3.0 Unported License. * 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(){const e=window.location.hash.substring(1);if(e){const t=document.querySelector(`[data-bs-target="#nav-${e}"]`);t&&bootstrap.Tab.getOrCreateInstance(t).show()}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)})});const t=document.getElementById("tabSelector");t&&(t.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(e=>{e.addEventListener("shown.bs.tab",e=>{const a=e.target.getAttribute("data-bs-target");t.value=a.substring(1)})}))}); (()=>{"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

@@ -1,13 +1,19 @@
// use Bootstrap 5's Tab component to manage tab navigation and synchronize with URL hash
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
'use strict';
const selectElement = document.getElementById('tabSelector');
// code to handle tab selection and URL hash synchronization // code to handle tab selection and URL hash synchronization
const hash = window.location.hash.substring(1) // remove the '#' prefix const hash = window.location.hash.substring(1) // remove the '#' prefix
if (hash) { if (hash) {
const trigger = document.querySelector(`[data-bs-target="#nav-${hash}"]`); const target = `#nav-${hash}`;
const trigger = document.querySelector(`[data-bs-target="${target}"]`);
if (trigger) { if (trigger) {
bootstrap.Tab.getOrCreateInstance(trigger).show(); bootstrap.Tab.getOrCreateInstance(trigger).show();
selectElement.value = target; // keep the dropdown in sync
} }
} }
//
// update the URL hash when a tab is shown // update the URL hash when a tab is shown
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(btn => { document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(btn => {
btn.addEventListener('shown.bs.tab', event => { btn.addEventListener('shown.bs.tab', event => {
@@ -17,14 +23,12 @@ document.addEventListener("DOMContentLoaded", function () {
}); });
// allow tab selection via a dropdown on small screens // allow tab selection via a dropdown on small screens
const selectElement = document.getElementById('tabSelector');
if (!selectElement) return; if (!selectElement) return;
selectElement.addEventListener('change', function () { selectElement.addEventListener('change', function () {
const targetSelector = this.value; const target = this.value;
const triggerEl = document.querySelector(`[data-bs-target="#${targetSelector}"]`); const trigger = document.querySelector(`[data-bs-target="${target}"]`);
if (triggerEl) { if (trigger) {
// Use Bootstrap 5.3's API — ensures transitions + ARIA updates const tabInstance = bootstrap.Tab.getOrCreateInstance(trigger);
const tabInstance = bootstrap.Tab.getOrCreateInstance(triggerEl);
tabInstance.show(); tabInstance.show();
} }
}); });
@@ -33,7 +37,7 @@ document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll('[data-bs-toggle="tab"]').forEach(btn => { document.querySelectorAll('[data-bs-toggle="tab"]').forEach(btn => {
btn.addEventListener('shown.bs.tab', event => { btn.addEventListener('shown.bs.tab', event => {
const target = event.target.getAttribute('data-bs-target'); const target = event.target.getAttribute('data-bs-target');
selectElement.value = target.substring(1); // remove the '#' character selectElement.value = target;
}); });
}); });
}); });

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> <button class="btn btn-outline-primary" type="submit">Search</button>
</div> </div>
</form> </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 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>
@@ -23,9 +23,10 @@
<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.min.css" %}?v={{ site_conf.version }}" rel="stylesheet"> <link href="{% static "css/main.min.css" %}?v={{ site_conf.version }}" rel="stylesheet">
<script src="{% static "js/main.min.js" %}"></script> <script src="{% static "js/main.min.js" %}?v={{ site_conf.version }}"></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>

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

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

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

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

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

@@ -7,7 +7,7 @@ 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.urls import Resolver404 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.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
@@ -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): class GetData(View):
title = None title = None
template = "pagination.html" template = "pagination.html"
@@ -267,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()

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.9" 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

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

View File

@@ -85,39 +85,42 @@ class DownloadFile(View):
# Find all models inheriting from PublishableFile # Find all models inheriting from PublishableFile
for model in apps.get_models(): for model in apps.get_models():
if issubclass(model, PrivateDocument) and not model._meta.abstract: if issubclass(model, PrivateDocument) and not model._meta.abstract:
try: # Due to deduplication, multiple documents may have
doc = model.objects.get(file__endswith=filename) # the same file name; if any is private, use a failsafe
if doc.private and not request.user.is_staff: # approach enforce access control
break docs = model.objects.filter(file__endswith=filename)
if not docs.exists():
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:
continue 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") 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",
@@ -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