33 Commits

Author SHA1 Message Date
9cb3fb1d8a Replace ckeditor with tinymce due to deprecation 2024-02-17 17:06:28 +01:00
ed8ffb5ece Fix a RuntimeWarning introduced in Django 5 2024-01-20 22:05:15 +01:00
8400a5acd3 Add a sample background to sample_data 2023-11-12 15:30:13 +01:00
7dadf23f5f Make pylibmc optional in requirements-prod.txt 2023-11-04 23:58:51 +01:00
4a12201d22 Make Document and Image files not nullable 2023-11-04 23:54:56 +01:00
830da80302 Keep media folder clean (#28)
* Reorg roster, portal and bookshelf media
* Extend media reorg to consists
* Delete roster and bookshelf images on delte.
   Do not delete others data that might be dedup! 
* Bump version
2023-10-31 11:16:55 +01:00
416ca5bbc6 eu.gif is part of dajngo-countries 2023-10-28 14:00:52 +02:00
03fc82c38d Enable csrf protection 2023-10-28 13:56:43 +02:00
ec8684dbc0 Add a "None" country and "Europe" with flags 2023-10-28 13:55:21 +02:00
7ec8baf733 Replace \t with spaces in base.html 2023-10-28 09:29:11 +02:00
86589ad718 More w3c minor fixes 2023-10-27 23:20:36 +02:00
98fed02a40 Fix a table in rollingstock.html 2023-10-27 23:16:23 +02:00
9602f67e0e Remove a spurious tag 2023-10-27 23:14:09 +02:00
5bb6279095 Extend UX improvements on other pages 2023-10-27 23:11:21 +02:00
84cdee42a6 Fix html syntax in rollingstock.html 2023-10-27 22:58:24 +02:00
168b424df7 Bump version 2023-10-27 22:46:19 +02:00
e1400fe720 Remove health page 2023-10-27 22:26:24 +02:00
26dea2fb35 Improve rollingstock page UX on mobile 2023-10-27 22:26:05 +02:00
ef767ec33d Fix a pretty-print on companies 2023-10-23 18:54:57 +02:00
b23801dbf0 Clear cache on save if active 2023-10-21 21:42:03 +02:00
c7fa54e90e Rename roster methods in portal view 2023-10-17 22:46:55 +02:00
9164ba494f Update examples to implement caching 2023-10-17 22:40:31 +02:00
97989c3384 Improve UX and filtering 2023-10-17 13:44:30 +02:00
7865bf04f0 Add consists view in rolling stock and them in company filter 2023-10-16 22:48:46 +02:00
e6f1480894 Change login menu icon on mobile 2023-10-12 22:33:55 +02:00
8d8ede4c06 Improve page layout on mobile 2023-10-11 22:39:29 +02:00
87e1107156 Bugfixing (#27)
* Enforce ordering on some metadata models
* Fix a 500 error while accessing flat pages
* Clean up HTML and fix cards (missing class)
* Make the "driver" app optional and disabled by default
2023-10-10 22:17:21 +02:00
448ecae070 Add Python 3.12 flow 2023-10-09 23:17:00 +02:00
2b0fdc4487 Workaround for python 3.12 on Fedora 39 2023-10-09 23:16:06 +02:00
764240d67a Fix bookshelf default sorting 2023-10-09 23:09:05 +02:00
424b17ae58 Bug fixing for consists 2023-10-08 09:52:38 +02:00
c73efb01e4 Introduce private docs and flatpages preview (#26)
* Add support for private documents
* Fix migrations after merge
* Rebase fixtures
* Filter private decoder docs
* Enable preview of unpublished pages
2023-10-07 22:38:20 +02:00
a21baac10c Fix a dependency on solo during bootstrap 2023-10-06 21:37:24 +02:00
55 changed files with 1117 additions and 192 deletions

View File

@@ -13,7 +13,7 @@ jobs:
strategy:
max-parallel: 2
matrix:
python-version: ['3.9', '3.10', '3.11']
python-version: ['3.9', '3.10', '3.11', '3.12']
steps:
- uses: actions/checkout@v3

View File

@@ -0,0 +1,20 @@
# Generated by Django 4.2.6 on 2023-10-09 21:08
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0007_alter_book_options"),
]
operations = [
migrations.AlterModelOptions(
name="author",
options={"ordering": ["last_name", "first_name"]},
),
migrations.AlterModelOptions(
name="publisher",
options={"ordering": ["name"]},
),
]

View File

@@ -0,0 +1,49 @@
# Generated by Django 4.2.6 on 2023-10-30 13:16
import os
import sys
import shutil
import ram.utils
import bookshelf.models
from django.db import migrations, models
from django.conf import settings
def move_images(apps, schema_editor):
sys.stdout.write("\n Processing files. Please await...")
for r in bookshelf.models.BookImage.objects.all():
fname = os.path.basename(r.image.path)
new_image = bookshelf.models.book_image_upload(r, fname)
new_path = os.path.join(settings.MEDIA_ROOT, new_image)
os.makedirs(os.path.dirname(new_path), exist_ok=True)
try:
shutil.move(r.image.path, new_path)
except FileNotFoundError:
sys.stderr.write(" !! FileNotFoundError: {}\n".format(new_image))
pass
r.image.name = new_image
r.save()
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0008_alter_author_options_alter_publisher_options"),
]
operations = [
migrations.AlterField(
model_name="bookimage",
name="image",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to=bookshelf.models.book_image_upload,
),
),
migrations.RunPython(
move_images,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 4.2.6 on 2023-11-04 22:53
import bookshelf.models
from django.db import migrations, models
import ram.utils
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0009_alter_bookimage_image"),
]
operations = [
migrations.AlterField(
model_name="bookimage",
name="image",
field=models.ImageField(
storage=ram.utils.DeduplicatedStorage,
upload_to=bookshelf.models.book_image_upload,
),
),
]

View File

@@ -0,0 +1,121 @@
# Generated by Django 5.0.1 on 2024-01-20 21:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0010_alter_bookimage_image"),
]
operations = [
migrations.AlterField(
model_name="book",
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"),
("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

@@ -0,0 +1,19 @@
# Generated by Django 5.0.2 on 2024-02-17 12:19
import tinymce.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0011_alter_book_language"),
]
operations = [
migrations.AlterField(
model_name="book",
name="notes",
field=tinymce.models.HTMLField(blank=True),
),
]

View File

@@ -1,10 +1,12 @@
import os
import shutil
from uuid import uuid4
from django.db import models
from django.conf import settings
from django.urls import reverse
from django_countries.fields import CountryField
from ckeditor_uploader.fields import RichTextUploadingField
from tinymce import models as tinymce
from metadata.models import Tag
from ram.utils import DeduplicatedStorage
@@ -16,6 +18,9 @@ class Publisher(models.Model):
country = CountryField(blank=True)
website = models.URLField(blank=True)
class Meta:
ordering = ["name"]
def __str__(self):
return self.name
@@ -24,6 +29,9 @@ class Author(models.Model):
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
class Meta:
ordering = ["last_name", "first_name"]
def __str__(self):
return f"{self.last_name}, {self.first_name}"
@@ -48,7 +56,7 @@ class Book(models.Model):
tags = models.ManyToManyField(
Tag, related_name="bookshelf", blank=True
)
notes = RichTextUploadingField(blank=True)
notes = tinymce.HTMLField(blank=True)
creation_time = models.DateTimeField(auto_now_add=True)
updated_time = models.DateTimeField(auto_now=True)
@@ -64,16 +72,32 @@ class Book(models.Model):
def get_absolute_url(self):
return reverse("book", kwargs={"uuid": self.uuid})
def delete(self, *args, **kwargs):
shutil.rmtree(
os.path.join(
settings.MEDIA_ROOT, "images", "books", str(self.uuid)
),
ignore_errors=True
)
super(Book, self).delete(*args, **kwargs)
def book_image_upload(instance, filename):
return os.path.join(
"images",
"books",
str(instance.book.uuid),
filename
)
class BookImage(Image):
book = models.ForeignKey(
Book, on_delete=models.CASCADE, related_name="image"
)
image = models.ImageField(
upload_to="images/books/", # FIXME, find a better way to replace this
upload_to=book_image_upload,
storage=DeduplicatedStorage,
null=True,
blank=True
)

View File

@@ -0,0 +1,51 @@
# Generated by Django 4.2.6 on 2023-10-31 09:41
import os
import sys
import shutil
import ram.utils
from django.conf import settings
from django.db import migrations, models
def move_images(apps, schema_editor):
sys.stdout.write("\n Processing files. Please await...")
model = apps.get_model("consist", "Consist")
for r in model.objects.all():
if not r.image: # exit the loop if there's no image
continue
fname = os.path.basename(r.image.path)
new_image = os.path.join("images", "consists", fname)
new_path = os.path.join(settings.MEDIA_ROOT, new_image)
os.makedirs(os.path.dirname(new_path), exist_ok=True)
try:
shutil.move(r.image.path, new_path)
except FileNotFoundError:
sys.stderr.write(" !! FileNotFoundError: {}\n".format(new_image))
pass
r.image.name = new_image
r.save()
class Migration(migrations.Migration):
dependencies = [
("consist", "0008_alter_consist_options"),
]
operations = [
migrations.AlterField(
model_name="consist",
name="image",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to="images/consists",
),
),
migrations.RunPython(
move_images,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.0.2 on 2024-02-17 12:19
import tinymce.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("consist", "0009_alter_consist_image"),
]
operations = [
migrations.AlterField(
model_name="consist",
name="notes",
field=tinymce.models.HTMLField(blank=True),
),
]

View File

@@ -1,8 +1,10 @@
import os
from uuid import uuid4
from django.db import models
from django.urls import reverse
from ckeditor_uploader.fields import RichTextUploadingField
from tinymce import models as tinymce
from ram.utils import DeduplicatedStorage
from metadata.models import Company, Tag
@@ -19,9 +21,12 @@ class Consist(models.Model):
company = models.ForeignKey(Company, on_delete=models.CASCADE)
era = models.CharField(max_length=32, blank=True)
image = models.ImageField(
upload_to="images/", storage=DeduplicatedStorage, null=True, blank=True
upload_to=os.path.join("images", "consists"),
storage=DeduplicatedStorage,
null=True,
blank=True,
)
notes = RichTextUploadingField(blank=True)
notes = tinymce.HTMLField(blank=True)
creation_time = models.DateTimeField(auto_now_add=True)
updated_time = models.DateTimeField(auto_now=True)

View File

@@ -15,6 +15,7 @@ from metadata.models import (
@admin.register(Property)
class PropertyAdmin(admin.ModelAdmin):
list_display = ("name", "private")
search_fields = ("name",)

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.5 on 2023-10-06 19:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("metadata", "0012_alter_decoder_manufacturer_decoderdocument"),
]
operations = [
migrations.AddField(
model_name="decoderdocument",
name="private",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 4.2.6 on 2023-10-10 12:44
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("metadata", "0013_decoderdocument_private"),
]
operations = [
migrations.AlterModelOptions(
name="decoder",
options={"ordering": ["manufacturer", "name"]},
),
migrations.AlterModelOptions(
name="tag",
options={"ordering": ["name"]},
),
]

View File

@@ -0,0 +1,80 @@
# Generated by Django 4.2.6 on 2023-10-30 13:16
import os
import sys
import shutil
import ram.utils
from django.conf import settings
from django.db import migrations, models
def move_images(apps, schema_editor):
fields = {
"Company": ["companies", "logo"],
"Decoder": ["decoders", "image"],
"Manufacturer": ["manufacturers", "logo"],
}
sys.stdout.write("\n Processing files. Please await...")
for m in fields.items():
model = apps.get_model("metadata", m[0])
for r in model.objects.all():
field = getattr(r, m[1][1])
if not field: # exit the loop if there's no image
continue
fname = os.path.basename(field.path)
new_image = os.path.join("images", m[1][0], fname)
new_path = os.path.join(settings.MEDIA_ROOT, new_image)
os.makedirs(os.path.dirname(new_path), exist_ok=True)
try:
shutil.move(field.path, new_path)
except FileNotFoundError:
sys.stderr.write(
" !! FileNotFoundError: {}\n".format(new_image)
)
pass
field.name = new_image
r.save()
class Migration(migrations.Migration):
dependencies = [
("metadata", "0014_alter_decoder_options_alter_tag_options"),
]
operations = [
migrations.AlterField(
model_name="company",
name="logo",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to="images/companies",
),
),
migrations.AlterField(
model_name="decoder",
name="image",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to="images/decoders",
),
),
migrations.AlterField(
model_name="manufacturer",
name="logo",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to="images/manufacturers",
),
),
migrations.RunPython(
move_images,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 4.2.6 on 2023-11-04 22:53
from django.db import migrations, models
import ram.utils
class Migration(migrations.Migration):
dependencies = [
("metadata", "0015_alter_company_logo_alter_decoder_image_and_more"),
]
operations = [
migrations.AlterField(
model_name="decoderdocument",
name="file",
field=models.FileField(
storage=ram.utils.DeduplicatedStorage(), upload_to="files/"
),
),
]

View File

@@ -1,3 +1,4 @@
import os
from django.db import models
from django.urls import reverse
from django.conf import settings
@@ -28,7 +29,10 @@ class Manufacturer(models.Model):
)
website = models.URLField(blank=True)
logo = models.ImageField(
upload_to="images/", storage=DeduplicatedStorage, null=True, blank=True
upload_to=os.path.join("images", "manufacturers"),
storage=DeduplicatedStorage,
null=True,
blank=True,
)
class Meta:
@@ -58,7 +62,10 @@ class Company(models.Model):
country = CountryField()
freelance = models.BooleanField(default=False)
logo = models.ImageField(
upload_to="images/", storage=DeduplicatedStorage, null=True, blank=True
upload_to=os.path.join("images", "companies"),
storage=DeduplicatedStorage,
null=True,
blank=True,
)
class Meta:
@@ -76,6 +83,9 @@ class Company(models.Model):
}
)
def extended_name_pp(self):
return "({})".format(self.extended_name) if self.extended_name else ""
def logo_thumbnail(self):
return get_image_preview(self.logo.url)
@@ -92,9 +102,15 @@ class Decoder(models.Model):
version = models.CharField(max_length=64, blank=True)
sound = models.BooleanField(default=False)
image = models.ImageField(
upload_to="images/", storage=DeduplicatedStorage, null=True, blank=True
upload_to=os.path.join("images", "decoders"),
storage=DeduplicatedStorage,
null=True,
blank=True,
)
class Meta(object):
ordering = ["manufacturer", "name"]
def __str__(self):
return "{0} - {1}".format(self.manufacturer, self.name)
@@ -163,6 +179,9 @@ class Tag(models.Model):
name = models.CharField(max_length=128, unique=True)
slug = models.CharField(max_length=128, unique=True)
class Meta(object):
ordering = ["name"]
def __str__(self):
return self.name

