Compare commits

..

5 Commits

20 changed files with 254 additions and 191 deletions

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
def length(self):
return self.consist_item.count()
return self.consist_item.filter(load=False).count()
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")
).values(
"type"
@@ -69,6 +69,7 @@ class ConsistItem(models.Model):
Consist, on_delete=models.CASCADE, related_name="consist_item"
)
rolling_stock = models.ForeignKey(RollingStock, on_delete=models.CASCADE)
load = models.BooleanField(default=False)
order = models.PositiveIntegerField(blank=False, null=False)
class Meta:
@@ -92,10 +93,15 @@ class ConsistItem(models.Model):
# because the consist is not saved yet and it must be moved
# to the admin form validation via InlineFormSet.clean()
consist = self.consist
if rolling_stock.scale != consist.scale:
# Scale must match, but allow loads of any scale
if rolling_stock.scale != consist.scale and not self.load:
raise ValidationError(
"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:
raise ValidationError(
"You must unpublish the the consist before using this item."

View File

@@ -20,6 +20,7 @@ class SiteConfigurationAdmin(SingletonModelAdmin):
"about",
"items_per_page",
"items_ordering",
"featured_items_ordering",
"currency",
"footer",
"footer_extended",

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

@@ -22,14 +22,17 @@ class SiteConfiguration(SingletonModel):
default="6",
)
items_ordering = models.CharField(
max_length=10,
max_length=11,
choices=[
("type", "By rolling stock type"),
("company", "By company name"),
("identifier", "By rolling stock class"),
("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",
)
featured_items_ordering = items_ordering.clone()
currency = models.CharField(max_length=3, default="EUR")
footer = tinymce.HTMLField(blank=True)
footer_extended = tinymce.HTMLField(blank=True)

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

@@ -148,7 +148,7 @@
<strong>{{ site_conf.site_name }}</strong>
</a>
</div>
{% include 'includes/login.html' %}
{% include '_includes/login.html' %}
</div>
</nav>
</header>
@@ -186,7 +186,7 @@
{% show_bookshelf_menu %}
{% show_flatpages_menu user %}
</ul>
{% include 'includes/search.html' %}
{% include '_includes/search.html' %}
</div>
</div>
</nav>
@@ -211,9 +211,9 @@
<div class="container">{% block pagination %}{% endblock %}</div>
</div>
{% block extra_content %}{% endblock %}
{% include 'includes/symbols.html' %}
{% include '_includes/symbols.html' %}
</main>
{% include 'includes/footer.html' %}
{% include '_includes/footer.html' %}
{% if site_conf.use_cdn %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
{% else %}

View File

@@ -147,49 +147,8 @@
{% endif %}
</tbody>
</table>
{% if request.user.is_staff %}
<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 %}
{% 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 %}
{% include "_modules/purchase_data.html" %}
{% include "_modules/properties.html" %}
</div>
<div class="tab-pane table-responsive" id="nav-toc" role="tabpanel" aria-labelledby="nav-toc-tab">
<table class="table table-striped">
@@ -216,22 +175,7 @@
</table>
</div>
<div class="tab-pane" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
<table class="table table-striped">
<thead>
<tr>
<th colspan="3" scope="row">Documents</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for d in documents.all %}
<tr>
<td class="w-33">{{ d.description }}</td>
<td><a href="{{ d.file.url }}" target="_blank">{{ d.filename }}</a></td>
<td class="text-end">{{ d.file.size | filesizeformat }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% include "_modules/documents.html" %}
</div>
</div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end">

View File

@@ -7,11 +7,11 @@
{{ t.name }}</a>{# new line is required #}
{% endfor %}
</p>
{% endif %}
{% if not consist.published %}
<span class="badge text-bg-warning">Unpublished</span> |
{% endif %}
<small class="text-body-secondary">Updated {{ consist.updated_time | date:"M d, Y H:i" }}</small>
{% endif %}
{% endblock %}
{% block carousel %}
{% if consist.image %}
@@ -26,6 +26,33 @@
</div>
{% endif %}
{% 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>
<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>
{% endblock %}
{% block pagination %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation">
@@ -76,7 +103,7 @@
<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">
<div class="tab-pane show active table-responsive" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
<table class="table table-striped">
<thead>
<tr>
@@ -113,7 +140,7 @@
</tr>
<tr>
<th scope="row">Composition</th>
<td>{% for t in consist.get_type_count %}{{ t.count }}x {{ t.type }} {{t.category }}{% if not forloop.last %} &raquo; {% endif %}{% endfor %}</td>
<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>
</tbody>
</table>

View File

@@ -217,49 +217,8 @@
{% endif %}
</tbody>
</table>
{% if request.user.is_staff %}
<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>
{{ 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 %}
{% include "_modules/purchase_data.html" with data=rolling_stock %}
{% include "_modules/properties.html" %}
</div>
<div class="tab-pane" id="nav-class" role="tabpanel" aria-labelledby="nav-class-tab">
<table class="table table-striped">
@@ -296,23 +255,7 @@
{% endif %}
</tbody>
</table>
{% if 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 %}
{% include "_modules/properties.html" with properties=class_properties %}
</div>
<div class="tab-pane" id="nav-company" role="tabpanel" aria-labelledby="nav-company-tab">
<table class="table table-striped">
@@ -402,43 +345,9 @@
</tbody>
</table>
</div>
<div class="tab-pane" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
{% if documents %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="3" scope="row">Documents</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for d in documents.all %}
<tr>
<td class="w-33">{{ d.description }}</td>
<td><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 %}
{% if decoder_documents %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="3" scope="row">Decoder documents</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for d in decoder_documents.all %}
<tr>
<td class="w-33">{{ d.description }}</td>
<td><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 %}
<div class="tab-pane table-responsive" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
{% include "_modules/documents.html" %}
{% include "_modules/documents.html" with documents=decoder_documents header="Decoder documents" %}
</div>
<div class="tab-pane" id="nav-journal" role="tabpanel" aria-labelledby="nav-journal-tab">
<table class="table table-striped">

View File

@@ -36,25 +36,29 @@ def get_items_per_page():
return int(items_per_page)
def get_order_by_field():
def get_items_ordering(config="items_ordering"):
try:
order_by = get_site_conf().items_ordering
order_by = getattr(get_site_conf(), config)
except (OperationalError, ProgrammingError):
order_by = "type"
fields = [
"rolling_class__type",
"rolling_class__company",
"rolling_class__identifier",
"road_number_int",
"rolling_class__type", # 0
"rolling_class__company", # 1
"rolling_class__company__country", # 2
"rolling_class__identifier", # 3
"road_number_int", # 4
]
if order_by == "type":
return (fields[0], fields[1], fields[2], fields[3])
elif order_by == "company":
return (fields[1], fields[0], fields[2], fields[3])
elif order_by == "identifier":
return (fields[2], fields[0], fields[1], fields[3])
order_map = {
"type": (0, 1, 3, 4),
"company": (1, 0, 3, 4),
"country": (2, 0, 1, 3, 4),
"cou+com": (2, 1, 0, 3, 4),
"class": (0, 3, 1, 4),
}
return tuple(fields[i] for i in order_map.get(order_by, "type"))
class Render404(View):
@@ -70,7 +74,7 @@ class GetData(View):
def get_data(self, request):
return (
RollingStock.objects.get_published(request.user)
.order_by(*get_order_by_field())
.order_by(*get_items_ordering())
.filter(self.filter)
)
@@ -107,7 +111,9 @@ class GetHome(GetData):
return (
RollingStock.objects.get_published(request.user)
.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)
@@ -174,7 +180,7 @@ class SearchObjects(View):
RollingStock.objects.get_published(request.user)
.filter(query)
.distinct()
.order_by(*get_order_by_field())
.order_by(*get_items_ordering())
)
data = list(roster)
@@ -301,7 +307,7 @@ class GetManufacturerItem(View):
if search != "all":
roster = get_list_or_404(
RollingStock.objects.get_published(request.user).order_by(
*get_order_by_field()
*get_items_ordering()
),
Q(
Q(manufacturer=manufacturer)
@@ -323,7 +329,7 @@ class GetManufacturerItem(View):
| Q(rolling_class__manufacturer=manufacturer)
)
.distinct()
.order_by(*get_order_by_field())
.order_by(*get_items_ordering())
)
catalogs = Catalog.objects.get_published(request.user).filter(
manufacturer=manufacturer
@@ -376,7 +382,7 @@ class GetObjectsFiltered(View):
RollingStock.objects.get_published(request.user)
.filter(query)
.distinct()
.order_by(*get_order_by_field())
.order_by(*get_items_ordering())
)
data = list(roster)
@@ -480,7 +486,7 @@ class GetRollingStock(View):
& Q(set=True)
)
)
.order_by(*get_order_by_field())
.order_by(*get_items_ordering())
)
return render(
@@ -520,7 +526,13 @@ class GetConsist(View):
RollingStock.objects.get_published(request.user).get(
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())
data = paginator.get_page(page)
@@ -535,6 +547,7 @@ class GetConsist(View):
"title": consist,
"consist": consist,
"data": data,
"loads": loads,
"page_range": page_range,
},
)

View File

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