28 Commits

Author SHA1 Message Date
3e69b9ae6e Remove a migration operation failing on Django 6.0 (safe) 2025-12-04 00:07:05 +01:00
66c3c3f51c Extend compatibility with Django 6.0 2025-12-03 23:24:53 +01:00
935c439084 Introduce compatibility with Django 6.0 2025-12-03 23:07:56 +01:00
d757388ca8 Restore missing DeduplicatedStorage for documents 2025-10-28 22:19:05 +01:00
536101d2ff Update bootstrap to 5.3.8 (#52) 2025-10-05 22:39:15 +02:00
955397acd5 Update site icon 2025-06-01 21:51:51 +02:00
672cadd7e1 Introduce symbols legend 2025-05-28 23:38:02 +02:00
464fe57536 Fix an abbr in DCC 2025-05-27 23:57:21 +02:00
bd16c7eee7 Still improve the DCC section 2025-05-27 23:49:22 +02:00
cc2e374558 Simplify cards, use icons for DCC 2025-05-26 00:04:07 +02:00
1c25ac9b14 Minor UI improvements 2025-05-25 19:13:28 +02:00
de126a735d Reverse field renaming 2025-05-24 15:00:52 +02:00
18b5ab8053 Bump DCC-EX submodule 2025-05-24 14:52:36 +02:00
3acc80e2ad Fix another counting issue 2025-05-24 14:44:42 +02:00
552ba39970 Upgrade bootstrap to 5.3.6 and icons to 1.13.1 2025-05-12 14:06:37 +02:00
222e2075ec Change the behavior of the modal 2025-05-12 13:51:31 +02:00
b5c57dcd94 Rely on slugs to have a more natural sorting 2025-05-04 22:46:23 +02:00
b81c63898f Add more information in consist_item rows 2025-05-04 22:05:47 +02:00
76b266b1f9 Extend search on company to slug field to better manage accented and utf names 2025-05-04 19:13:43 +02:00
86657a3b9f Manually fix a migration to correctly bootsrap a new DB 2025-05-04 19:12:50 +02:00
d0d25424fb Fix road_number_int field sizing 2025-05-04 19:12:05 +02:00
292b95b8ed Fix a bug in roster search via scale 2025-05-03 21:02:44 +02:00
dea7a594bc Implement CSV export for cosists 2025-05-02 22:25:59 +02:00
60195bc99f Simplify the logic about scales in the consist and remove async updates 2025-05-02 13:40:09 +02:00
7673f0514a Fix filter by scale counters and add consist constrains
Still to be improved, see FIXME
2025-05-01 23:49:22 +02:00
40f42a9ee9 Reformat portal/views.py 2025-04-30 22:52:51 +02:00
2e06e94fde Add counters to cards 2025-04-30 22:50:43 +02:00
ece8d1ad94 Minor UI improvement 2025-04-29 22:34:46 +02:00
50 changed files with 818 additions and 167 deletions

View File

@@ -2,7 +2,7 @@ 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, strip_tags from django.utils.html import format_html, format_html_join, strip_tags
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
from ram.admin import publish, unpublish from ram.admin import publish, unpublish
@@ -123,13 +123,14 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
@admin.display(description="Invoices") @admin.display(description="Invoices")
def invoices(self, obj): def invoices(self, obj):
if obj.invoice.exists(): if obj.invoice.exists():
html = "<br>".join( html = format_html_join(
"<a href=\"{}\" target=\"_blank\">{}</a>".format( "<br>",
i.file.url, i "<a href=\"{}\" target=\"_blank\">{}</a>",
) for i in obj.invoice.all()) ((i.file.url, i) for i in obj.invoice.all())
)
else: else:
html = "-" html = "-"
return format_html(html) return html
@admin.display(description="Publisher") @admin.display(description="Publisher")
def get_publisher(self, obj): def get_publisher(self, obj):
@@ -207,7 +208,7 @@ class PublisherAdmin(admin.ModelAdmin):
@admin.display(description="Country") @admin.display(description="Country")
def country_flag(self, obj): def country_flag(self, obj):
return format_html( return format_html(
'<img src="{}" /> {}'.format(obj.country.flag, obj.country.name) '<img src="{}" /> {}', obj.country.flag, obj.country.name
) )
@@ -285,13 +286,14 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
@admin.display(description="Invoices") @admin.display(description="Invoices")
def invoices(self, obj): def invoices(self, obj):
if obj.invoice.exists(): if obj.invoice.exists():
html = "<br>".join( html = format_html_join(
"<a href=\"{}\" target=\"_blank\">{}</a>".format( "<br>",
i.file.url, i "<a href=\"{}\" target=\"_blank\">{}</a>",
) for i in obj.invoice.all()) ((i.file.url, i) for i in obj.invoice.all())
)
else: else:
html = "-" html = "-"
return format_html(html) return html
def download_csv(modeladmin, request, queryset): def download_csv(modeladmin, request, queryset):
header = [ header = [

View File

@@ -101,10 +101,10 @@ class Migration(migrations.Migration):
model_name="basebook", model_name="basebook",
name="old_title", name="old_title",
), ),
migrations.RemoveField( # migrations.RemoveField(
model_name="basebook", # model_name="basebook",
name="old_authors", # name="old_authors",
), # ),
migrations.RemoveField( migrations.RemoveField(
model_name="basebook", model_name="basebook",
name="old_publisher", name="old_publisher",

View File

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

View File

@@ -0,0 +1,123 @@
# Generated by Django 6.0 on 2025-12-03 22:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0023_delete_basebookdocument"),
]
operations = [
migrations.AlterField(
model_name="basebook",
name="language",
field=models.CharField(
choices=[
("af", "Afrikaans"),
("ar", "Arabic"),
("ar-dz", "Algerian Arabic"),
("ast", "Asturian"),
("az", "Azerbaijani"),
("bg", "Bulgarian"),
("be", "Belarusian"),
("bn", "Bengali"),
("br", "Breton"),
("bs", "Bosnian"),
("ca", "Catalan"),
("ckb", "Central Kurdish (Sorani)"),
("cs", "Czech"),
("cy", "Welsh"),
("da", "Danish"),
("de", "German"),
("dsb", "Lower Sorbian"),
("el", "Greek"),
("en", "English"),
("en-au", "Australian English"),
("en-gb", "British English"),
("eo", "Esperanto"),
("es", "Spanish"),
("es-ar", "Argentinian Spanish"),
("es-co", "Colombian Spanish"),
("es-mx", "Mexican Spanish"),
("es-ni", "Nicaraguan Spanish"),
("es-ve", "Venezuelan Spanish"),
("et", "Estonian"),
("eu", "Basque"),
("fa", "Persian"),
("fi", "Finnish"),
("fr", "French"),
("fy", "Frisian"),
("ga", "Irish"),
("gd", "Scottish Gaelic"),
("gl", "Galician"),
("he", "Hebrew"),
("hi", "Hindi"),
("hr", "Croatian"),
("hsb", "Upper Sorbian"),
("ht", "Haitian Creole"),
("hu", "Hungarian"),
("hy", "Armenian"),
("ia", "Interlingua"),
("id", "Indonesian"),
("ig", "Igbo"),
("io", "Ido"),
("is", "Icelandic"),
("it", "Italian"),
("ja", "Japanese"),
("ka", "Georgian"),
("kab", "Kabyle"),
("kk", "Kazakh"),
("km", "Khmer"),
("kn", "Kannada"),
("ko", "Korean"),
("ky", "Kyrgyz"),
("lb", "Luxembourgish"),
("lt", "Lithuanian"),
("lv", "Latvian"),
("mk", "Macedonian"),
("ml", "Malayalam"),
("mn", "Mongolian"),
("mr", "Marathi"),
("ms", "Malay"),
("my", "Burmese"),
("nb", "Norwegian Bokmål"),
("ne", "Nepali"),
("nl", "Dutch"),
("nn", "Norwegian Nynorsk"),
("os", "Ossetic"),
("pa", "Punjabi"),
("pl", "Polish"),
("pt", "Portuguese"),
("pt-br", "Brazilian Portuguese"),
("ro", "Romanian"),
("ru", "Russian"),
("sk", "Slovak"),
("sl", "Slovenian"),
("sq", "Albanian"),
("sr", "Serbian"),
("sr-latn", "Serbian Latin"),
("sv", "Swedish"),
("sw", "Swahili"),
("ta", "Tamil"),
("te", "Telugu"),
("tg", "Tajik"),
("th", "Thai"),
("tk", "Turkmen"),
("tr", "Turkish"),
("tt", "Tatar"),
("udm", "Udmurt"),
("ug", "Uyghur"),
("uk", "Ukrainian"),
("ur", "Urdu"),
("uz", "Uzbek"),
("vi", "Vietnamese"),
("zh-hans", "Simplified Chinese"),
("zh-hant", "Traditional Chinese"),
],
default="en",
max_length=7,
),
),
]

View File

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

View File

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

View File

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

View File

@@ -2,12 +2,13 @@ import os
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.text import Truncator
from django.dispatch import receiver from django.dispatch import receiver
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from ram.models import BaseModel from ram.models import BaseModel
from ram.utils import DeduplicatedStorage from ram.utils import DeduplicatedStorage
from metadata.models import Company, Tag from metadata.models import Company, Scale, Tag
from roster.models import RollingStock from roster.models import RollingStock
@@ -26,6 +27,7 @@ class Consist(BaseModel):
blank=True, blank=True,
help_text="Era or epoch of the consist", help_text="Era or epoch of the consist",
) )
scale = models.ForeignKey(Scale, on_delete=models.CASCADE)
image = models.ImageField( image = models.ImageField(
upload_to=os.path.join("images", "consists"), upload_to=os.path.join("images", "consists"),
storage=DeduplicatedStorage, storage=DeduplicatedStorage,
@@ -82,7 +84,19 @@ class ConsistItem(models.Model):
return "{0}".format(self.rolling_stock) return "{0}".format(self.rolling_stock)
def clean(self): def clean(self):
if self.consist.published and not self.rolling_stock.published: rolling_stock = getattr(self, "rolling_stock", False)
if not rolling_stock:
return # exit if no inline are present
# FIXME this does not work when creating a new consist,
# because the consist is not saved yet and it must be moved
# to the admin form validation via InlineFormSet.clean()
consist = self.consist
if rolling_stock.scale != consist.scale:
raise ValidationError(
"The rolling stock and consist must be of the same scale."
)
if self.consist.published and not rolling_stock.published:
raise ValidationError( raise ValidationError(
"You must unpublish the the consist before using this item." "You must unpublish the the consist before using this item."
) )
@@ -94,9 +108,21 @@ class ConsistItem(models.Model):
def preview(self): def preview(self):
return self.rolling_stock.image.first().image_thumbnail(100) return self.rolling_stock.image.first().image_thumbnail(100)
@property
def manufacturer(self):
return Truncator(self.rolling_stock.manufacturer).chars(10)
@property
def item_number(self):
return self.rolling_stock.item_number
@property
def scale(self):
return self.rolling_stock.scale
@property @property
def type(self): def type(self):
return self.rolling_stock.rolling_class.type return self.rolling_stock.rolling_class.type.type
@property @property
def address(self): def address(self):

View File

@@ -54,7 +54,7 @@ class CompanyAdmin(admin.ModelAdmin):
@admin.display(description="Country") @admin.display(description="Country")
def country_flag(self, obj): def country_flag(self, obj):
return format_html( return format_html(
'<img src="{}" /> {}'.format(obj.country.flag, obj.country.name) '<img src="{}" /> {}', obj.country.flag, obj.country.name
) )
@@ -68,7 +68,7 @@ class ManufacturerAdmin(admin.ModelAdmin):
@admin.display(description="Country") @admin.display(description="Country")
def country_flag(self, obj): def country_flag(self, obj):
return format_html( return format_html(
'<img src="{}" /> {}'.format(obj.country.flag, obj.country.name) '<img src="{}" /> {}', obj.country.flag, obj.country.name
) )

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,12 +27,12 @@
<th colspan="2" scope="row"> <th colspan="2" scope="row">
Consist Consist
<div class="float-end"> <div class="float-end">
{% if not d.item.published %}
<span class="badge text-bg-warning">Unpublished</span>
{% endif %}
{% if d.item.company.freelance %} {% if d.item.company.freelance %}
<span class="badge text-bg-secondary">Freelance</span> <span class="badge text-bg-secondary">Freelance</span>
{% endif %} {% endif %}
{% if not d.item.published %}
<span class="badge text-bg-warning">Draft</span>
{% endif %}
</div> </div>
</th> </th>
</tr> </tr>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@
</div> </div>
<!-- Modal --> <!-- Modal -->
<div class="modal fade" id="disclaimerModal" tabindex="-1" aria-labelledby="disclaimerLabel" aria-hidden="true"> <div class="modal fade" id="disclaimerModal" tabindex="-1" aria-labelledby="disclaimerLabel" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog modal-dialog-scrollable modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h1 class="modal-title fs-5" id="disclaimerLabel">Disclaimer</h1> <h1 class="modal-title fs-5" id="disclaimerLabel">Disclaimer</h1>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ class Document(models.Model):
description = models.CharField(max_length=128, blank=True) description = models.CharField(max_length=128, blank=True)
file = models.FileField( file = models.FileField(
upload_to="files/", upload_to="files/",
storage=DeduplicatedStorage,
) )
creation_time = models.DateTimeField(auto_now_add=True) creation_time = models.DateTimeField(auto_now_add=True)
updated_time = models.DateTimeField(auto_now=True) updated_time = models.DateTimeField(auto_now=True)

View File

@@ -13,7 +13,7 @@ class DeduplicatedStorage(FileSystemStorage):
""" """
A derived FileSystemStorage class that compares already existing files A derived FileSystemStorage class that compares already existing files
(with the same name) with new uploaded ones and stores new file only if (with the same name) with new uploaded ones and stores new file only if
sha256 hash on is content is different sha256 hash on its content is different
""" """
def save(self, name, content, max_length=None): def save(self, name, content, max_length=None):
@@ -48,8 +48,9 @@ def git_suffix(fname):
def get_image_preview(url, max_size=150): def get_image_preview(url, max_size=150):
return format_html( return format_html(
'<img src="{src}" style="max-width: {size}px; max-height: {size}px;' '<img src="{src}" style="max-width: {size}px; max-height: {size}px; background-color: #eee;" />', # noqa: E501
'background-color: #eee;" />'.format(src=url, size=max_size) src=url,
size=max_size,
) )

View File

@@ -0,0 +1,49 @@
# Generated by Django 5.2.7 on 2025-10-28 21:17
import ram.utils
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("repository", "0002_invoicedocument_remove_basebookdocument_book_and_more"),
]
operations = [
migrations.AlterField(
model_name="bookdocument",
name="file",
field=models.FileField(
storage=ram.utils.DeduplicatedStorage, upload_to="files/"
),
),
migrations.AlterField(
model_name="catalogdocument",
name="file",
field=models.FileField(
storage=ram.utils.DeduplicatedStorage, upload_to="files/"
),
),
migrations.AlterField(
model_name="decoderdocument",
name="file",
field=models.FileField(
storage=ram.utils.DeduplicatedStorage, upload_to="files/"
),
),
migrations.AlterField(
model_name="genericdocument",
name="file",
field=models.FileField(
storage=ram.utils.DeduplicatedStorage, upload_to="files/"
),
),
migrations.AlterField(
model_name="rollingstockdocument",
name="file",
field=models.FileField(
storage=ram.utils.DeduplicatedStorage, upload_to="files/"
),
),
]

View File

@@ -2,7 +2,7 @@ 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, strip_tags from django.utils.html import format_html, format_html_join, strip_tags
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
@@ -36,6 +36,7 @@ class RollingClass(admin.ModelAdmin):
search_fields = ( search_fields = (
"identifier", "identifier",
"company__name", "company__name",
"company__slug",
"type__type", "type__type",
) )
save_as = True save_as = True
@@ -43,7 +44,7 @@ class RollingClass(admin.ModelAdmin):
@admin.display(description="Country") @admin.display(description="Country")
def country_flag(self, obj): def country_flag(self, obj):
return format_html( return format_html(
'<img src="{}" /> {}'.format(obj.country.flag, obj.country) '<img src="{}" /> {}', obj.country.flag, obj.country.name
) )
@@ -139,7 +140,9 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
search_fields = ( search_fields = (
"rolling_class__identifier", "rolling_class__identifier",
"rolling_class__company__name", "rolling_class__company__name",
"rolling_class__company__slug",
"manufacturer__name", "manufacturer__name",
"scale__scale",
"road_number", "road_number",
"address", "address",
"item_number", "item_number",
@@ -149,7 +152,7 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
@admin.display(description="Country") @admin.display(description="Country")
def country_flag(self, obj): def country_flag(self, obj):
return format_html( return format_html(
'<img src="{}" /> {}'.format(obj.country.flag, obj.country) '<img src="{}" /> {}', obj.country.flag, obj.country.name
) )
fieldsets = ( fieldsets = (
@@ -219,19 +222,23 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
@admin.display(description="Invoices") @admin.display(description="Invoices")
def invoices(self, obj): def invoices(self, obj):
if obj.invoice.exists(): if obj.invoice.exists():
html = "<br>".join( html = format_html_join(
"<a href=\"{}\" target=\"_blank\">{}</a>".format( "<br>",
i.file.url, i "<a href=\"{}\" target=\"_blank\">{}</a>",
) for i in obj.invoice.all()) ((i.file.url, i) for i in obj.invoice.all())
)
else: else:
html = "-" html = "-"
return format_html(html) return html
def download_csv(modeladmin, request, queryset): def download_csv(modeladmin, request, queryset):
header = [ header = [
"ID",
"Name", "Name",
"Class",
"Type",
"Company", "Company",
"Identifier", "Country",
"Road Number", "Road Number",
"Manufacturer", "Manufacturer",
"Scale", "Scale",
@@ -258,9 +265,12 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
) )
data.append( data.append(
[ [
obj.uuid,
obj.__str__(), obj.__str__(),
obj.rolling_class.company.name,
obj.rolling_class.identifier, obj.rolling_class.identifier,
obj.rolling_class.type,
obj.rolling_class.company.name,
obj.rolling_class.company.country,
obj.road_number, obj.road_number,
obj.manufacturer.name, obj.manufacturer.name,
obj.scale.scale, obj.scale.scale,

View File

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

View File

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

View File

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