View File

@@ -6,6 +6,7 @@ from portal.models import SiteConfiguration, Flatpage
@admin.register(SiteConfiguration)
class SiteConfigurationAdmin(SingletonModelAdmin):
readonly_fields = ("site_name",)
fieldsets = (
(
None,

View File

@@ -0,0 +1,16 @@
# Generated by Django 5.0.1 on 2024-01-20 21:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("portal", "0015_siteconfiguration_use_cdn"),
]
operations = [
migrations.RemoveField(
model_name="siteconfiguration",
name="site_name",
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.0.2 on 2024-02-17 12:19
import tinymce.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("portal", "0016_remove_siteconfiguration_site_name"),
]
operations = [
migrations.AlterField(
model_name="flatpage",
name="content",
field=tinymce.models.HTMLField(),
),
migrations.AlterField(
model_name="siteconfiguration",
name="about",
field=tinymce.models.HTMLField(blank=True),
),
migrations.AlterField(
model_name="siteconfiguration",
name="footer",
field=tinymce.models.HTMLField(blank=True),
),
migrations.AlterField(
model_name="siteconfiguration",
name="footer_extended",
field=tinymce.models.HTMLField(blank=True),
),
]

View File

@@ -1,23 +1,20 @@
import django
from django.db import models
from django.conf import settings
from django.urls import reverse
from django.dispatch.dispatcher import receiver
from django.utils.safestring import mark_safe
from solo.models import SingletonModel
from ckeditor.fields import RichTextField
from ckeditor_uploader.fields import RichTextUploadingField
from tinymce import models as tinymce
from ram import __version__ as app_version
from ram.utils import slugify
class SiteConfiguration(SingletonModel):
site_name = models.CharField(
max_length=256, default="Railroad Assets Manager"
)
site_author = models.CharField(max_length=256, blank=True)
about = RichTextField(blank=True)
about = tinymce.HTMLField(blank=True)
items_per_page = models.CharField(
max_length=2,
choices=[(str(x * 3), str(x * 3)) for x in range(2, 11)],
@@ -32,8 +29,8 @@ class SiteConfiguration(SingletonModel):
],
default="type",
)
footer = RichTextField(blank=True)
footer_extended = RichTextField(blank=True)
footer = tinymce.HTMLField(blank=True)
footer_extended = tinymce.HTMLField(blank=True)
show_version = models.BooleanField(default=True)
use_cdn = models.BooleanField(default=True)
extra_head = models.TextField(blank=True)
@@ -44,6 +41,9 @@ class SiteConfiguration(SingletonModel):
def __str__(self):
return "Site Configuration"
def site_name(self):
return settings.SITE_NAME
def version(self):
return app_version
@@ -55,7 +55,7 @@ class Flatpage(models.Model):
name = models.CharField(max_length=256, unique=True)
path = models.CharField(max_length=256, unique=True)
published = models.BooleanField(default=False)
content = RichTextUploadingField()
content = tinymce.HTMLField()
creation_time = models.DateTimeField(auto_now_add=True)
updated_time = models.DateTimeField(auto_now=True)
@@ -66,12 +66,11 @@ class Flatpage(models.Model):
return reverse("flatpage", kwargs={"flatpage": self.path})
def get_link(self):
if self.published:
return mark_safe(
'<a href="{0}" target="_blank">Link</a>'.format(
self.get_absolute_url()
)
return mark_safe(
'<a href="{0}" target="_blank">Link</a>'.format(
self.get_absolute_url()
)
)
@receiver(models.signals.pre_save, sender=Flatpage)

View File

@@ -7,6 +7,16 @@ html[data-bs-theme='dark'] .navbar svg {
width: 100%;
}
td > img.logo {
max-width: 200px;
max-height: 48px;
}
td > img.logo-xl {
max-width: 400px;
max-height: 96px;
}
.btn > span {
display: inline-block;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

View File

@@ -115,13 +115,26 @@
})
})()
</script>
<script>
document.addEventListener('DOMContentLoaded', function () {
var selectElement = document.getElementById('tabSelector');
selectElement.addEventListener('change', function () {
var selectedTabId = this.value;
var tabs = document.querySelectorAll('.tab-pane');
tabs.forEach(function (tab) {
tab.classList.remove('show', 'active');
});
document.getElementById(selectedTabId).classList.add('show', 'active');
});
});
</script>
{% block extra_head %}
{{ site_conf.extra_head | safe }}
{% endblock %}
</head>
<body>
<header>
<nav class="navbar navbar-expand-lg bg-body-tertiary shadow-sm">
<nav class="navbar navbar-expand-sm bg-body-tertiary shadow-sm">
<div class="container d-flex">
<div class="me-auto">
<a href="{% url 'index' %}" class="navbar-brand d-flex align-items-center">
@@ -146,29 +159,30 @@
<nav class="navbar navbar-expand-lg">
<div class="container-fluid g-0">
<a class="navbar-brand" href="{% url 'index' %}">Home</a>
<div class="navbar-collapse" id="navbarSupportedContent">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="rosterDropdownMenu" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Roster
</a>
<ul class="dropdown-menu" aria-labelledby="rosterDropdownMenu">
<li><a class="dropdown-item" href="{% url 'roster' %}">Rolling stock</a></li>
<li><a class="dropdown-item" href="{% url 'companies' %}">Companies</a></li>
<li><a class="dropdown-item" href="{% url 'types' %}">Types</a></li>
<li><a class="dropdown-item" href="{% url 'scales' %}">Scales</a></li>
</ul>
<li class="nav-item">
<a class="nav-link" href="{% url 'roster' %}">Roster</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'consists' %}">Consists</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="manufacturersDropdownMenu" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Manufacturers
<a class="nav-link dropdown-toggle" href="#" id="filterDropdownMenu" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Search by
</a>
<ul class="dropdown-menu" aria-labelledby="manufacturersDropdownMenu">
<li><a class="dropdown-item" href="{% url 'manufacturers' category='model' %}">Models</a></li>
<li><a class="dropdown-item" href="{% url 'manufacturers' category='real' %}">Real</a></li>
<ul class="dropdown-menu" aria-labelledby="filterDropdownMenu">
<li class="ps-2 text-secondary">Model</li>
<li><a class="dropdown-item" href="{% url 'scales' %}">Scale</a></li>
<li><a class="dropdown-item" href="{% url 'manufacturers' category='model' %}">Manufacturer</a></li>
<li><hr class="dropdown-divider"></li>
<li class="ps-2 text-secondary">Prototype</li>
<li><a class="dropdown-item" href="{% url 'types' %}">Type</a></li>
<li><a class="dropdown-item" href="{% url 'companies' %}">Company</a></li>
<li><a class="dropdown-item" href="{% url 'manufacturers' category='real' %}">Manufacturer</a></li>
</ul>
</li>
{% show_bookshelf_menu %}

View File

@@ -43,10 +43,14 @@
<section class="py-4 text-start container">
<div class="row">
<div class="mx-auto">
<nav class="nav nav-tabs flex-column flex-md-row mb-2" id="nav-tab">
<nav class="nav nav-tabs d-none d-lg-flex flex-row mb-2" id="nav-tab" role="tablist">
<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>
{% if book.notes %}<button class="nav-link" id="nav-notes-tab" data-bs-toggle="tab" data-bs-target="#nav-notes" type="button" role="tab" aria-controls="nav-notes" aria-selected="false">Notes</button>{% endif %}
</nav>
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
<option value="nav-summary" selected>Summary</option>
{% if book.notes %}<option value="nav-notes">Notes</option>{% endif %}
</select>
<div class="tab-content" id="nav-tabContent">
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
<table class="table table-striped">

View File

@@ -3,11 +3,11 @@
<p class="lead text-muted">Results found: {{ matches }}</p>
{% endblock %}
{% block cards_layout %}
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
{% block cards %}
{% for d in data %}
{% if d.type == "rolling_stock" %}
{% include "cards/rolling_stock.html" %}
{% include "cards/roster.html" %}
{% elif d.type == "company" %}
{% include "cards/company.html" %}
{% elif d.type == "rolling_stock_type" %}

View File

@@ -1,11 +1,7 @@
<div class="col">
<div class="card shadow-sm">
{% if d.item.image.all %}
<a href="{{ d.item.get_absolute_url }}">
{% for r in d.item.image.all %}
{% if forloop.first %}<img src="{{ r.image.url }}" alt="Card image cap">{% endif %}
{% endfor %}
</a>
{% if d.item.image.exists %}
<a href="{{d.item.get_absolute_url}}"><img class="card-img-top" src="{{ d.item.image.first.image.url }}" alt="{{ d.item }}"></a>
{% endif %}
<div class="card-body">
<p class="card-text" style="position: relative;">

View File

@@ -14,7 +14,7 @@
{% if d.item.logo %}
<tr>
<th class="w-33" scope="row">Logo</th>
<td><img style="max-height: 48px" src="{{ d.item.logo.url }}" /></td>
<td><img class="logo" src="{{ d.item.logo.url }}" alt="{{ d.item.name }} logo"></td>
</tr>
{% endif %}
<tr>
@@ -27,7 +27,7 @@
</tr>
<tr>
<th class="w-33" scope="row">Country</th>
<td>{{ d.item.country.name }} <img src="{{ d.item.country.flag }}" alt="{{ d.item.country }}" />
<td>{{ d.item.country.name }} <img src="{{ d.item.country.flag }}" alt="{{ d.item.country }}">
</tr>
{% if d.item.freelance %}
<tr>

View File

@@ -2,12 +2,10 @@
<div class="card shadow-sm">
<a href="{{ d.item.get_absolute_url }}">
{% if d.item.image %}
<img src="{{ d.item.image.url }}" alt="Card image cap">
<img class="card-img-top" src="{{ d.item.image.url }}" alt="{{ d.item }}">
{% else %}
{% with d.item.consist_item.first.rolling_stock as r %}
{% for i in r.image.all %}
{% if forloop.first %}<img src="{{ i.image.url }}" alt="Card image cap">{% endif %}
{% endfor %}
<img class="card-img-top" src="{{ r.image.first.image.url }}" alt="{{ d.item }}">
{% endwith %}
{% endif %}
</a>
@@ -26,7 +24,7 @@
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Consist data</th>
<th colspan="2" scope="row">Consist</th>
</tr>
</thead>
<tbody class="table-group-divider">
@@ -46,7 +44,7 @@
</tr>
<tr>
<th scope="row">Length</th>
<td>{{ d.item.consist_item.all | length }}</td>
<td>{{ d.item.consist_item.count }}</td>
</tr>
</tbody>
</table>

View File

@@ -14,7 +14,7 @@
{% if d.item.logo %}
<tr>
<th class="w-33" scope="row">Logo</th>
<td><img style="max-height: 48px" src="{{ d.item.logo.url }}" /></td>
<td><img class="logo" src="{{ d.item.logo.url }}" alt="{{ d.item.name }} logo"></td>
</tr>
{% endif %}
{% if d.item.website %}

View File

@@ -1,11 +1,11 @@
{% load static %}
<div class="col">
<div class="card shadow-sm">
{% if d.item.image.count > 0 %}
<a href="{{d.item.get_absolute_url}}"><img class="card-img-top" src="{{ d.item.image.first.image.url }}" alt="{{ d }}"></a>
{% if d.item.image.exists %}
<a href="{{d.item.get_absolute_url}}"><img class="card-img-top" src="{{ d.item.image.first.image.url }}" alt="{{ d.item }}"></a>
{% else %}
<!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
<a href="{{d.item.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d }}"></a>
<a href="{{d.item.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d.item }}"></a>
{% endif %}
<div class="card-body">
<p class="card-text" style="position: relative;">

View File

@@ -16,7 +16,7 @@
<div id="carouselControls" class="carousel carousel-dark slide" data-bs-ride="carousel">
<div class="carousel-inner">
<div class="carousel-item active">
<img src="{{ consist.image.url }}" class="d-block w-100 rounded img-thumbnail" alt="...">
<img src="{{ consist.image.url }}" class="d-block w-100 rounded img-thumbnail" alt="Consist cover">
</div>
</div>
</div>
@@ -66,10 +66,14 @@
<section class="py-4 text-start container">
<div class="row">
<div class="mx-auto">
<nav class="nav nav-tabs flex-column flex-md-row mb-2" id="nav-tab">
<nav class="nav nav-tabs d-none d-lg-flex flex-row mb-2" id="nav-tab" role="tablist">
<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>
{% if consist.notes %}<button class="nav-link" id="nav-notes-tab" data-bs-toggle="tab" data-bs-target="#nav-notes" type="button" role="tab" aria-controls="nav-notes" aria-selected="false">Notes</button>{% endif %}
</nav>
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
<option value="nav-summary" selected>Summary</option>
{% if consist.notes %}<option value="nav-notes">Notes</option>{% endif %}
</select>
<div class="tab-content" id="nav-tabContent">
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
<table class="table table-striped">
@@ -81,7 +85,9 @@
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Company</th>
<td><abbr title="{{ consist.company.extended_name }}">{{ consist.company }}</abbr></td>
<td>
<a href="{% url 'filtered' _filter="company" search=consist.company.slug %}">{{ consist.company }}</a> ({{ consist.company.extended_name }})
</td>
</tr>
<tr>
<th scope="row">Era</th>

View File

@@ -1,4 +1,7 @@
<div class="navbar-collapse justify-content-end" id="loginNavbar">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#loginNavbar" aria-controls="loginNavbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="bi bi-person-fill-gear"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="loginNavbar">
<ul class="navbar-nav">
<li class="nav-item dropdown">
{% if request.user.is_staff %}
@@ -14,7 +17,6 @@
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'admin:index' %}">Admin</a></li>
<li><a class="dropdown-item" href="{% url 'admin:portal_siteconfiguration_changelist' %}">Site configuration</a></li>
<li><a class="dropdown-item" href="{% url 'admin:driver_driverconfiguration_changelist' %}">DCC configuration</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="{% url 'admin:logout' %}?next={{ request.path }}">Logout</a></li>
</ul>

View File

@@ -1,4 +1,4 @@
<form class="d-flex needs-validation" action="{% url 'search' %}" method="post" novalidate>
<form class="d-flex needs-validation" action="{% url 'search' %}" method="post" novalidate>{% csrf_token %}
<div class="input-group has-validation">
<input class="form-control" type="search" list="datalistOptions" placeholder="Search" aria-label="Search" name="search" id="searchValidation" required>
<datalist id="datalistOptions">

View File

@@ -43,15 +43,29 @@
<section class="py-4 text-start container">
<div class="row">
<div class="mx-auto">
<nav class="nav nav-tabs flex-column flex-md-row mb-2" id="nav-tab">
<nav class="nav nav-tabs d-none d-lg-flex flex-row mb-2" id="nav-tab" role="tablist">
<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" 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 data</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 data</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-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.document.count > 0 or rolling_stock.decoder.document.count > 0 %}<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 rolling_stock_journal.count > 0 %}<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 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 rolling_stock.notes %}<button class="nav-link" id="nav-notes-tab" data-bs-toggle="tab" data-bs-target="#nav-notes" type="button" role="tab" aria-controls="nav-notes" aria-selected="false">Notes</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>
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
<option value="nav-summary" selected>Summary</option>
<option value="nav-model">Model</option>
<option value="nav-class">Class</option>
<option value="nav-company">Company</option>
{% if rolling_stock.decoder %}<option value="nav-dcc">DCC</option>{% endif %}
{% if documents or decoder_documents %}<option value="nav-documents">Documents</option>{% endif %}
{% if journal %}<option value="nav-journal">Journal</option>{% endif %}
{% if rolling_stock.notes %}<option value="nav-notes">Notes</option>{% endif %}
{% if consists %}<option value="nav-consists">Consists</option>{% endif %}
</select>
{% with class=rolling_stock.rolling_class company=rolling_stock.rolling_class.company %}
<div class="tab-content" id="nav-tabContent">
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
<table class="table table-striped">
@@ -63,17 +77,17 @@
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Type</th>
<td>{{ rolling_stock.rolling_class.type }}</td>
<td>{{ class.type }}</td>
</tr>
<tr>
<th scope="row">Company</th>
<td>
<a href="{% url 'filtered' _filter="company" search=rolling_stock.rolling_class.company.slug %}"><abbr title="{{ rolling_stock.rolling_class.company.extended_name }}">{{ rolling_stock.rolling_class.company }}</abbr></a>
<a href="{% url 'filtered' _filter="company" search=company.slug %}">{{ company }}</a> {{ company.extended_name_pp }}
</td>
</tr>
<tr>
<th scope="row">Class</th>
<td>{{ rolling_stock.rolling_class.identifier }}</td>
<td>{{ class.identifier }}</td>
</tr>
<tr>
<th scope="row">Road number</th>
@@ -95,7 +109,7 @@
<tr>
<th class="w-33" scope="row">Manufacturer</th>
<td>{%if rolling_stock.manufacturer %}
<a href="{% url 'filtered' _filter="manufacturer" search=rolling_stock.manufacturer.slug %}">{{ rolling_stock.manufacturer }}{% if rolling_stock.manufacturer.website %}</a> <a href="{{ rolling_stock.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
<a href="{% url 'filtered' _filter="manufacturer" search=rolling_stock.manufacturer.slug %}">{{ rolling_stock.manufacturer }}</a>{% if rolling_stock.manufacturer.website %} <a href="{{ rolling_stock.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
{% endif %}</td>
</tr>
<tr>
@@ -144,9 +158,11 @@
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Manufacturer</th>
<td>{%if rolling_stock.manufacturer %}
<a href="{% url 'filtered' _filter="manufacturer" search=rolling_stock.manufacturer.slug %}">{{ rolling_stock.manufacturer }}{% if rolling_stock.manufacturer.website %}</a> <a href="{{ rolling_stock.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
{% endif %}</td>
<td>
{%if rolling_stock.manufacturer %}
<a href="{% url 'filtered' _filter="manufacturer" search=rolling_stock.manufacturer.slug %}">{{ rolling_stock.manufacturer }}</a>{% if rolling_stock.manufacturer.website %} <a href="{{ rolling_stock.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
{% else %}-{% endif %}
</td>
</tr>
<tr>
<th scope="row">Scale</th>
@@ -170,7 +186,7 @@
</tr>
</tbody>
</table>
{% if rolling_stock_properties %}
{% if properties %}
<table class="table table-striped">
<thead>
<tr>
@@ -178,7 +194,7 @@
</tr>
</thead>
<tbody class="table-group-divider">
{% for p in rolling_stock_properties %}
{% for p in properties %}
<tr>
<th class="w-33" scope="row">{{ p.property }}</th>
<td>{{ p.value }}</td>
@@ -198,27 +214,19 @@
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Class</th>
<td>{{ rolling_stock.rolling_class.identifier }}</td>
<td>{{ class.identifier }}</td>
</tr>
<tr>
<th scope="row">Type</th>
<td>{{ rolling_stock.rolling_class.type }}</td>
</tr>
<tr>
<th scope="row">Company</th>
<td>
<a href="{% url 'filtered' _filter="company" search=rolling_stock.rolling_class.company.slug %}">{{ rolling_stock.rolling_class.company }}</a> ({{ rolling_stock.rolling_class.company.extended_name }})
</td>
</tr>
<tr>
<th scope="row">Country</th>
<td>{{ rolling_stock.rolling_class.company.country.name }}</td>
<td>{{ class.type }}</td>
</tr>
<tr>
<th scope="row">Manufacturer</th>
<td>{%if rolling_stock.rolling_class.manufacturer %}
<a href="{% url 'filtered' _filter="manufacturer" search=rolling_stock.rolling_class.manufacturer.slug %}">{{ rolling_stock.rolling_class.manufacturer }}{% if rolling_stock.rolling_class.manufacturer.website %}</a> <a href="{{ rolling_stock.rolling_class.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
{% endif %}</td>
<td>
{%if class.manufacturer %}
<a href="{% url 'filtered' _filter="manufacturer" search=class.manufacturer.slug %}">{{ class.manufacturer }}</a>{% if class.manufacturer.website %} <a href="{{ class.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
{% else %}-{% endif %}
</td>
</tr>
</tbody>
</table>
@@ -240,11 +248,42 @@
</table>
{% endif %}
</div>
<div class="tab-pane" id="nav-company" role="tabpanel" aria-labelledby="nav-company-tab">
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Company data</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% if company.logo %}
<tr>
<th class="w-33" scope="row">Logo</th>
<td><img class="logo-xl" src="{{ company.logo.url }}" alt="{{ company }} logo"></td>
</tr>
{% endif %}
<tr>
<th class="w-33" scope="row">Name</th>
<td><a href="{% url 'filtered' _filter="company" search=company.slug %}">{{ company.name }}</a> {{ company.extended_name_pp }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Country</th>
<td>{{ company.country.name }} <img src="{{ company.country.flag }}" alt="{{ company.country }}">
</tr>
{% if company.freelance %}
<tr>
<th class="w-33" scope="row">Notes</th>
<td>A <em>freelance</em> company</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<div class="tab-pane" id="nav-dcc" role="tabpanel" aria-labelledby="nav-dcc-tab">
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">DCC data</th>
<th colspan="2" scope="row">Decoder data</th>
</tr>
</thead>
<tbody class="table-group-divider">
@@ -276,7 +315,7 @@
</table>
</div>
<div class="tab-pane" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
{% if rolling_stock.document.count > 0 %}
{% if documents %}
<table class="table table-striped">
<thead>
<tr>
@@ -284,7 +323,7 @@
</tr>
</thead>
<tbody class="table-group-divider">
{% for d in rolling_stock.document.all %}
{% 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>
@@ -294,7 +333,7 @@
</tbody>
</table>
{% endif %}
{% if rolling_stock.decoder.document.count > 0 %}
{% if decoder_documents %}
<table class="table table-striped">
<thead>
<tr>
@@ -302,7 +341,7 @@
</tr>
</thead>
<tbody class="table-group-divider">
{% for d in rolling_stock.decoder.document.all %}
{% 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>
@@ -317,14 +356,14 @@
<table class="table table-striped">
<thead>
<tr>
<th colspan="3" scope="row">Journal</th>
<th colspan="2" scope="row">Journal</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for j in rolling_stock_journal %}
{% for j in journal %}
<tr>
<th class="w-33" scope="row">{{ j.date }}</th>
<td>{{ j.log | safe }}</a></td>
<td>{{ j.log | safe }}</td>
</tr>
{% endfor %}
</tbody>
@@ -333,7 +372,15 @@
<div class="tab-pane" id="nav-notes" role="tabpanel" aria-labelledby="nav-notes-tab">
{{ rolling_stock.notes | safe }}
</div>
<div class="tab-pane" id="nav-consists" role="tabpanel" aria-labelledby="nav-cosists-tab">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3 mb-3">
{% for d in consists %}
{% include "cards/consist.html" %}
{% endfor %}
</div>
</div>
</div>
{% endwith %}
<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:roster_rollingstock_change' rolling_stock.pk %}">Edit</a>{% endif %}
</div>

View File

@@ -5,7 +5,7 @@
<ul class="pagination justify-content-center mt-4 mb-0">
{% if data.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'scales_pagination' page=data.previous_page_number %}#main-content" tabindex="-1">Previous</a>
<a class="page-link" href="{% url 'types_pagination' page=data.previous_page_number %}#main-content" tabindex="-1">Previous</a>
</li>
{% else %}
<li class="page-item disabled">
@@ -21,13 +21,13 @@
{% if i == data.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %}
<li class="page-item"><a class="page-link" href="{% url 'scales_pagination' page=i %}#main-content">{{ i }}</a></li>
<li class="page-item"><a class="page-link" href="{% url 'types_pagination' page=i %}#main-content">{{ i }}</a></li>
{% endif %}
{% endif %}
{% endfor %}
{% if data.has_next %}
<li class="page-item">
<a class="page-link" href="{% url 'scales_pagination' page=data.next_page_number %}#main-content" tabindex="-1">Next</a>
<a class="page-link" href="{% url 'types_pagination' page=data.next_page_number %}#main-content" tabindex="-1">Next</a>
</li>
{% else %}
<li class="page-item disabled">

View File

@@ -3,7 +3,7 @@ from django.urls import path
from portal.views import (
GetData,
GetRoster,
GetRosterFiltered,
GetObjectsFiltered,
GetFlatpage,
GetRollingStock,
GetConsist,
@@ -14,7 +14,7 @@ from portal.views import (
Types,
Books,
GetBook,
SearchRoster,
SearchObjects,
)
urlpatterns = [
@@ -26,9 +26,15 @@ urlpatterns = [
GetFlatpage.as_view(),
name="flatpage",
),
path("consists", Consists.as_view(), name="consists"),
path(
"consists/<int:page>", Consists.as_view(), name="consists_pagination"
"consists",
Consists.as_view(template="consists.html"),
name="consists"
),
path(
"consists/<int:page>",
Consists.as_view(template="consists.html"),
name="consists_pagination"
),
path("consist/<uuid:uuid>", GetConsist.as_view(), name="consist"),
path(
@@ -89,22 +95,22 @@ urlpatterns = [
path("bookshelf/book/<uuid:uuid>", GetBook.as_view(), name="book"),
path(
"search",
SearchRoster.as_view(http_method_names=["post"]),
SearchObjects.as_view(http_method_names=["post"]),
name="search",
),
path(
"search/<str:search>/<int:page>",
SearchRoster.as_view(),
SearchObjects.as_view(),
name="search_pagination",
),
path(
"<str:_filter>/<str:search>",
GetRosterFiltered.as_view(),
GetObjectsFiltered.as_view(),
name="filtered",
),
path(
"<str:_filter>/<str:search>/<int:page>",
GetRosterFiltered.as_view(),
GetObjectsFiltered.as_view(),
name="filtered_pagination",
),
path("<uuid:uuid>", GetRollingStock.as_view(), name="rolling_stock"),

View File

@@ -5,6 +5,7 @@ from urllib.parse import unquote
from django.views import View
from django.http import Http404, HttpResponseBadRequest
from django.db.utils import OperationalError, ProgrammingError
from django.db.models import Q
from django.shortcuts import render, get_object_or_404
from django.core.exceptions import ObjectDoesNotExist
@@ -15,11 +16,17 @@ from portal.models import Flatpage
from roster.models import RollingStock
from consist.models import Consist
from bookshelf.models import Book
from metadata.models import Company, Manufacturer, Scale, RollingStockType, Tag
from metadata.models import (
Company, Manufacturer, Scale, DecoderDocument, RollingStockType, Tag
)
def order_by_fields():
order_by = get_site_conf().items_ordering
try:
order_by = get_site_conf().items_ordering
except (OperationalError, ProgrammingError):
order_by = "type"
fields = [
"rolling_class__type",
"rolling_class__company",
@@ -71,12 +78,12 @@ class GetData(View):
class GetRoster(GetData):
title = "Rolling stock"
title = "Roster"
item_type = "rolling_stock"
queryset = RollingStock.objects.order_by(*order_by_fields())
class SearchRoster(View):
class SearchObjects(View):
def run_search(self, request, search, _filter, page=1):
site_conf = get_site_conf()
if _filter is None:
@@ -208,15 +215,17 @@ class SearchRoster(View):
return self.get(request, search, page)
class GetRosterFiltered(View):
class GetObjectsFiltered(View):
def run_filter(self, request, search, _filter, page=1):
site_conf = get_site_conf()
if _filter == "type":
title = RollingStockType.objects.get(slug__iexact=search)
title = get_object_or_404(RollingStockType, slug__iexact=search)
query = Q(rolling_class__type__slug__iexact=search)
elif _filter == "company":
title = get_object_or_404(Company, slug__iexact=search)
query = Q(rolling_class__company__slug__iexact=search)
query_2nd = Q(company__slug__iexact=search)
elif _filter == "manufacturer":
title = get_object_or_404(Manufacturer, slug__iexact=search)
query = Q(
@@ -226,9 +235,13 @@ class GetRosterFiltered(View):
elif _filter == "scale":
title = get_object_or_404(Scale, slug__iexact=search)
query = Q(scale__slug__iexact=search)
query_2nd = Q(
consist_item__rolling_stock__scale__slug__iexact=search
)
elif _filter == "tag":
title = get_object_or_404(Tag, slug__iexact=search)
query = Q(tags__slug__iexact=search)
query_2nd = query # For tags the 2nd level query doesn't change
else:
raise Http404
@@ -245,9 +258,9 @@ class GetRosterFiltered(View):
"item": item
})
if _filter == "tag":
try: # Execute only if query_2nd is defined
consists = (
Consist.objects.filter(query)
Consist.objects.filter(query_2nd)
.distinct()
)
for item in consists:
@@ -255,15 +268,18 @@ class GetRosterFiltered(View):
"type": "consist",
"item": item
})
books = (
Book.objects.filter(query)
.distinct()
)
for item in books:
data.append({
"type": "book",
"item": item
})
if _filter == "tag": # Books can be filtered only by tag
books = (
Book.objects.filter(query_2nd)
.distinct()
)
for item in books:
data.append({
"type": "book",
"item": item
})
except NameError:
pass
paginator = Paginator(data, site_conf.items_per_page)
data = paginator.get_page(page)
@@ -300,24 +316,36 @@ class GetRollingStock(View):
except ObjectDoesNotExist:
raise Http404
class_properties = (
rolling_stock.rolling_class.property.all()
if request.user.is_authenticated
else rolling_stock.rolling_class.property.filter(
# FIXME there's likely a better and more efficient way of doing this
# but keeping KISS for now
decoder_documents = []
if request.user.is_authenticated:
class_properties = rolling_stock.rolling_class.property.all()
properties = rolling_stock.property.all()
documents = rolling_stock.document.all()
journal = rolling_stock.journal.all()
if rolling_stock.decoder:
decoder_documents = rolling_stock.decoder.document.all()
else:
class_properties = rolling_stock.rolling_class.property.filter(
property__private=False
)
)
rolling_stock_properties = (
rolling_stock.property.all()
if request.user.is_authenticated
else rolling_stock.property.filter(property__private=False)
)
properties = rolling_stock.property.filter(
property__private=False
)
documents = rolling_stock.document.filter(private=False)
journal = rolling_stock.journal.filter(private=False)
if rolling_stock.decoder:
decoder_documents = rolling_stock.decoder.document.filter(
private=False
)
rolling_stock_journal = (
rolling_stock.journal.all()
if request.user.is_authenticated
else rolling_stock.journal.filter(private=False)
)
consists = [{
"type": "consist",
"item": c
} for c in Consist.objects.filter(
consist_item__rolling_stock=rolling_stock
)] # A dict with "item" is required by the consists card
return render(
request,
@@ -326,17 +354,19 @@ class GetRollingStock(View):
"title": rolling_stock,
"rolling_stock": rolling_stock,
"class_properties": class_properties,
"rolling_stock_properties": rolling_stock_properties,
"rolling_stock_journal": rolling_stock_journal,
"properties": properties,
"decoder_documents": decoder_documents,
"documents": documents,
"journal": journal,
"consists": consists,
},
)
class Consists(GetData):
def __init__(self):
self.title = "Consists"
self.item_type = "consist"
self.queryset = Consist.objects.all()
title = "Consists"
item_type = "consist"
queryset = Consist.objects.all()
class GetConsist(View):
@@ -431,10 +461,12 @@ class GetBook(View):
class GetFlatpage(View):
def get(self, request, flatpage):
_filter = Q(published=True) # Show only published pages
if request.user.is_authenticated:
_filter = Q() # Reset the filter if user is authenticated
try:
flatpage = Flatpage.objects.get(
Q(Q(path=flatpage) & Q(published=True))
)
flatpage = Flatpage.objects.filter(_filter).get(path=flatpage)
except ObjectDoesNotExist:
raise Http404

View File

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

View File

@@ -1,10 +1,4 @@
from django.contrib import admin
from django.db.utils import OperationalError, ProgrammingError
from portal.utils import get_site_conf
from django.conf import settings
try:
site_name = get_site_conf().site_name
except (OperationalError, ProgrammingError):
site_name = "Train Assets Manager"
admin.site.site_header = site_name
admin.site.site_header = settings.SITE_NAME

14
ram/ram/apps.py Normal file
View File

@@ -0,0 +1,14 @@
from django.conf import settings
from django.apps import AppConfig
class RamConfig(AppConfig):
name = "ram"
def ready(self):
cache_middleware = set([
"django.middleware.cache.UpdateCacheMiddleware",
"django.middleware.cache.FetchFromCacheMiddleware",
])
if cache_middleware.issubset(settings.MIDDLEWARE):
from ram.signals import clear_cache # noqa: F401

View File

@@ -1,7 +1,8 @@
# vim: syntax=python
from django.conf import settings
"""
Django local_settings for ram project.
Example of changes suitable for production
"""
# SECURITY WARNING: keep the secret key used in production secret!
@@ -12,9 +13,23 @@ SECRET_KEY = (
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False
# SECURITY WARNING: cache middlewares must be loaded before cookies one
MIDDLEWARE = [
"django.middleware.cache.UpdateCacheMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.cache.FetchFromCacheMiddleware",
] + settings.MIDDLEWARE
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://127.0.0.1:6379",
}
}
CACHE_MIDDLEWARE_SECONDS = 300
STORAGE_DIR = BASE_DIR / "storage"
ALLOWED_HOSTS = ["127.0.0.1"]
ALLOWED_HOSTS = ["127.0.0.1", "myhost"]
CSRF_TRUSTED_ORIGINS = ["https://myhost"]
ROOT_URLCONF = "ram.urls"
STATIC_URL = "static/"
MEDIA_URL = "media/"

View File

@@ -11,9 +11,8 @@ class Document(models.Model):
file = models.FileField(
upload_to="files/",
storage=DeduplicatedStorage(),
null=True,
blank=True,
)
private = models.BooleanField(default=False)
class Meta:
abstract = True
@@ -33,7 +32,8 @@ class Document(models.Model):
class Image(models.Model):
order = models.PositiveIntegerField(default=0, blank=False, null=False)
image = models.ImageField(
upload_to="images/", storage=DeduplicatedStorage, null=True, blank=True
upload_to="images/",
storage=DeduplicatedStorage,
)
def image_thumbnail(self):

View File

@@ -41,17 +41,14 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"health_check",
"health_check.db",
"adminsortable2",
"django_countries",
"solo",
"ckeditor",
"ckeditor_uploader",
"tinymce",
"rest_framework",
"ram",
"portal",
"driver",
# "driver", # uncomment this to enable the "driver" API
"metadata",
"roster",
"consist",
@@ -62,7 +59,7 @@ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
# 'django.middleware.csrf.CsrfViewMiddleware',
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
@@ -144,12 +141,31 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
MEDIA_URL = "media/"
MEDIA_ROOT = STORAGE_DIR / "media"
CKEDITOR_UPLOAD_PATH = "uploads/"
TINYMCE_DEFAULT_CONFIG = {
"height": "500px",
"menubar": False,
"plugins": "autolink lists link image charmap preview anchor "
"searchreplace visualblocks code fullscreen insertdatetime media "
"table paste code",
"toolbar": "undo redo | "
"bold italic underline strikethrough removeformat | "
"fontsizeselect formatselect | "
"alignleft aligncenter alignright alignjustify | "
"outdent indent numlist bullist | "
"insertfile image media pageembed template link anchor codesample | "
"charmap | "
"fullscreen preview code",
"images_upload_url": "/tinymce/upload_image",
}
COUNTRIES_OVERRIDE = {
"ZZ": "Freelance",
"EU": "Europe",
"XX": "None",
}
SITE_NAME = "Railroad Assets Manger"
# Image used on cards without a custom image uploaded.
# The file must be placed in the root of the 'static' folder
DEFAULT_CARD_IMAGE = "coming_soon.svg"

8
ram/ram/signals.py Normal file
View File

@@ -0,0 +1,8 @@
from django.core.cache import cache
from django.db.models.signals import post_save
from django.dispatch import receiver
@receiver(post_save)
def clear_cache(sender, **kwargs):
cache.clear()

View File

@@ -13,24 +13,36 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.apps import apps
from django.conf import settings
from django.shortcuts import redirect
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from ram.views import UploadImage
urlpatterns = [
path("", lambda r: redirect("portal/")),
path("ckeditor/", include("ckeditor_uploader.urls")),
path('tinymce/', include('tinymce.urls')),
path(
"tinymce/upload_image",
UploadImage.as_view(),
name="upload_image"
),
path("portal/", include("portal.urls")),
path("ht/", include("health_check.urls")),
path("admin/", admin.site.urls),
path("api/v1/consist/", include("consist.urls")),
path("api/v1/roster/", include("roster.urls")),
path("api/v1/dcc/", include("driver.urls")),
path("api/v1/bookshelf/", include("bookshelf.urls")),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# Enable the "/dcc" routing only if the "driver" app is active
if apps.is_installed("driver"):
urlpatterns += [
path("api/v1/dcc/", include("driver.urls")),
]
if settings.DEBUG:
from django.views.generic import TemplateView
from rest_framework.schemas import get_schema_view
@@ -54,3 +66,7 @@ if settings.DEBUG:
name="openapi-schema",
),
]
if apps.is_installed("debug_toolbar"):
urlpatterns += [
path("__debug__/", include("debug_toolbar.urls")),
]

59
ram/ram/views.py Normal file
View File

@@ -0,0 +1,59 @@
import os
import datetime
import posixpath
from pathlib import Path
from PIL import Image, UnidentifiedImageError
from django.views import View
from django.conf import settings
from django.http import (
JsonResponse, HttpResponseForbidden, HttpResponse
)
from django.utils.text import slugify as slugify
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
@method_decorator(csrf_exempt, name='dispatch')
class UploadImage(View):
def post(self, request, application=None, model=None):
if not request.user.is_authenticated:
raise HttpResponseForbidden()
file_obj = request.FILES['file']
file_name, file_extension = os.path.splitext(file_obj.name)
file_name = slugify(file_name) + file_extension
try:
Image.open(file_obj)
except UnidentifiedImageError:
response = HttpResponse("Invalid extension") # FIXME
response.status_code = 400
return response
today = datetime.date.today()
container = (
"uploads",
today.strftime("%Y"),
today.strftime("%m"),
today.strftime("%d"),
)
file_path = os.path.join(
settings.MEDIA_ROOT,
*(p for p in container)
)
Path(file_path).mkdir(parents=True, exist_ok=True)
with open(os.path.join(file_path, file_name), 'wb+') as f:
for chunk in file_obj.chunks():
f.write(chunk)
return JsonResponse({
'message': 'Image uploaded successfully',
'location': posixpath.join(
settings.MEDIA_URL,
*(p for p in container),
file_name
)
})

View File

@@ -28,6 +28,7 @@ class RollingClass(admin.ModelAdmin):
"company__name",
"type__type",
)
save_as = True
class RollingStockDocInline(admin.TabularInline):
@@ -64,6 +65,7 @@ class RollingStockDocumentAdmin(admin.ModelAdmin):
"__str__",
"rolling_stock",
"description",
"private",
"download",
)
search_fields = (

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.5 on 2023-10-06 19:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("roster", "0018_rename_sku_rollingstock_item_number"),
]
operations = [
migrations.AddField(
model_name="rollingstockdocument",
name="private",
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,49 @@
# Generated by Django 4.2.6 on 2023-10-30 13:16
import os
import sys
import shutil
import ram.utils
import roster.models
from django.db import migrations, models
from django.conf import settings
def move_images(apps, schema_editor):
sys.stdout.write("\n Processing files. Please await...")
for r in roster.models.RollingStockImage.objects.all():
fname = os.path.basename(r.image.path)
new_image = roster.models.rolling_stock_image_upload(r, fname)
new_path = os.path.join(settings.MEDIA_ROOT, new_image)
os.makedirs(os.path.dirname(new_path), exist_ok=True)
try:
shutil.move(r.image.path, new_path)
except FileNotFoundError:
sys.stderr.write(" !! FileNotFoundError: {}\n".format(new_image))
pass
r.image.name = new_image
r.save()
class Migration(migrations.Migration):
dependencies = [
("roster", "0019_rollingstockdocument_private"),
]
operations = [
migrations.AlterField(
model_name="rollingstockimage",
name="image",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to=roster.models.rolling_stock_image_upload,
),
),
migrations.RunPython(
move_images,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 4.2.6 on 2023-11-04 22:53
from django.db import migrations, models
import ram.utils
import roster.models
class Migration(migrations.Migration):
dependencies = [
("roster", "0020_alter_rollingstockimage_image"),
]
operations = [
migrations.AlterField(
model_name="rollingstockdocument",
name="file",
field=models.FileField(
storage=ram.utils.DeduplicatedStorage(), upload_to="files/"
),
),
migrations.AlterField(
model_name="rollingstockimage",
name="image",
field=models.ImageField(
storage=ram.utils.DeduplicatedStorage,
upload_to=roster.models.rolling_stock_image_upload,
),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.0.2 on 2024-02-17 12:19
import tinymce.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("roster", "0021_alter_rollingstockdocument_file_and_more"),
]
operations = [
migrations.AlterField(
model_name="rollingstock",
name="notes",
field=tinymce.models.HTMLField(blank=True),
),
migrations.AlterField(
model_name="rollingstockjournal",
name="log",
field=tinymce.models.HTMLField(),
),
]

View File

@@ -1,14 +1,16 @@
import os
import re
import shutil
from uuid import uuid4
from django.db import models
from django.urls import reverse
from django.conf import settings
from django.dispatch import receiver
from ckeditor_uploader.fields import RichTextUploadingField
from tinymce import models as tinymce
from ram.models import Document, Image, PropertyInstance
from ram.utils import get_image_preview
from ram.utils import DeduplicatedStorage
from metadata.models import (
Scale,
Manufacturer,
@@ -83,7 +85,7 @@ class RollingStock(models.Model):
era = models.CharField(max_length=32, blank=True)
production_year = models.SmallIntegerField(null=True, blank=True)
purchase_date = models.DateField(null=True, blank=True)
notes = RichTextUploadingField(blank=True)
notes = tinymce.HTMLField(blank=True)
tags = models.ManyToManyField(
Tag, related_name="rolling_stock", blank=True
)
@@ -106,6 +108,15 @@ class RollingStock(models.Model):
def company(self):
return str(self.rolling_class.company)
def delete(self, *args, **kwargs):
shutil.rmtree(
os.path.join(
settings.MEDIA_ROOT, "images", "rollingstock", str(self.uuid)
),
ignore_errors=True
)
super(RollingStock, self).delete(*args, **kwargs)
@receiver(models.signals.pre_save, sender=RollingStock)
def pre_save_running_number(sender, instance, *args, **kwargs):
@@ -126,10 +137,23 @@ class RollingStockDocument(Document):
unique_together = ("rolling_stock", "file")
def rolling_stock_image_upload(instance, filename):
return os.path.join(
"images",
"rollingstock",
str(instance.rolling_stock.uuid),
filename
)
class RollingStockImage(Image):
rolling_stock = models.ForeignKey(
RollingStock, on_delete=models.CASCADE, related_name="image"
)
image = models.ImageField(
upload_to=rolling_stock_image_upload,
storage=DeduplicatedStorage,
)
class RollingStockProperty(PropertyInstance):
@@ -151,7 +175,7 @@ class RollingStockJournal(models.Model):
blank=False,
)
date = models.DateField()
log = RichTextUploadingField()
log = tinymce.HTMLField()
private = models.BooleanField(default=False)
creation_time = models.DateTimeField(auto_now_add=True)
updated_time = models.DateTimeField(auto_now=True)

View File

@@ -1 +1,2 @@
gunicorn
# Optional: # pylibmc

View File

@@ -7,6 +7,9 @@ django-solo
django-countries
django-health-check
django-admin-sortable2
django-ckeditor
# psycopg2-binary
pySerial
django-tinymce
# Optional: # psycopg2-binary
# Optional: # pySerial
# Required by django-countries and not always installed
# by default on modern venvs (like Python 3.12 on Fedora 39)
setuptools

BIN
sample_data/background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB