77 Commits

Author SHA1 Message Date
7eddd1b52b Fix a regression introduced in v0.14.0 2024-12-23 12:16:22 +01:00
11515d79ef Fix a regression in bookshelf properties 2024-12-23 02:15:15 +01:00
f2b817103f Add catalog to by tag filter 2024-12-23 02:02:34 +01:00
2d00436a87 Disable scales if no items are available 2024-12-23 01:50:58 +01:00
6ff5450124 Minor fixes and improvements 2024-12-23 01:26:22 +01:00
f4af44c41c Merge pull request #40 from daniviga/catalogue
Introduce the concept of catalogs, improve books and code refactoring
2024-12-22 22:13:30 +01:00
e3ae18a4bd Update Python versions in GitHub workflows 2024-12-22 22:00:26 +01:00
2695358d9b Disable an old migration 2024-12-22 21:59:14 +01:00
3fbae0417e Update a migration 2024-12-22 21:56:16 +01:00
7a51ab9095 Bump version 2024-12-22 21:46:42 +01:00
dad40b3ee7 Implement documents inline for books and catalogs 2024-12-22 21:45:56 +01:00
d55bce6e78 More code refactoring, reduce template duplications 2024-12-22 21:32:22 +01:00
cbf6c942b9 Complete Catalogs with code refactoring 2024-12-22 18:53:47 +01:00
64f616d89f Merge branch 'master' into catalogue 2024-11-30 18:51:24 +01:00
f8246c31d3 Hotfix the manufacturer template 2024-11-30 14:56:26 +01:00
005ea11011 Minor improvements 2024-11-29 23:49:35 +01:00
83444266cb Add Catalogs views, but still need to fix templates (use books for now) 2024-11-29 23:43:36 +01:00
1a3b30ace3 Enable Catalogs in Admin 2024-11-29 23:30:33 +01:00
21c99f73c3 Implement Book data migration 2024-11-29 23:16:31 +01:00
b5b88f7714 Minor change to Image model Meta 2024-11-27 23:14:44 +01:00
119d25ede6 WIP: implement catalogue type of books 2024-11-27 23:07:43 +01:00
41d9338459 Allow books with no authors 2024-11-26 23:22:37 +01:00
32785f321a Fix the logout to use a POST (introduced with Django 4.1)
Also add an handy command to clear Django cache
2024-11-05 22:43:15 +01:00
5b975355a1 Add an help text to gauge 2024-11-04 22:33:26 +01:00
7d8c539e47 Update tracks related templates 2024-11-04 22:31:56 +01:00
9a832bca82 Improve sorting 2024-11-04 22:27:23 +01:00
54254bda7d Fix get_data method signatore in portal views.py 2024-11-04 15:06:55 +01:00
1c07c6a7a9 Add a custom manager to filter private and unpublished stuff (#39)
* Implement a customer manager for flatpages

* Implement public manager for private objects

* Add support for unpublished objects in roster and consist

* Add support for unpublished objects in bookshelf

* Update filtering on REST views

* Use uuid in urls.py

* Increment version
2024-11-04 15:00:34 +01:00
61b6d7a84e Update submodules 2024-11-04 11:33:44 +01:00
d0854a4cff Speedup inlines using autocomplete field and add more previews (#38) 2024-11-04 11:33:28 +01:00
456272b93a Add built-in decoder interface type 2024-04-30 11:08:16 +02:00
35905bafdf Improve rendering of pagination on mobile (#37) 2024-04-27 15:00:23 +02:00
6a9f37ca05 Add a 404 page and improve manufacturer lookup (#36)
* Add a custom 404 page
* Better manufacturer and item lookup
* Add migration to populate new field
* Version bump
2024-04-24 00:33:41 +02:00
54a68d9b1f Fix data retreival issue on GetData (#35) 2024-04-21 15:34:16 +02:00
aa02404dfe Fix an ordering issue on items in a set query 2024-04-21 09:56:10 +02:00
e4ad98fa38 Implement support for sets and other improvements (#34)
* Add a boolean to define item as part of a set
* Add contextual help in admin
* Introduce support to sets and to item code lookup
Also review the url path for pagination
2024-04-21 00:31:52 +02:00
b37f5420c5 Update to Bootstrap 5.3.3 (#33)
* Update to Bootstrap 5.3.3
* Remove support for python 3.9
2024-04-09 23:45:58 +02:00
4b74a69f3f Add the possbility to provide descriptions (#32)
to class, rolling stock, book
2024-03-02 15:45:42 +01:00
e7d34ce8e0 Remove unused args in upload_image 2024-02-17 23:06:41 +01:00
19eb70c492 Replace ckeditor with tinymce (#30)
* Replace ckeditor with tinymce due to deprecation
* Remove any ckeditor dependency from old migrations
   Disable alters, replace create with plain models.TextField
* Reformat files
* Add more hardening in image_upload
2024-02-17 23:05:18 +01:00
4428b8c11d Fix a RuntimeWarning introduced in Django 5 (#29) 2024-01-20 22:08:10 +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
4b0361acc1 Fix the consists search 2023-10-05 23:21:52 +02:00
425eed3d83 Bookshelf reloaded (#25)
* Navbar refactoring
* Fix coming soon SVG fonts
* Overhaul templating and extend search to consists and books
2023-10-05 23:13:42 +02:00
2d48463474 Change model default sort for Book 2023-10-03 23:08:11 +02:00
08226247c7 Extend ISBN to include dashes 2023-10-03 22:43:20 +02:00
4f52736d97 Fix a copy-paste issue in bookshelf model 2023-10-03 22:26:51 +02:00
122 changed files with 3173 additions and 1114 deletions

View File

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

1
.gitignore vendored
View File

@@ -10,7 +10,6 @@ __pycache__/
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/

View File

@@ -49,7 +49,7 @@ It has been developed with:
## Requirements
- Python 3.9+
- Python 3.10+
- A USB port when running Arduino hardware (and adaptors if you have a Mac)
## Web portal installation

View File

@@ -1,36 +1,94 @@
from django.contrib import admin
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
from bookshelf.models import BookProperty, BookImage, Book, Author, Publisher
from bookshelf.models import (
BaseBookProperty,
BaseBookImage,
BaseBookDocument,
Book,
Author,
Publisher,
Catalog,
)
class BookImageInline(SortableInlineAdminMixin, admin.TabularInline):
model = BookImage
model = BaseBookImage
min_num = 0
extra = 0
readonly_fields = ("image_thumbnail",)
classes = ["collapse"]
verbose_name = "Image"
class BookDocInline(admin.TabularInline):
model = BaseBookDocument
min_num = 0
extra = 0
classes = ["collapse"]
class BookPropertyInline(admin.TabularInline):
model = BookProperty
model = BaseBookProperty
min_num = 0
extra = 0
autocomplete_fields = ("property",)
verbose_name = "Property"
verbose_name_plural = "Properties"
@admin.register(Book)
class BookAdmin(SortableAdminBase, admin.ModelAdmin):
inlines = (BookImageInline, BookPropertyInline,)
inlines = (
BookPropertyInline,
BookImageInline,
BookDocInline,
)
list_display = (
"title",
"get_authors",
"get_publisher",
"publication_year",
"number_of_pages"
"number_of_pages",
"published",
)
autocomplete_fields = ("authors", "publisher")
readonly_fields = ("creation_time", "updated_time")
search_fields = ("title", "publisher__name", "authors__last_name")
list_filter = ("publisher__name", "authors")
fieldsets = (
(
None,
{
"fields": (
"published",
"title",
"authors",
"publisher",
"ISBN",
"language",
"number_of_pages",
"publication_year",
"description",
"purchase_date",
"notes",
"tags",
)
},
),
(
"Audit",
{
"classes": ("collapse",),
"fields": (
"creation_time",
"updated_time",
),
},
),
)
@admin.display(description="Publisher")
def get_publisher(self, obj):
return obj.publisher.name
@@ -42,7 +100,10 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
@admin.register(Author)
class AuthorAdmin(admin.ModelAdmin):
search_fields = ("first_name", "last_name",)
search_fields = (
"first_name",
"last_name",
)
list_filter = ("last_name",)
@@ -50,3 +111,59 @@ class AuthorAdmin(admin.ModelAdmin):
class PublisherAdmin(admin.ModelAdmin):
list_display = ("name", "country")
search_fields = ("name",)
@admin.register(Catalog)
class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
inlines = (
BookPropertyInline,
BookImageInline,
BookDocInline,
)
list_display = (
"__str__",
"manufacturer",
"years",
"get_scales",
"published",
)
autocomplete_fields = ("manufacturer",)
readonly_fields = ("creation_time", "updated_time")
search_fields = ("manufacturer__name", "years", "scales__scale")
list_filter = ("manufacturer__name", "publication_year", "scales__scale")
fieldsets = (
(
None,
{
"fields": (
"published",
"manufacturer",
"years",
"scales",
"ISBN",
"language",
"number_of_pages",
"publication_year",
"description",
"purchase_date",
"notes",
"tags",
)
},
),
(
"Audit",
{
"classes": ("collapse",),
"fields": (
"creation_time",
"updated_time",
),
},
),
)
@admin.display(description="Scales")
def get_scales(self, obj):
return "/".join(s.scale for s in obj.scales.all())

View File

@@ -1,6 +1,7 @@
# Generated by Django 4.2.5 on 2023-10-01 20:16
import ckeditor_uploader.fields
# ckeditor removal
# import ckeditor_uploader.fields
from django.db import migrations, models
import django.db.models.deletion
import uuid
@@ -47,7 +48,8 @@ class Migration(migrations.Migration):
("ISBN", models.CharField(max_length=13, unique=True)),
("publication_year", models.SmallIntegerField(blank=True, null=True)),
("purchase_date", models.DateField(blank=True, null=True)),
("notes", ckeditor_uploader.fields.RichTextUploadingField(blank=True)),
# ("notes", ckeditor_uploader.fields.RichTextUploadingField(blank=True)),
("notes", models.TextField(blank=True)),
("creation_time", models.DateTimeField(auto_now_add=True)),
("updated_time", models.DateTimeField(auto_now=True)),
("authors", models.ManyToManyField(to="bookshelf.author")),

View File

@@ -12,9 +12,6 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name="book",
options={
"ordering": ["authors__last_name", "title"],
"verbose_name_plural": "Rolling stock",
},
options={"ordering": ["authors__last_name", "title"]},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.5 on 2023-10-03 20:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0005_alter_book_options"),
]
operations = [
migrations.AlterField(
model_name="book",
name="ISBN",
field=models.CharField(blank=True, max_length=17),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 4.2.5 on 2023-10-03 21:07
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0006_alter_book_isbn"),
]
operations = [
migrations.AlterModelOptions(
name="book",
options={"ordering": ["title"]},
),
]

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,51 @@
# 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.BaseBookImage.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"),
]
# Migration is stale and shouldn't be used since model hes been heavily
# modified since then. Leaving it here for reference.
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

@@ -0,0 +1,19 @@
# Generated by Django 5.0.2 on 2024-03-02 14:31
import tinymce.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0012_alter_book_notes"),
]
operations = [
migrations.AddField(
model_name="book",
name="description",
field=tinymce.models.HTMLField(blank=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.2 on 2024-11-04 13:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0013_book_description"),
]
operations = [
migrations.AddField(
model_name="book",
name="published",
field=models.BooleanField(default=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.2 on 2024-11-26 22:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0014_book_published"),
]
operations = [
migrations.AlterField(
model_name="book",
name="authors",
field=models.ManyToManyField(blank=True, to="bookshelf.author"),
),
]

View File

@@ -0,0 +1,141 @@
# Generated by Django 5.1.2 on 2024-11-27 16:35
import django.db.models.deletion
from django.db import migrations, models
def basebook_to_book(apps, schema_editor):
basebook = apps.get_model("bookshelf", "BaseBook")
book = apps.get_model("bookshelf", "Book")
for row in basebook.objects.all():
b = book.objects.create(
basebook_ptr=row,
title=row.old_title,
publisher=row.old_publisher,
)
b.authors.set(row.old_authors.all())
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0015_alter_book_authors"),
("metadata", "0019_alter_scale_gauge"),
]
operations = [
migrations.AlterModelOptions(
name="Book",
options={"ordering": ["creation_time"]},
),
migrations.RenameModel(
old_name="BookImage",
new_name="BaseBookImage",
),
migrations.RenameModel(
old_name="BookProperty",
new_name="BaseBookProperty",
),
migrations.RenameModel(
old_name="Book",
new_name="BaseBook",
),
migrations.RenameField(
model_name="basebook",
old_name="title",
new_name="old_title",
),
migrations.RenameField(
model_name="basebook",
old_name="authors",
new_name="old_authors",
),
migrations.RenameField(
model_name="basebook",
old_name="publisher",
new_name="old_publisher",
),
migrations.AlterModelOptions(
name="basebookimage",
options={"ordering": ["order"], "verbose_name_plural": "Images"},
),
migrations.CreateModel(
name="Book",
fields=[
(
"basebook_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="bookshelf.basebook",
),
),
("title", models.CharField(max_length=200)),
(
"authors",
models.ManyToManyField(
blank=True,
to="bookshelf.author"
),
),
(
"publisher",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="bookshelf.publisher"
),
),
],
options={
"ordering": ["title"],
},
),
migrations.RunPython(
basebook_to_book,
reverse_code=migrations.RunPython.noop
),
migrations.RemoveField(
model_name="basebook",
name="old_title",
),
migrations.RemoveField(
model_name="basebook",
name="old_authors",
),
migrations.RemoveField(
model_name="basebook",
name="old_publisher",
),
migrations.CreateModel(
name="Catalog",
fields=[
(
"basebook_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="bookshelf.basebook",
),
),
("years", models.CharField(max_length=12)),
(
"manufacturer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="metadata.manufacturer",
),
),
("scales", models.ManyToManyField(to="metadata.scale")),
],
options={
"ordering": ["manufacturer", "publication_year"],
},
bases=("bookshelf.basebook",),
),
]

View File

@@ -0,0 +1,52 @@
# Generated by Django 5.1.2 on 2024-12-22 20:38
import django.db.models.deletion
import ram.utils
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0016_basebook_book_catalogue"),
]
operations = [
migrations.AlterModelOptions(
name="basebook",
options={},
),
migrations.CreateModel(
name="BaseBookDocument",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("description", models.CharField(blank=True, max_length=128)),
(
"file",
models.FileField(
storage=ram.utils.DeduplicatedStorage(), upload_to="files/"
),
),
("private", models.BooleanField(default=False)),
(
"book",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="document",
to="bookshelf.basebook",
),
),
],
options={
"unique_together": {("book", "file")},
},
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.1.4 on 2024-12-22 20:44
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0017_alter_basebook_options_basebookdocument"),
]
operations = [
migrations.AlterModelOptions(
name="basebookdocument",
options={"verbose_name_plural": "Documents"},
),
]

View File

@@ -1,14 +1,16 @@
from uuid import uuid4
import os
import shutil
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
from ram.models import Image, PropertyInstance
from ram.models import BaseModel, Image, Document, PropertyInstance
from metadata.models import Scale, Manufacturer
class Publisher(models.Model):
@@ -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}"
@@ -31,12 +39,8 @@ class Author(models.Model):
return f"{self.last_name} {self.first_name[0]}."
class Book(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False)
title = models.CharField(max_length=200)
authors = models.ManyToManyField(Author)
publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE)
ISBN = models.CharField(max_length=13, blank=True)
class BaseBook(BaseModel):
ISBN = models.CharField(max_length=17, blank=True) # 13 + dashes
language = models.CharField(
max_length=7,
choices=settings.LANGUAGES,
@@ -44,17 +48,68 @@ class Book(models.Model):
)
number_of_pages = models.SmallIntegerField(null=True, blank=True)
publication_year = models.SmallIntegerField(null=True, blank=True)
description = tinymce.HTMLField(blank=True)
purchase_date = models.DateField(null=True, blank=True)
tags = models.ManyToManyField(
Tag, related_name="bookshelf", blank=True
)
notes = RichTextUploadingField(blank=True)
creation_time = models.DateTimeField(auto_now_add=True)
updated_time = models.DateTimeField(auto_now=True)
def delete(self, *args, **kwargs):
shutil.rmtree(
os.path.join(
settings.MEDIA_ROOT, "images", "books", str(self.uuid)
),
ignore_errors=True
)
super(BaseBook, self).delete(*args, **kwargs)
def book_image_upload(instance, filename):
return os.path.join(
"images",
"books",
str(instance.book.uuid),
filename
)
class BaseBookImage(Image):
book = models.ForeignKey(
BaseBook, on_delete=models.CASCADE, related_name="image"
)
image = models.ImageField(
upload_to=book_image_upload,
storage=DeduplicatedStorage,
)
class BaseBookDocument(Document):
book = models.ForeignKey(
BaseBook, on_delete=models.CASCADE, related_name="document"
)
class Meta:
ordering = ["authors__last_name", "title"]
verbose_name_plural = "Rolling stock"
verbose_name_plural = "Documents"
unique_together = ("book", "file")
class BaseBookProperty(PropertyInstance):
book = models.ForeignKey(
BaseBook,
on_delete=models.CASCADE,
null=False,
blank=False,
related_name="property",
)
class Book(BaseBook):
title = models.CharField(max_length=200)
authors = models.ManyToManyField(Author, blank=True)
publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE)
class Meta:
ordering = ["title"]
def __str__(self):
return self.title
@@ -63,26 +118,33 @@ class Book(models.Model):
return self.publisher.name
def get_absolute_url(self):
return reverse("book", kwargs={"uuid": self.uuid})
return reverse(
"bookshelf_item",
kwargs={"selector": "book", "uuid": self.uuid}
)
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
storage=DeduplicatedStorage,
null=True,
blank=True
)
class BookProperty(PropertyInstance):
book = models.ForeignKey(
Book,
class Catalog(BaseBook):
manufacturer = models.ForeignKey(
Manufacturer,
on_delete=models.CASCADE,
null=False,
blank=False,
related_name="property",
)
years = models.CharField(max_length=12)
scales = models.ManyToManyField(Scale)
class Meta:
ordering = ["manufacturer", "publication_year"]
def __str__(self):
scales = self.get_scales
return "%s %s %s" % (self.manufacturer.name, self.years, scales)
def get_absolute_url(self):
return reverse(
"bookshelf_item",
kwargs={"selector": "catalog", "uuid": self.uuid}
)
@property
def get_scales(self):
return "/".join([s.scale for s in self.scales.all()])

View File

@@ -3,5 +3,5 @@ from bookshelf.views import BookList, BookGet
urlpatterns = [
path("book/list", BookList.as_view()),
path("book/get/<str:uuid>", BookGet.as_view()),
path("book/get/<uuid:uuid>", BookGet.as_view()),
]

View File

@@ -6,13 +6,16 @@ from bookshelf.serializers import BookSerializer
class BookList(ListAPIView):
queryset = Book.objects.all()
serializer_class = BookSerializer
def get_queryset(self):
return Book.objects.get_published(self.request.user)
class BookGet(RetrieveAPIView):
queryset = Book.objects.all()
serializer_class = BookSerializer
lookup_field = "uuid"
schema = AutoSchema(operation_id_base="retrieveBookByUUID")
def get_queryset(self):
return Book.objects.get_published(self.request.user)

View File

@@ -8,7 +8,8 @@ class ConsistItemInline(SortableInlineAdminMixin, admin.TabularInline):
model = ConsistItem
min_num = 1
extra = 0
readonly_fields = ("address", "type", "company", "era")
autocomplete_fields = ("rolling_stock",)
readonly_fields = ("preview", "published", "address", "type", "company", "era")
@admin.register(Consist)
@@ -18,7 +19,7 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
"creation_time",
"updated_time",
)
list_display = ("identifier", "company", "era")
list_display = ("identifier", "published", "company", "era")
list_filter = list_display
search_fields = list_display
save_as = True
@@ -28,6 +29,7 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
None,
{
"fields": (
"published",
"identifier",
"consist_address",
"company",

View File

@@ -1,6 +1,7 @@
# Generated by Django 4.1 on 2022-08-23 15:54
import ckeditor_uploader.fields
# ckeditor removal
# import ckeditor_uploader.fields
from django.db import migrations
@@ -11,9 +12,9 @@ class Migration(migrations.Migration):
]
operations = [
migrations.AlterField(
model_name="consist",
name="notes",
field=ckeditor_uploader.fields.RichTextUploadingField(blank=True),
),
# migrations.AlterField(
# model_name="consist",
# name="notes",
# field=ckeditor_uploader.fields.RichTextUploadingField(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

@@ -0,0 +1,30 @@
# Generated by Django 5.0.4 on 2024-04-20 12:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("consist", "0010_alter_consist_notes"),
]
operations = [
migrations.AlterField(
model_name="consist",
name="consist_address",
field=models.SmallIntegerField(
blank=True,
default=None,
help_text="DCC consist address if enabled",
null=True,
),
),
migrations.AlterField(
model_name="consist",
name="era",
field=models.CharField(
blank=True, help_text="Era or epoch of the consist", max_length=32
),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.1.2 on 2024-11-04 12:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("consist", "0011_alter_consist_consist_address_alter_consist_era"),
]
operations = [
migrations.AddField(
model_name="consist",
name="published",
field=models.BooleanField(default=True),
),
]

View File

@@ -1,29 +1,37 @@
from uuid import uuid4
import os
from django.db import models
from django.urls import reverse
from django.dispatch import receiver
from django.core.exceptions import ValidationError
from ckeditor_uploader.fields import RichTextUploadingField
from ram.models import BaseModel
from ram.utils import DeduplicatedStorage
from metadata.models import Company, Tag
from roster.models import RollingStock
class Consist(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False)
class Consist(BaseModel):
identifier = models.CharField(max_length=128, unique=False)
tags = models.ManyToManyField(Tag, related_name="consist", blank=True)
consist_address = models.SmallIntegerField(
default=None, null=True, blank=True
default=None,
null=True,
blank=True,
help_text="DCC consist address if enabled",
)
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
era = models.CharField(
max_length=32,
blank=True,
help_text="Era or epoch of the consist",
)
image = models.ImageField(
upload_to=os.path.join("images", "consists"),
storage=DeduplicatedStorage,
null=True,
blank=True,
)
notes = RichTextUploadingField(blank=True)
creation_time = models.DateTimeField(auto_now_add=True)
updated_time = models.DateTimeField(auto_now=True)
def __str__(self):
return "{0} {1}".format(self.company, self.identifier)
@@ -31,6 +39,12 @@ class Consist(models.Model):
def get_absolute_url(self):
return reverse("consist", kwargs={"uuid": self.uuid})
def clean(self):
if self.consist_item.filter(rolling_stock__published=False).exists():
raise ValidationError(
"You must publish all items in the consist before publishing the consist." # noqa: E501
)
class Meta:
ordering = ["company", "-creation_time"]
@@ -48,6 +62,13 @@ class ConsistItem(models.Model):
def __str__(self):
return "{0}".format(self.rolling_stock)
def published(self):
return self.rolling_stock.published
published.boolean = True
def preview(self):
return self.rolling_stock.image.first().image_thumbnail(100)
def type(self):
return self.rolling_stock.rolling_class.type
@@ -59,3 +80,14 @@ class ConsistItem(models.Model):
def era(self):
return self.rolling_stock.era
# Unpublish any consist that contains an unpublished rolling stock
# this signal is called after a rolling stock is saved
# it is hosted here to avoid circular imports
@receiver(models.signals.post_save, sender=RollingStock)
def post_save_unpublish_consist(sender, instance, *args, **kwargs):
consists = Consist.objects.filter(consist_item__rolling_stock=instance)
for consist in consists:
consist.published = False
consist.save()

View File

@@ -5,11 +5,15 @@ from consist.serializers import ConsistSerializer
class ConsistList(ListAPIView):
queryset = Consist.objects.all()
serializer_class = ConsistSerializer
def get_queryset(self):
return Consist.objects.get_published(self.request.user)
class ConsistGet(RetrieveAPIView):
queryset = Consist.objects.all()
serializer_class = ConsistSerializer
lookup_field = "uuid"
def get_queryset(self):
return Consist.objects.get_published(self.request.user)

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

@@ -0,0 +1,20 @@
# Generated by Django 5.0.4 on 2024-04-20 12:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("metadata", "0016_alter_decoderdocument_file"),
]
operations = [
migrations.AlterField(
model_name="property",
name="private",
field=models.BooleanField(
default=False, help_text="Property will be only visible to logged users"
),
),
]

View File

@@ -0,0 +1,69 @@
# Generated by Django 5.1.2 on 2024-11-04 21:17
import django.db.migrations.operations.special
import metadata.models
from django.db import migrations, models
def gen_ratio(apps, schema_editor):
Scale = apps.get_model('metadata', 'Scale')
for row in Scale.objects.all():
row.ratio_int = metadata.models.calculate_ratio(row.ratio)
row.save(update_fields=['ratio_int'])
def convert_tarcks(apps, schema_editor):
Scale = apps.get_model("metadata", "Scale")
for row in Scale.objects.all():
row.tracks = "".join(
filter(
lambda x: str.isdigit(x) or x == "." or x == ",",
row.tracks
)
)
row.save(update_fields=["tracks"])
class Migration(migrations.Migration):
dependencies = [
('metadata', '0017_alter_property_private'),
]
operations = [
migrations.AlterModelOptions(
name='decoder',
options={'ordering': ['manufacturer__name', 'name']},
),
migrations.AlterModelOptions(
name='scale',
options={'ordering': ['ratio_int', 'scale']},
),
migrations.AddField(
model_name='scale',
name='ratio_int',
field=models.SmallIntegerField(default=0, editable=False),
),
migrations.RunPython(
code=gen_ratio,
reverse_code=django.db.migrations.operations.special.RunPython.noop,
),
migrations.AlterField(
model_name='scale',
name='ratio',
field=models.CharField(max_length=16, validators=[metadata.models.calculate_ratio]),
),
migrations.AlterModelOptions(
name='scale',
options={'ordering': ['-ratio_int', '-tracks', 'scale']},
),
migrations.RunPython(
code=convert_tarcks,
reverse_code=django.db.migrations.operations.special.RunPython.noop,
),
migrations.AlterField(
model_name='scale',
name='tracks',
field=models.FloatField(help_text='Distance between model tracks in mm'),
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.1.2 on 2024-11-04 21:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("metadata", "0018_alter_decoder_options_alter_scale_options_and_more"),
]
operations = [
migrations.AlterField(
model_name="scale",
name="gauge",
field=models.CharField(
blank=True,
help_text="Distance between real tracks. Please specify the unit (mm, in, ...)",
max_length=16,
),
),
]

View File

@@ -1,16 +1,22 @@
import os
from django.db import models
from django.urls import reverse
from django.conf import settings
from django.dispatch.dispatcher import receiver
from django.core.exceptions import ValidationError
from django_countries.fields import CountryField
from ram.models import Document
from ram.utils import DeduplicatedStorage, get_image_preview, slugify
from ram.managers import PublicManager
class Property(models.Model):
name = models.CharField(max_length=128, unique=True)
private = models.BooleanField(default=False)
private = models.BooleanField(
default=False,
help_text="Property will be only visible to logged users",
)
class Meta:
verbose_name_plural = "Properties"
@@ -19,6 +25,8 @@ class Property(models.Model):
def __str__(self):
return self.name
objects = PublicManager()
class Manufacturer(models.Model):
name = models.CharField(max_length=128, unique=True)
@@ -28,7 +36,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:
@@ -39,10 +50,11 @@ class Manufacturer(models.Model):
def get_absolute_url(self):
return reverse(
"filtered", kwargs={
"filtered",
kwargs={
"_filter": "manufacturer",
"search": self.slug,
}
},
)
def logo_thumbnail(self):
@@ -58,7 +70,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:
@@ -70,12 +85,16 @@ class Company(models.Model):
def get_absolute_url(self):
return reverse(
"filtered", kwargs={
"filtered",
kwargs={
"_filter": "company",
"search": self.slug,
}
},
)
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 +111,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", "name"]
def __str__(self):
return "{0} - {1}".format(self.manufacturer, self.name)
@@ -113,28 +138,49 @@ class DecoderDocument(Document):
unique_together = ("decoder", "file")
def calculate_ratio(ratio):
try:
num, den = ratio.split(":")
return int(num) / float(den) * 10000
except (ValueError, ZeroDivisionError):
raise ValidationError("Invalid ratio format")
class Scale(models.Model):
scale = models.CharField(max_length=32, unique=True)
slug = models.CharField(max_length=32, unique=True, editable=False)
ratio = models.CharField(max_length=16, blank=True)
gauge = models.CharField(max_length=16, blank=True)
tracks = models.CharField(max_length=16, blank=True)
ratio = models.CharField(max_length=16, validators=[calculate_ratio])
ratio_int = models.SmallIntegerField(editable=False, default=0)
tracks = models.FloatField(
help_text="Distance between model tracks in mm",
)
gauge = models.CharField(
max_length=16,
blank=True,
help_text="Distance between real tracks. Please specify the unit (mm, in, ...)", # noqa: E501
)
class Meta:
ordering = ["scale"]
ordering = ["-ratio_int", "-tracks", "scale"]
def get_absolute_url(self):
return reverse(
"filtered", kwargs={
"filtered",
kwargs={
"_filter": "scale",
"search": self.slug,
}
},
)
def __str__(self):
return str(self.scale)
@receiver(models.signals.pre_save, sender=Scale)
def scale_save(sender, instance, **kwargs):
instance.ratio_int = calculate_ratio(instance.ratio)
class RollingStockType(models.Model):
type = models.CharField(max_length=64)
order = models.PositiveSmallIntegerField()
@@ -149,10 +195,11 @@ class RollingStockType(models.Model):
def get_absolute_url(self):
return reverse(
"filtered", kwargs={
"filtered",
kwargs={
"_filter": "type",
"search": self.slug,
}
},
)
def __str__(self):
@@ -163,15 +210,19 @@ 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
def get_absolute_url(self):
return reverse(
"filtered", kwargs={
"filtered",
kwargs={
"_filter": "tag",
"search": self.slug,
}
},
)

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

@@ -1,7 +1,8 @@
# Generated by Django 4.1 on 2022-08-23 15:54
import ckeditor.fields
import ckeditor_uploader.fields
# ckeditor dependency removal
# import ckeditor.fields
# import ckeditor_uploader.fields
from django.db import migrations
@@ -12,24 +13,24 @@ class Migration(migrations.Migration):
]
operations = [
migrations.AlterField(
model_name="flatpage",
name="content",
field=ckeditor_uploader.fields.RichTextUploadingField(),
),
migrations.AlterField(
model_name="siteconfiguration",
name="about",
field=ckeditor.fields.RichTextField(blank=True),
),
migrations.AlterField(
model_name="siteconfiguration",
name="footer",
field=ckeditor.fields.RichTextField(blank=True),
),
migrations.AlterField(
model_name="siteconfiguration",
name="footer_extended",
field=ckeditor.fields.RichTextField(blank=True),
),
# migrations.AlterField(
# model_name="flatpage",
# name="content",
# field=ckeditor_uploader.fields.RichTextUploadingField(),
# ),
# migrations.AlterField(
# model_name="siteconfiguration",
# name="about",
# field=ckeditor.fields.RichTextField(blank=True),
# ),
# migrations.AlterField(
# model_name="siteconfiguration",
# name="footer",
# field=ckeditor.fields.RichTextField(blank=True),
# ),
# migrations.AlterField(
# model_name="siteconfiguration",
# name="footer_extended",
# field=ckeditor.fields.RichTextField(blank=True),
# ),
]

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,21 @@
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.managers import PublicManager
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 +30,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 +42,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 +56,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 +67,13 @@ 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()
)
)
objects = PublicManager()
@receiver(models.signals.pre_save, sender=Flatpage)

View File

@@ -1,14 +1,14 @@
/*!
* Bootstrap Icons v1.11.1 (https://icons.getbootstrap.com/)
* Copyright 2019-2023 The Bootstrap Authors
* Bootstrap Icons v1.11.3 (https://icons.getbootstrap.com/)
* Copyright 2019-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE)
*/
@font-face {
font-display: block;
font-family: "bootstrap-icons";
src: url("./fonts/bootstrap-icons.woff2?2820a3852bdb9a5832199cc61cec4e65") format("woff2"),
url("./fonts/bootstrap-icons.woff?2820a3852bdb9a5832199cc61cec4e65") format("woff");
src: url("./fonts/bootstrap-icons.woff2?dd67030699838ea613ee6dbda90effa6") format("woff2"),
url("./fonts/bootstrap-icons.woff?dd67030699838ea613ee6dbda90effa6") format("woff");
}
.bi::before,

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

View File

@@ -46,12 +46,12 @@
transform="rotate(-90)" />
<text
xml:space="preserve"
style="font-weight:bold;font-size:0.444444px;line-height:1.25;font-family:'Noto Sans';-inkscape-font-specification:'Noto Sans Bold';letter-spacing:0px;word-spacing:0px;stroke-width:0.0138889"
style="font-weight:bold;font-size:0.444444px;line-height:1.25;font-family:system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue','Noto Sans','Liberation Sans',Arial,sans-serif;-inkscape-font-specification:'Noto Sans Bold';letter-spacing:0px;word-spacing:0px;stroke-width:0.0138889"
x="1.5366687"
y="1.6798887"
id="text1"><tspan
id="tspan1"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:0.444444px;font-family:'Noto Sans';-inkscape-font-specification:'Noto Sans';fill:#dee2e6;fill-opacity:1;stroke-width:0.0138889"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:0.444444px;font-family:system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue','Noto Sans','Liberation Sans',Arial,sans-serif;-inkscape-font-specification:'Noto Sans';fill:#dee2e6;fill-opacity:1;stroke-width:0.0138889"
x="1.5366687"
y="1.6798887">Coming soon</tspan></text>
</g>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

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

@@ -16,11 +16,11 @@
<link rel="icon" href="{% static "favicon.png" %}" sizes="any">
<link rel="icon" href="{% static "favicon.svg" %}" type="image/svg+xml">
{% if site_conf.use_cdn %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
<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-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
{% else %}
<link href="{% static "bootstrap@5.3.2/dist/css/bootstrap.min.css" %}" rel="stylesheet">
<link href="{% static "bootstrap-icons@1.11.1/font/bootstrap-icons.css" %}" rel="stylesheet">
<link href="{% static "bootstrap@5.3.3/dist/css/bootstrap.min.css" %}" rel="stylesheet">
<link href="{% static "bootstrap-icons@1.11.3/font/bootstrap-icons.css" %}" rel="stylesheet">
{% endif %}
<link href="{% static "css/main.css" %}?v={{ site_conf.version }}" rel="stylesheet">
<style>
@@ -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,33 +159,34 @@
<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 'rolling_stock_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_flatpages_menu %}
{% show_bookshelf_menu %}
{% show_flatpages_menu user %}
</ul>
{% include 'includes/search.html' %}
</div>
@@ -202,9 +216,9 @@
</main>
{% include 'includes/footer.html' %}
{% if site_conf.use_cdn %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% else %}
<script src="{% static "bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" %}"></script>
<script src="{% static "bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" %}"></script>
{% endif %}
</body>
</html>

View File

@@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load dynamic_url %}
{% block header %}
{% if book.tags.all %}
@@ -27,11 +28,11 @@
{% if book.image.count > 1 %}
<button class="carousel-control-prev" type="button" data-bs-target="#carouselControls" data-bs-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="visually-hidden">Previous</span>
<span class="visually-hidden"><i class="bi bi-chevron-left"></i></span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#carouselControls" data-bs-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="visually-hidden">Next</span>
<span class="visually-hidden"><i class="bi bi-chevron-right"></i></span>
</button>
{% endif %}
</div>
@@ -43,35 +44,55 @@
<section class="py-4 text-start container">
<div class="row">
<div class="mx-auto">
<nav>
<div class="nav nav-tabs" 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 %}
</div>
<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 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 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 documents %}<option value="nav-documents">Documents</option>{% endif %}
{% if book.notes %}<option value="nav-notes">Notes</option>{% endif %}
</select>
<div class="tab-content" id="nav-tabContent">
<div class="tab-pane fade show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
<table class="table table-striped">
{{ book.description | safe }}
<thead>
<tr>
{% if type == "catalog" %}
<th colspan="2" scope="row">Catalog</th>
{% elif type == "book" %}
<th colspan="2" scope="row">Book</th>
{% endif %}
</tr>
</thead>
<tbody class="table-group-divider">
{% if type == "catalog" %}
<tr>
<th class="w-33" scope="row">Manufacturer</th>
<td>{{ book.manufacturer }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Scales</th>
<td>{{ book.get_scales }}</td>
</tr>
{% elif type == "book" %}
<tr>
<th class="w-33" scope="row">Title</th>
<td>{{ book.title }}</td>
</tr>
<tr>
<th scope="row">Authors</th>
<td>
<ul class="mb-0 list-unstyled">{% for a in book.authors.all %}<li>{{ a }}</li>{% endfor %}</ul>
</td>
<th class="w-33" scope="row">Authors</th>
<td>
<ul class="mb-0 list-unstyled">{% for a in book.authors.all %}<li>{{ a }}</li>{% endfor %}</ul>
</td>
</tr>
<tr>
<th scope="row">Publisher</th>
<th class="w-33" scope="row">Publisher</th>
<td>{{ book.publisher }}</td>
</tr>
{% endif %}
<tr>
<th scope="row">ISBN</th>
<td>{{ book.ISBN|default:"-" }}</td>
@@ -94,7 +115,7 @@
</tr>
</tbody>
</table>
{% if book_properties %}
{% if properties %}
<table class="table table-striped">
<thead>
<tr>
@@ -102,7 +123,7 @@
</tr>
</thead>
<tbody class="table-group-divider">
{% for p in book_properties %}
{% for p in properties %}
<tr>
<th class="w-33" scope="row">{{ p.property }}</th>
<td>{{ p.value }}</td>
@@ -112,12 +133,30 @@
</table>
{% endif %}
</div>
<div class="tab-pane fade" id="nav-notes" role="tabpanel" aria-labelledby="nav-notes-tab">
<div class="tab-pane" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
<table class="table table-striped">
<thead>
<tr>
<th colspan="3" scope="row">Documents</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for d in documents.all %}
<tr>
<td class="w-33">{{ d.description }}</td>
<td><a href="{{ d.file.url }}" target="_blank">{{ d.filename }}</a></td>
<td class="text-end">{{ d.file.size | filesizeformat }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tab-pane" id="nav-notes" role="tabpanel" aria-labelledby="nav-notes-tab">
{{ book.notes | safe }}
</div>
</div>
<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:bookshelf_book_change' book.pk %}">Edit</a>{% endif %}
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% dynamic_admin_url 'bookshelf' type book.pk %}">Edit</a>{% endif %}
</div>
</div>
</div>

View File

@@ -1,104 +0,0 @@
{% extends "cards.html" %}
{% block cards %}
{% for d in data %}
<div class="col">
<div class="card shadow-sm">
{% if d.image.all %}
<a href="{{ d.get_absolute_url }}">
{% for r in d.image.all %}
{% if forloop.first %}<img src="{{ r.image.url }}" alt="Card image cap">{% endif %}
{% endfor %}
</a>
{% endif %}
<div class="card-body">
<p class="card-text" style="position: relative;">
<strong>{{ d }}</strong>
<a class="stretched-link" href="{{ d.get_absolute_url }}"></a>
</p>
{% if d.tags.all %}
<p class="card-text"><small>Tags:</small>
{% for t in d.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary">
{{ t.name }}</a>{# new line is required #}
{% endfor %}
</p>
{% endif %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Book</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Authors</th>
<td>
<ul class="mb-0 list-unstyled">{% for a in d.authors.all %}<li>{{ a }}</li>{% endfor %}</ul>
</td>
</tr>
<tr>
<th class="w-33" scope="row">Publisher</th>
<td>{{ d.publisher }}</td>
</tr>
<tr>
<th scope="row">Language</th>
<td>{{ d.get_language_display }}</td>
</tr>
<tr>
<th scope="row">Pages</th>
<td>{{ d.number_of_pages|default:"-" }}</td>
</tr>
<tr>
<th scope="row">Year</th>
<td>{{ d.publication_year|default:"-" }}</td>
</tr>
</tbody>
</table>
<div class="d-grid gap-2 mb-1 d-md-block">
<a class="btn btn-sm btn-outline-primary" href="{{ d.get_absolute_url }}">Show all data</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:bookshelf_book_change' d.pk %}">Edit</a>{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
{% endblock %}
{% block pagination %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<ul class="pagination justify-content-center mt-4 mb-0">
{% if data.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'books_pagination' page=data.previous_page_number %}#main-content" tabindex="-1">Previous</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Previous</span>
</li>
{% endif %}
{% for i in page_range %}
{% if data.number == i %}
<li class="page-item active">
<span class="page-link">{{ i }}</span>
</li>
{% else %}
{% 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 'books_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 'books_pagination' page=data.next_page_number %}#main-content" tabindex="-1">Next</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Next</span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}

View File

@@ -4,7 +4,12 @@
Bookshelf
</a>
<ul class="dropdown-menu" aria-labelledby="bookshelfDropdownMenuLink">
<li><a class="nav-link" href="{% url 'books' %}">Books</a></li>
{% if books_menu %}
<li><a class="dropdown-item" href="{% url 'books' %}">Books</a></li>
{% endif %}
{% if catalogs_menu %}
<li><a class="dropdown-item" href="{% url 'catalogs' %}">Catalogs</a></li>
{% endif %}
</ul>
</li>
{% endif %}

View File

@@ -1,104 +1,26 @@
{% extends "base.html" %}
{% load static %}
{% block header %}
<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 %}
<div class="col">
<div class="card shadow-sm">
{% if d.image.count > 0 %}
<a href="{{d.get_absolute_url}}"><img class="card-img-top" src="{{ d.image.first.image.url }}" alt="{{ d }}"></a>
{% else %}
<!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
<a href="{{d.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d }}"></a>
{% endif %}
<div class="card-body">
<p class="card-text" style="position: relative;">
<strong>{{ d }}</strong>
<a class="stretched-link" href="{{ d.get_absolute_url }}"></a>
</p>
{% if d.tags.all %}
<p class="card-text"><small>Tags:</small>
{% for t in d.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary">
{{ t.name }}</a>{# new line is required #}
{% endfor %}
</p>
{% endif %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Data</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Type</th>
<td>{{ d.rolling_class.type }}</td>
</tr>
<tr>
<th scope="row">Company</th>
<td>
<a href="{% url 'filtered' _filter="company" search=d.rolling_class.company.slug %}"><abbr title="{{ d.rolling_class.company.extended_name }}">{{ d.rolling_class.company }}</abbr></a>
</td>
</tr>
<tr>
<th scope="row">Class</th>
<td>{{ d.rolling_class.identifier }}</td>
</tr>
<tr>
<th scope="row">Road number</th>
<td>{{ d.road_number }}</td>
</tr>
<tr>
<th scope="row">Era</th>
<td>{{ d.era }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Manufacturer</th>
<td>{%if d.manufacturer %}
<a href="{% url 'filtered' _filter="manufacturer" search=d.manufacturer.slug %}">{{ d.manufacturer }}{% if d.manufacturer.website %}</a> <a href="{{ d.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
{% endif %}</td>
</tr>
<tr>
<th scope="row">Scale</th>
<td><a href="{% url 'filtered' _filter="scale" search=d.scale.slug %}"><abbr title="{{ d.scale.ratio }} - {{ d.scale.tracks }}">{{ d.scale }}</abbr></a></td>
</tr>
<tr>
<th scope="row">Item number</th>
<td>{{ d.item_number }}</td>
</tr>
</tbody>
</table>
{% if d.decoder %}
<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">Decoder</th>
<td>{{ d.decoder }}</td>
</tr>
<tr>
<th scope="row">Address</th>
<td>{{ d.address }}</td>
</tr>
</tbody>
</table>
{% endif %}
<div class="d-grid gap-2 mb-1 d-md-block">
<a class="btn btn-sm btn-outline-primary" href="{{d.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.pk %}">Edit</a>{% endif %}
</div>
</div>
</div>
</div>
{% if d.type == "roster" %}
{% include "cards/roster.html" %}
{% elif d.type == "company" %}
{% include "cards/company.html" %}
{% elif d.type == "rolling_stock_type" %}
{% include "cards/rolling_stock_type.html" %}
{% elif d.type == "scale" %}
{% include "cards/scale.html" %}
{% elif d.type == "consist" %}
{% include "cards/consist.html" %}
{% elif d.type == "manufacturer" %}
{% include "cards/manufacturer.html" %}
{% elif d.type == "book" or d.type == "catalog" %}
{% include "cards/book.html" %}
{% endif %}
{% endfor %}
{% endblock %}
</div>

View File

@@ -0,0 +1,72 @@
{% load dynamic_url %}
<div class="col">
<div class="card shadow-sm">
{% 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;">
<strong>{{ d.item }}</strong>
<a class="stretched-link" href="{{ d.item.get_absolute_url }}"></a>
</p>
{% if d.item.tags.all %}
<p class="card-text"><small>Tags:</small>
{% for t in d.item.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary">
{{ t.name }}</a>{# new line is required #}
{% endfor %}
</p>
{% endif %}
<table class="table table-striped">
<thead>
<tr>
{% if d.type == "catalog" %}
<th colspan="2" scope="row">Catalog</th>
{% elif d.type == "book" %}
<th colspan="2" scope="row">Book</th>
{% endif %}
</tr>
</thead>
<tbody class="table-group-divider">
{% if d.type == "catalog" %}
<tr>
<th class="w-33" scope="row">Manufacturer</th>
<td>{{ d.item.manufacturer }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Scales</th>
<td>{{ d.item.get_scales }}</td>
</tr>
{% elif d.type == "book" %}
<tr>
<th class="w-33" scope="row">Authors</th>
<td>
<ul class="mb-0 list-unstyled">{% for a in d.item.authors.all %}<li>{{ a }}</li>{% endfor %}</ul>
</td>
</tr>
<tr>
<th class="w-33" scope="row">Publisher</th>
<td>{{ d.item.publisher }}</td>
</tr>
{% endif %}
<tr>
<th scope="row">Language</th>
<td>{{ d.item.get_language_display }}</td>
</tr>
<tr>
<th scope="row">Pages</th>
<td>{{ d.item.number_of_pages|default:"-" }}</td>
</tr>
<tr>
<th scope="row">Year</th>
<td>{{ d.item.publication_year|default:"-" }}</td>
</tr>
</tbody>
</table>
<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>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% dynamic_admin_url 'bookshelf' d.type d.item.pk %}">Edit</a>{% endif %}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,46 @@
<div class="col">
<div class="card shadow-sm">
<div class="card-body">
<p class="card-text" style="position: relative;">
<strong>{{ d.item.name }}</strong>
</p>
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Company</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% if d.item.logo %}
<tr>
<th class="w-33" scope="row">Logo</th>
<td><img class="logo" src="{{ d.item.logo.url }}" alt="{{ d.item.name }} logo"></td>
</tr>
{% endif %}
<tr>
<th class="w-33" scope="row">Name</th>
<td>{{ d.item.extended_name }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Abbreviation</th>
<td>{{ d.item.name }}</td>
</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 }}">
</tr>
{% if d.item.freelance %}
<tr>
<th class="w-33" scope="row">Notes</th>
<td>A <em>freelance</em> company</td>
</tr>
{% endif %}
</tbody>
</table>
<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>
{% 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 %}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,57 @@
<div class="col">
<div class="card shadow-sm">
<a href="{{ d.item.get_absolute_url }}">
{% if d.item.image %}
<img class="card-img-top" src="{{ d.item.image.url }}" alt="{{ d.item }}">
{% else %}
{% with d.item.consist_item.first.rolling_stock as r %}
<img class="card-img-top" src="{{ r.image.first.image.url }}" alt="{{ d.item }}">
{% endwith %}
{% endif %}
</a>
<div class="card-body">
<p class="card-text" style="position: relative;">
<strong>{{ d.item }}</strong>
<a class="stretched-link" href="{{ d.item.get_absolute_url }}"></a>
</p>
{% if d.item.tags.all %}
<p class="card-text"><small>Tags:</small>
{% for t in d.item.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary">
{{ t.name }}</a>{# new line is required #}
{% endfor %}
</p>
{% endif %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Consist</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% if d.item.address %}
<tr>
<th class="w-33" scope="row">Address</th>
<td>{{ d.item.address }}</td>
</tr>
{% endif %}
<tr>
<th class="w-33" scope="row">Company</th>
<td><abbr title="{{ d.item.company.extended_name }}">{{ d.item.company }}</abbr></td>
</tr>
<tr>
<th scope="row">Era</th>
<td>{{ d.item.era }}</td>
</tr>
<tr>
<th scope="row">Length</th>
<td>{{ d.item.consist_item.count }}</td>
</tr>
</tbody>
</table>
<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>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:consist_consist_change' d.item.pk %}">Edit</a>{% endif %}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,39 @@
<div class="col">
<div class="card shadow-sm">
<div class="card-body">
<p class="card-text" style="position: relative;">
<strong>{{ d.item.name }}</strong>
</p>
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Manufacturer</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% if d.item.logo %}
<tr>
<th class="w-33" scope="row">Logo</th>
<td><img class="logo" src="{{ d.item.logo.url }}" alt="{{ d.item.name }} logo"></td>
</tr>
{% endif %}
{% if d.item.website %}
<tr>
<th class="w-33" scope="row">Website</th>
<td><a href="{{ d.item.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a></td>
</tr>
{% endif %}
<tr>
<th class="w-33" scope="row">Category</th>
<td>{{ d.item.category | title }}</td>
</tr>
</tbody>
</table>
<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>
{% 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 %}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,29 @@
<div class="col">
<div class="card shadow-sm">
<div class="card-body">
<p class="card-text"><strong>{{ d.item }}</strong></p>
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Type</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Type</th>
<td>{{ d.item.type }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Category</th>
<td>{{ d.item.category | title}}</td>
</tr>
</tbody>
</table>
<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>
{% 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 %}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,92 @@
{% load static %}
<div class="col">
<div class="card shadow-sm">
{% 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.item }}"></a>
{% endif %}
<div class="card-body">
<p class="card-text" style="position: relative;">
<strong>{{ d.item }}</strong>
<a class="stretched-link" href="{{ d.item.get_absolute_url }}"></a>
</p>
{% if d.item.tags.all %}
<p class="card-text"><small>Tags:</small>
{% for t in d.item.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary">
{{ t.name }}</a>{# new line is required #}
{% endfor %}
</p>
{% endif %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Rolling stock</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Type</th>
<td>{{ d.item.rolling_class.type }}</td>
</tr>
<tr>
<th scope="row">Company</th>
<td>
<a href="{% url 'filtered' _filter="company" search=d.item.rolling_class.company.slug %}"><abbr title="{{ d.item.rolling_class.company.extended_name }}">{{ d.item.rolling_class.company }}</abbr></a>
</td>
</tr>
<tr>
<th scope="row">Class</th>
<td>{{ d.item.rolling_class.identifier }}</td>
</tr>
<tr>
<th scope="row">Road number</th>
<td>{{ d.item.road_number }}</td>
</tr>
<tr>
<th scope="row">Era</th>
<td>{{ d.item.era }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Manufacturer</th>
<td>{%if d.item.manufacturer %}
<a href="{% url 'filtered' _filter="manufacturer" search=d.item.manufacturer.slug %}">{{ d.item.manufacturer }}{% if d.item.manufacturer.website %}</a> <a href="{{ d.item.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
{% endif %}</td>
</tr>
<tr>
<th scope="row">Scale</th>
<td><a href="{% url 'filtered' _filter="scale" search=d.item.scale.slug %}"><abbr title="{{ d.item.scale.ratio }} - {{ d.item.scale.tracks }} mm">{{ d.item.scale }}</abbr></a></td>
</tr>
<tr>
<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>
</tr>
</tbody>
</table>
{% if d.item.decoder %}
<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">Decoder</th>
<td>{{ d.item.decoder }}</td>
</tr>
<tr>
<th scope="row">Address</th>
<td>{{ d.item.address }}</td>
</tr>
</tbody>
</table>
{% endif %}
<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>
{% 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 %}
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,36 @@
<div class="col">
<div class="card shadow-sm">
<div class="card-body">
<p class="card-text"><strong>{{ d.item }}</strong></p>
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Scale</th>
</tr>
</thead>
<tbody>
<tr>
<th class="w-33" scope="row">Name</th>
<td>{{ d.item.scale }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Ratio</th>
<td>{{ d.item.ratio }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Tracks</th>
<td>{{ d.item.tracks }} mm</td>
</tr>
<tr>
<th class="w-33" scope="row">Gauge</th>
<td>{{ d.item.gauge }}</td>
</tr>
</tbody>
</table>
<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>
{% 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 %}
</div>
</div>
</div>
</div>

View File

@@ -1,91 +0,0 @@
{% extends "cards.html" %}
{% block cards %}
{% for d in data %}
<div class="col">
<div class="card shadow-sm">
<div class="card-body">
<p class="card-text" style="position: relative;">
<strong>{{ d.name }}</strong>
</p>
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Company</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% if d.logo %}
<tr>
<th class="w-33" scope="row">Logo</th>
<td><img style="max-height: 48px" src="{{ d.logo.url }}" /></td>
</tr>
{% endif %}
<tr>
<th class="w-33" scope="row">Name</th>
<td>{{ d.extended_name }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Abbreviation</th>
<td>{{ d.name }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Country</th>
<td>{{ d.country.name }} <img src="{{ d.country.flag }}" alt="{{ d.country }}" />
</tr>
{% if d.freelance %}
<tr>
<th class="w-33" scope="row">Notes</th>
<td>A <em>freelance</em> company</td>
</tr>
{% endif %}
</tbody>
</table>
<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.slug %}">Show all rolling stock</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_company_change' d.pk %}">Edit</a>{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
{% endblock %}
{% block pagination %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<ul class="pagination justify-content-center mt-4 mb-0">
{% if data.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'companies_pagination' page=data.previous_page_number %}#main-content" tabindex="-1">Previous</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Previous</span>
</li>
{% endif %}
{% for i in page_range %}
{% if data.number == i %}
<li class="page-item active">
<span class="page-link">{{ i }}</span>
</li>
{% else %}
{% 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 'companies_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 'companies_pagination' page=data.next_page_number %}#main-content" tabindex="-1">Next</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Next</span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}

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>
@@ -26,14 +26,14 @@
{% block pagination %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<ul class="pagination justify-content-center mt-4 mb-0">
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'consist_pagination' uuid=consist.uuid page=data.previous_page_number %}#main-content" tabindex="-1">Previous</a>
<a class="page-link" href="{% url 'consist_pagination' uuid=consist.uuid page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Previous</span>
<span class="page-link"><i class="bi bi-chevron-left"></i></span>
</li>
{% endif %}
{% for i in page_range %}
@@ -51,11 +51,11 @@
{% endfor %}
{% if data.has_next %}
<li class="page-item">
<a class="page-link" href="{% url 'consist_pagination' uuid=consist.uuid page=data.next_page_number %}#main-content" tabindex="-1">Next</a>
<a class="page-link" href="{% url 'consist_pagination' uuid=consist.uuid page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Next</span>
<span class="page-link"><i class="bi bi-chevron-right"></i></span>
</li>
{% endif %}
</ul>
@@ -66,14 +66,16 @@
<section class="py-4 text-start container">
<div class="row">
<div class="mx-auto">
<nav>
<div class="nav nav-tabs" 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 %}
</div>
<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 fade show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
<table class="table table-striped">
<thead>
<tr>
@@ -83,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>
@@ -96,7 +100,7 @@
</tbody>
</table>
</div>
<div class="tab-pane fade" id="nav-notes" role="tabpanel" aria-labelledby="nav-notes-tab">
<div class="tab-pane" id="nav-notes" role="tabpanel" aria-labelledby="nav-notes-tab">
<table class="table">
<thead>
<tr>

View File

@@ -1,104 +0,0 @@
{% extends "cards.html" %}
{% block cards %}
{% for d in data %}
<div class="col">
<div class="card shadow-sm">
<a href="{{ d.get_absolute_url }}">
{% if d.image %}
<img src="{{ d.image.url }}" alt="Card image cap">
{% else %}
{% with d.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 %}
{% endwith %}
{% endif %}
</a>
<div class="card-body">
<p class="card-text" style="position: relative;">
<strong>{{ d }}</strong>
<a class="stretched-link" href="{{ d.get_absolute_url }}"></a>
</p>
{% if d.tags.all %}
<p class="card-text"><small>Tags:</small>
{% for t in d.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary">
{{ t.name }}</a>{# new line is required #}
{% endfor %}
</p>
{% endif %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Consist data</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% if d.address %}
<tr>
<th class="w-33" scope="row">Address</th>
<td>{{ d.address }}</td>
</tr>
{% endif %}
<tr>
<th class="w-33" scope="row">Company</th>
<td><abbr title="{{ d.company.extended_name }}">{{ d.company }}</abbr></td>
</tr>
<tr>
<th scope="row">Era</th>
<td>{{ d.era }}</td>
</tr>
<tr>
<th scope="row">Length</th>
<td>{{ d.consist_item.all | length }}</td>
</tr>
</tbody>
</table>
<div class="d-grid gap-2 mb-1 d-md-block">
<a class="btn btn-sm btn-outline-primary" href="{{ d.get_absolute_url }}">Show all data</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:consist_consist_change' d.pk %}">Edit</a>{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
{% endblock %}
{% block pagination %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<ul class="pagination justify-content-center mt-4 mb-0">
{% if data.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'consists_pagination' page=data.previous_page_number %}#main-content" tabindex="-1">Previous</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Previous</span>
</li>
{% endif %}
{% for i in page_range %}
{% if data.number == i %}
<li class="page-item active">
<span class="page-link">{{ i }}</span>
</li>
{% else %}
{% 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 'consists_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 'consists_pagination' page=data.next_page_number %}#main-content" tabindex="-1">Next</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Next</span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}

View File

@@ -1,16 +1,15 @@
{% extends "cards.html" %}
{% block pagination %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<ul class="pagination justify-content-center mt-4 mb-0">
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'filtered_pagination' _filter=filter search=search page=data.previous_page_number %}#main-content" tabindex="-1">Previous</a>
<a class="page-link" href="{% url 'filtered_pagination' _filter=filter search=search page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Previous</span>
<span class="page-link"><i class="bi bi-chevron-left"></i></span>
</li>
{% endif %}
{% for i in page_range %}
@@ -28,11 +27,11 @@
{% endfor %}
{% if data.has_next %}
<li class="page-item">
<a class="page-link" href="{% url 'filtered_pagination' _filter=filter search=search page=data.next_page_number %}#main-content" tabindex="-1">Next</a>
<a class="page-link" href="{% url 'filtered_pagination' _filter=filter search=search page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Next</span>
<span class="page-link"><i class="bi bi-chevron-right"></i></span>
</li>
{% endif %}
</ul>

View File

@@ -1,4 +1,4 @@
{% extends "roster.html" %}
{% extends "pagination.html" %}
{% block header %}
<div class="text-muted">{{ site_conf.about | safe }}</div>

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,9 +17,13 @@
<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>
<li>
<form id="logout-form" method="post" action="{% url 'admin:logout' %}?next={{ request.path }}">
{% csrf_token %}
<button class="btn btn-link dropdown-item text-danger" type="submit">Log out</button>
</form>
</li>
</ul>
{% else %}
<a class="nav-link" href="{% url 'admin:login' %}?next={{ request.path }}">Log in</a>

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

@@ -0,0 +1,40 @@
{% extends "cards.html" %}
{% block pagination %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'manufacturer_pagination' manufacturer=manufacturer.slug search=search page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-left"></i></span>
</li>
{% endif %}
{% for i in page_range %}
{% if data.number == i %}
<li class="page-item active">
<span class="page-link">{{ i }}</span>
</li>
{% else %}
{% 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 'manufacturer_pagination' manufacturer=manufacturer.slug search=search page=i %}#main-content">{{ i }}</a></li>
{% endif %}
{% endif %}
{% endfor %}
{% if data.has_next %}
<li class="page-item">
<a class="page-link" href="{% url 'manufacturer_pagination' manufacturer=manufacturer.slug search=search page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-right"></i></span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}

View File

@@ -1,85 +0,0 @@
{% extends "cards.html" %}
{% block cards %}
{% for d in data %}
<div class="col">
<div class="card shadow-sm">
<div class="card-body">
<p class="card-text" style="position: relative;">
<strong>{{ d.name }}</strong>
</p>
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Manufacturer</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% if d.logo %}
<tr>
<th class="w-33" scope="row">Logo</th>
<td><img style="max-height: 48px" src="{{ d.logo.url }}" /></td>
</tr>
{% endif %}
{% if d.website %}
<tr>
<th class="w-33" scope="row">Website</th>
<td><a href="{{ d.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a></td>
</tr>
{% endif %}
<tr>
<th class="w-33" scope="row">Category</th>
<td>{{ d.category | title }}</td>
</tr>
</tbody>
</table>
<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.slug %}">Show all rolling stock</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_manufacturer_change' d.pk %}">Edit</a>{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
{% endblock %}
{% block pagination %}
{% if data.has_other_pages %}
{% with data.0.category as c %}
<nav aria-label="Page navigation example">
<ul class="pagination justify-content-center mt-4 mb-0">
{% if data.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'manufacturers_pagination' category=c page=data.previous_page_number %}#main-content" tabindex="-1">Previous</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Previous</span>
</li>
{% endif %}
{% for i in page_range %}
{% if data.number == i %}
<li class="page-item active">
<span class="page-link">{{ i }}</span>
</li>
{% else %}
{% 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 'manufacturers_pagination' category=c page=i %}#main-content">{{ i }}</a></li>
{% endif %}
{% endif %}
{% endfor %}
{% if data.has_next %}
<li class="page-item">
<a class="page-link" href="{% url 'manufacturers_pagination' category=c page=data.next_page_number %}#main-content" tabindex="-1">Next</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Next</span>
</li>
{% endif %}
</ul>
</nav>
{% endwith %}
{% endif %}
{% endblock %}

View File

@@ -1,16 +1,17 @@
{% extends "cards.html" %}
{% load dynamic_url %}
{% block pagination %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<ul class="pagination justify-content-center mt-4 mb-0">
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'roster_pagination' page=data.previous_page_number %}#main-content" tabindex="-1">Previous</a>
<a class="page-link" href="{% dynamic_pagination type page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Previous</span>
<span class="page-link"><i class="bi bi-chevron-left"></i></span>
</li>
{% endif %}
{% for i in page_range %}
@@ -22,17 +23,17 @@
{% 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 'roster_pagination' page=i %}#main-content">{{ i }}</a></li>
<li class="page-item"><a class="page-link" href="{% dynamic_pagination type page=i %}#main-content">{{ i }}</a></li>
{% endif %}
{% endif %}
{% endfor %}
{% if data.has_next %}
<li class="page-item">
<a class="page-link" href="{% url 'roster_pagination' page=data.next_page_number %}#main-content" tabindex="-1">Next</a>
<a class="page-link" href="{% dynamic_pagination type page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Next</span>
<span class="page-link"><i class="bi bi-chevron-right"></i></span>
</li>
{% endif %}
</ul>

View File

@@ -0,0 +1,42 @@
{% extends "cards.html" %}
{% block pagination %}
{% if data.has_other_pages %}
{% with data.0.item.category as c %}
<nav aria-label="Page navigation example">
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'manufacturers_pagination' category=c page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-left"></i></span>
</li>
{% endif %}
{% for i in page_range %}
{% if data.number == i %}
<li class="page-item active">
<span class="page-link">{{ i }}</span>
</li>
{% else %}
{% 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 'manufacturers_pagination' category=c page=i %}#main-content">{{ i }}</a></li>
{% endif %}
{% endif %}
{% endfor %}
{% if data.has_next %}
<li class="page-item">
<a class="page-link" href="{% url 'manufacturers_pagination' category=c page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-right"></i></span>
</li>
{% endif %}
</ul>
</nav>
{% endwith %}
{% endif %}
{% endblock %}

View File

@@ -27,11 +27,11 @@
{% if rolling_stock.image.count > 1 %}
<button class="carousel-control-prev" type="button" data-bs-target="#carouselControls" data-bs-slide="prev">
<span class="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="visually-hidden">Previous</span>
<span class="visually-hidden"><i class="bi bi-chevron-left"></i></span>
</button>
<button class="carousel-control-next" type="button" data-bs-target="#carouselControls" data-bs-slide="next">
<span class="carousel-control-next-icon" aria-hidden="true"></span>
<span class="visually-hidden">Next</span>
<span class="visually-hidden"><i class="bi bi-chevron-right"></i></span>
</button>
{% endif %}
</div>
@@ -43,19 +43,33 @@
<section class="py-4 text-start container">
<div class="row">
<div class="mx-auto">
<nav>
<div class="nav nav-tabs" 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>
{% 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 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 %}
</div>
<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</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 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 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 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 set %}<option value="nav-set">Set</option>{% endif %}
{% if consists %}<option value="nav-consists">Consists</option>{% endif %}
</select>
{% with class=rolling_stock.rolling_class company=rolling_stock.rolling_class.company %}
<div class="tab-content" id="nav-tabContent">
<div class="tab-pane fade show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
<table class="table table-striped">
<thead>
<tr>
@@ -65,17 +79,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>
@@ -97,16 +111,16 @@
<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>
<th scope="row">Scale</th>
<td><a href="{% url 'filtered' _filter="scale" search=rolling_stock.scale %}"><abbr title="{{ rolling_stock.scale.ratio }} - {{ rolling_stock.scale.tracks }}">{{ rolling_stock.scale }}</abbr></a></td>
<td><a href="{% url 'filtered' _filter="scale" search=rolling_stock.scale %}"><abbr title="{{ rolling_stock.scale.ratio }} - {{ rolling_stock.scale.tracks }} mm">{{ rolling_stock.scale }}</abbr></a></td>
</tr>
<tr>
<th scope="row">Item number</th>
<td>{{ rolling_stock.item_number }}</td>
<td>{{ rolling_stock.item_number }}{%if rolling_stock.set %} | <a class="badge text-bg-primary" href="{% url 'manufacturer' manufacturer=rolling_stock.manufacturer.slug search=rolling_stock.item_number_slug %}">SET</a>{% endif %}</td>
</tr>
</tbody>
</table>
@@ -136,7 +150,8 @@
</table>
{% endif %}
</div>
<div class="tab-pane fade" id="nav-model" role="tabpanel" aria-labelledby="nav-model-tab">
<div class="tab-pane" id="nav-model" role="tabpanel" aria-labelledby="nav-model-tab">
{{ rolling_stock.description | safe }}
<table class="table table-striped">
<thead>
<tr>
@@ -146,17 +161,19 @@
<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>
<td><a href="{% url 'filtered' _filter="scale" search=rolling_stock.scale %}"><abbr title="{{ rolling_stock.scale.ratio }} - {{ rolling_stock.scale.tracks }}">{{ rolling_stock.scale }}</abbr></a></td>
<td><a href="{% url 'filtered' _filter="scale" search=rolling_stock.scale %}"><abbr title="{{ rolling_stock.scale.ratio }} - {{ rolling_stock.scale.tracks }} mm">{{ rolling_stock.scale }}</abbr></a></td>
</tr>
<tr>
<th scope="row">Item number</th>
<td>{{ rolling_stock.item_number }}</td>
<td>{{ rolling_stock.item_number }}{%if rolling_stock.set %} | <a class="badge text-bg-primary" href="{% url 'manufacturer' manufacturer=rolling_stock.manufacturer.slug search=rolling_stock.item_number_slug %}">SET</a>{% endif %}</td>
</tr>
<tr>
<th scope="row">Era</th>
@@ -172,7 +189,7 @@
</tr>
</tbody>
</table>
{% if rolling_stock_properties %}
{% if properties %}
<table class="table table-striped">
<thead>
<tr>
@@ -180,7 +197,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>
@@ -190,7 +207,8 @@
</table>
{% endif %}
</div>
<div class="tab-pane fade" id="nav-class" role="tabpanel" aria-labelledby="nav-class-tab">
<div class="tab-pane" id="nav-class" role="tabpanel" aria-labelledby="nav-class-tab">
{{ class.description | safe }}
<table class="table table-striped">
<thead>
<tr>
@@ -200,27 +218,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>
@@ -242,11 +252,42 @@
</table>
{% endif %}
</div>
<div class="tab-pane fade" id="nav-dcc" role="tabpanel" aria-labelledby="nav-dcc-tab">
<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">DCC data</th>
<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">Decoder data</th>
</tr>
</thead>
<tbody class="table-group-divider">
@@ -277,8 +318,8 @@
</tbody>
</table>
</div>
<div class="tab-pane fade" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
{% if rolling_stock.document.count > 0 %}
<div class="tab-pane" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
{% if documents %}
<table class="table table-striped">
<thead>
<tr>
@@ -286,7 +327,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>
@@ -296,7 +337,7 @@
</tbody>
</table>
{% endif %}
{% if rolling_stock.decoder.document.count > 0 %}
{% if decoder_documents %}
<table class="table table-striped">
<thead>
<tr>
@@ -304,7 +345,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>
@@ -315,27 +356,42 @@
</table>
{% endif %}
</div>
<div class="tab-pane fade" id="nav-journal" role="tabpanel" aria-labelledby="nav-journal-tab">
<div class="tab-pane" id="nav-journal" role="tabpanel" aria-labelledby="nav-journal-tab">
<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>
</table>
</div>
<div class="tab-pane fade" id="nav-notes" role="tabpanel" aria-labelledby="nav-notes-tab">
<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-set" role="tabpanel" aria-labelledby="nav-set-tab">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3 mb-3">
{% for d in set %}
{% include "cards/roster.html" %}
{% endfor %}
</div>
</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

@@ -1,81 +0,0 @@
{% extends "cards.html" %}
{% block cards %}
{% for d in data %}
<div class="col">
<div class="card shadow-sm">
<div class="card-body">
<p class="card-text"><strong>{{ d }}</strong></p>
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Scale</th>
</tr>
</thead>
<tbody>
<tr>
<th class="w-33" scope="row">Name</th>
<td>{{ d.scale }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Ratio</th>
<td>{{ d.ratio }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Gauge</th>
<td>{{ d.gauge }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Tracks</th>
<td>{{ d.tracks }}</td>
</tr>
</tbody>
</table>
<div class="d-grid gap-2 mb-1 d-md-block">
<a class="btn btn-sm btn-outline-primary" href="{% url 'filtered' _filter="scale" search=d.slug %}">Show all rolling stock</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_scale_change' d.pk %}">Edit</a>{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
{% endblock %}
{% block pagination %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<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>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Previous</span>
</li>
{% endif %}
{% for i in page_range %}
{% if data.number == i %}
<li class="page-item active">
<span class="page-link">{{ i }}</span>
</li>
{% else %}
{% 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>
{% 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>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Next</span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}

View File

@@ -3,14 +3,14 @@
{% block pagination %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<ul class="pagination justify-content-center mt-4 mb-0">
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'search_pagination' search=encoded_search page=data.previous_page_number %}#main-content" tabindex="-1">Previous</a>
<a class="page-link" href="{% url 'search_pagination' search=encoded_search page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Previous</span>
<span class="page-link"><i class="bi bi-chevron-left"></i></span>
</li>
{% endif %}
{% for i in page_range %}
@@ -28,11 +28,11 @@
{% endfor %}
{% if data.has_next %}
<li class="page-item">
<a class="page-link" href="{% url 'search_pagination' search=encoded_search page=data.next_page_number %}#main-content" tabindex="-1">Next</a>
<a class="page-link" href="{% url 'search_pagination' search=encoded_search page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Next</span>
<span class="page-link"><i class="bi bi-chevron-right"></i></span>
</li>
{% endif %}
</ul>

View File

@@ -1,73 +0,0 @@
{% extends "cards.html" %}
{% block cards %}
{% for d in data %}
<div class="col">
<div class="card shadow-sm">
<div class="card-body">
<p class="card-text"><strong>{{ d }}</strong></p>
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Type</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Type</th>
<td>{{ d.type }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Category</th>
<td>{{ d.category | title}}</td>
</tr>
</tbody>
</table>
<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.slug %}">Show all rolling stock</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_scale_change' d.pk %}">Edit</a>{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
{% endblock %}
{% block pagination %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<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>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Previous</span>
</li>
{% endif %}
{% for i in page_range %}
{% if data.number == i %}
<li class="page-item active">
<span class="page-link">{{ i }}</span>
</li>
{% else %}
{% 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>
{% 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>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Next</span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,21 @@
from django import template
from django.urls import reverse
register = template.Library()
@register.simple_tag
def dynamic_admin_url(app_name, model_name, object_id=None):
if object_id:
return reverse(
f'admin:{app_name}_{model_name}_change',
args=[object_id]
)
return reverse(f'admin:{app_name}_{model_name}_changelist')
@register.simple_tag
def dynamic_pagination(reverse_name, page):
if reverse_name.endswith('y'):
return reverse(f'{reverse_name[:-1]}ies_pagination', args=[page])
return reverse(f'{reverse_name}s_pagination', args=[page])

View File

@@ -1,16 +1,21 @@
from django import template
from portal.models import Flatpage
from bookshelf.models import Book
from bookshelf.models import Book, Catalog
register = template.Library()
@register.inclusion_tag('bookshelf/bookshelf_menu.html')
def show_bookshelf_menu():
return {"bookshelf_menu": Book.objects.exists()}
# FIXME: Filter out unpublished books and catalogs?
return {
"bookshelf_menu": (Book.objects.exists() or Catalog.objects.exists()),
"books_menu": Book.objects.exists(),
"catalogs_menu": Catalog.objects.exists(),
}
@register.inclusion_tag('flatpages/flatpages_menu.html')
def show_flatpages_menu():
menu = Flatpage.objects.filter(published=True).order_by("name")
def show_flatpages_menu(user):
menu = Flatpage.objects.get_published(user).order_by("name")
return {"flatpages_menu": menu}

View File

@@ -3,7 +3,8 @@ from django.urls import path
from portal.views import (
GetData,
GetRoster,
GetRosterFiltered,
GetObjectsFiltered,
GetManufacturerItem,
GetFlatpage,
GetRollingStock,
GetConsist,
@@ -13,70 +14,143 @@ from portal.views import (
Scales,
Types,
Books,
GetBook,
SearchRoster,
Catalogs,
GetBookCatalog,
SearchObjects,
)
urlpatterns = [
path("", GetData.as_view(), name="index"),
path("", GetData.as_view(template="home.html"), name="index"),
path("roster", GetRoster.as_view(), name="roster"),
path("roster/<int:page>", GetRoster.as_view(), name="roster_pagination"),
path(
"roster/page/<int:page>",
GetRoster.as_view(),
name="rosters_pagination"
),
path(
"page/<str:flatpage>",
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(),
name="consists"
),
path(
"consists/page/<int:page>",
Consists.as_view(),
name="consists_pagination"
),
path("consist/<uuid:uuid>", GetConsist.as_view(), name="consist"),
path(
"consist/<uuid:uuid>/<int:page>",
"consist/<uuid:uuid>/page/<int:page>",
GetConsist.as_view(),
name="consist_pagination",
),
path("companies", Companies.as_view(), name="companies"),
path(
"companies/<int:page>",
"companies",
Companies.as_view(),
name="companies"
),
path(
"companies/page/<int:page>",
Companies.as_view(),
name="companies_pagination",
),
path(
"manufacturers/<str:category>",
Manufacturers.as_view(),
Manufacturers.as_view(template="pagination_manufacturers.html"),
name="manufacturers"
),
path(
"manufacturers/<str:category>/<int:page>",
Manufacturers.as_view(),
"manufacturers/<str:category>/page/<int:page>",
Manufacturers.as_view(template="pagination_manufacturers.html"),
name="manufacturers_pagination",
),
path("scales", Scales.as_view(), name="scales"),
path("scales/<int:page>", Types.as_view(), name="scales_pagination"),
path("types", Types.as_view(), name="types"),
path("types/<int:page>", Types.as_view(), name="types_pagination"),
path("bookshelf/books", Books.as_view(), name="books"),
path("bookshelf/books/<int:page>", Books.as_view(), name="books_pagination"),
path("bookshelf/book/<uuid:uuid>", GetBook.as_view(), name="book"),
path(
"scales",
Scales.as_view(),
name="scales"
),
path(
"scales/page/<int:page>",
Scales.as_view(),
name="scales_pagination"
),
path(
"types",
Types.as_view(),
name="rolling_stock_types"
),
path(
"types/page/<int:page>",
Types.as_view(),
name="rolling_stock_types_pagination"
),
path(
"bookshelf/books",
Books.as_view(),
name="books"
),
path(
"bookshelf/books/page/<int:page>",
Books.as_view(),
name="books_pagination"
),
path(
"bookshelf/<str:selector>/<uuid:uuid>",
GetBookCatalog.as_view(),
name="bookshelf_item"
),
path(
"bookshelf/catalogs",
Catalogs.as_view(),
name="catalogs"
),
path(
"bookshelf/catalogs/page/<int:page>",
Catalogs.as_view(),
name="catalogs_pagination"
),
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(),
"search/<str:search>/page/<int:page>",
SearchObjects.as_view(),
name="search_pagination",
),
path(
"manufacturer/<str:manufacturer>",
GetManufacturerItem.as_view(),
name="manufacturer",
),
path(
"manufacturer/<str:manufacturer>/page/<int:page>",
GetManufacturerItem.as_view(),
name="manufacturer_pagination",
),
path(
"manufacturer/<str:manufacturer>/<str:search>",
GetManufacturerItem.as_view(),
name="manufacturer",
),
path(
"manufacturer/<str:manufacturer>/<str:search>/page/<int:page>",
GetManufacturerItem.as_view(),
name="manufacturer_pagination",
),
path(
"<str:_filter>/<str:search>",
GetRosterFiltered.as_view(),
GetObjectsFiltered.as_view(),
name="filtered",
),
path(
"<str:_filter>/<str:search>/<int:page>",
GetRosterFiltered.as_view(),
"<str:_filter>/<str:search>/page/<int:page>",
GetObjectsFiltered.as_view(),
name="filtered_pagination",
),
path("<uuid:uuid>", GetRollingStock.as_view(), name="rolling_stock"),

View File

@@ -1,12 +1,14 @@
import base64
import operator
from itertools import chain
from functools import reduce
from urllib.parse import unquote
from django.views import View
from django.http import Http404, HttpResponseBadRequest
from django.db.models import Q
from django.shortcuts import render, get_object_or_404
from django.db.utils import OperationalError, ProgrammingError
from django.db.models import Q, Count
from django.shortcuts import render, get_object_or_404, get_list_or_404
from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator
@@ -14,12 +16,30 @@ from portal.utils import get_site_conf
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 bookshelf.models import Book, Catalog
from metadata.models import (
Company,
Manufacturer,
Scale,
RollingStockType,
Tag,
)
def order_by_fields():
order_by = get_site_conf().items_ordering
def get_items_per_page():
try:
items_per_page = get_site_conf().items_per_page
except (OperationalError, ProgrammingError):
items_per_page = 6
return items_per_page
def get_order_by_field():
try:
order_by = get_site_conf().items_ordering
except (OperationalError, ProgrammingError):
order_by = "type"
fields = [
"rolling_class__type",
"rolling_class__company",
@@ -35,19 +55,33 @@ def order_by_fields():
return (fields[2], fields[0], fields[1], fields[3])
class Render404(View):
def get(self, request, exception):
return render(request, "base.html", {"title": "404 page not found"})
class GetData(View):
def __init__(self):
self.title = "Home"
self.template = "home.html"
self.data = RollingStock.objects.order_by(*order_by_fields())
title = "Home"
template = "pagination.html"
item_type = "roster"
filter = Q() # empty filter by default
def get_data(self, request):
return (
RollingStock.objects.get_published(request.user)
.order_by(*get_order_by_field())
.filter(self.filter)
)
def get(self, request, page=1):
site_conf = get_site_conf()
data = []
for item in self.get_data(request):
data.append({"type": self.item_type, "item": item})
paginator = Paginator(self.data, site_conf.items_per_page)
paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page)
page_range = paginator.get_elided_page_range(
data.number, on_each_side=2, on_ends=1
data.number, on_each_side=1, on_ends=1
)
return render(
@@ -55,6 +89,7 @@ class GetData(View):
self.template,
{
"title": self.title,
"type": self.item_type,
"data": data,
"matches": paginator.count,
"page_range": page_range,
@@ -63,15 +98,17 @@ class GetData(View):
class GetRoster(GetData):
def __init__(self):
self.title = "Rolling stock"
self.template = "roster.html"
self.data = RollingStock.objects.order_by(*order_by_fields())
title = "The Roster"
item_type = "roster"
def get_data(self, request):
return RollingStock.objects.get_published(request.user).order_by(
*get_order_by_field()
)
class SearchRoster(View):
class SearchObjects(View):
def run_search(self, request, search, _filter, page=1):
site_conf = get_site_conf()
if _filter is None:
query = reduce(
operator.or_,
@@ -111,20 +148,52 @@ class SearchRoster(View):
else:
raise Http404
rolling_stock = (
RollingStock.objects.filter(query)
# FIXME duplicated code!
# FIXME see if it makes sense to filter calatogs and books by scale
# and manufacturer as well
data = []
roster = (
RollingStock.objects.get_published(request.user)
.filter(query)
.distinct()
.order_by(*order_by_fields())
.order_by(*get_order_by_field())
)
matches = rolling_stock.count()
for item in roster:
data.append({"type": "roster", "item": item})
paginator = Paginator(rolling_stock, site_conf.items_per_page)
rolling_stock = paginator.get_page(page)
if _filter is None:
consists = (
Consist.objects.get_published(request.user)
.filter(
Q(
Q(identifier__icontains=search)
| Q(company__name__icontains=search)
)
)
.distinct()
)
for item in consists:
data.append({"type": "consist", "item": item})
books = (
Book.objects.get_published(request.user)
.filter(title__icontains=search)
.distinct()
)
catalogs = (
Catalog.objects.get_published(request.user)
.filter(manufacturer__name__icontains=search)
.distinct()
)
for item in list(chain(books, catalogs)):
data.append({"type": "book", "item": item})
paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page)
page_range = paginator.get_elided_page_range(
rolling_stock.number, on_each_side=2, on_ends=1
data.number, on_each_side=1, on_ends=1
)
return rolling_stock, matches, page_range
return data, paginator.count, page_range
def split_search(self, search):
search = search.strip().split(":")
@@ -146,10 +215,9 @@ class SearchRoster(View):
encoded_search = search
search = base64.b64decode(search.encode()).decode()
except Exception:
encoded_search = base64.b64encode(
search.encode()).decode()
encoded_search = base64.b64encode(search.encode()).decode()
_filter, keyword = self.split_search(search)
rolling_stock, matches, page_range = self.run_search(
data, matches, page_range = self.run_search(
request, keyword, _filter, page
)
@@ -157,11 +225,11 @@ class SearchRoster(View):
request,
"search.html",
{
"title": "Search: \"{}\"".format(search),
"title": 'Search: "{}"'.format(search),
"search": search,
"encoded_search": encoded_search,
"data": data,
"matches": matches,
"data": rolling_stock,
"page_range": page_range,
},
)
@@ -171,44 +239,128 @@ class SearchRoster(View):
return self.get(request, search, page)
class GetRosterFiltered(View):
class GetManufacturerItem(View):
def get(self, request, manufacturer, search="all", page=1):
manufacturer = get_object_or_404(
Manufacturer, slug__iexact=manufacturer
)
if search != "all":
roster = get_list_or_404(
RollingStock.objects.get_published(request.user).order_by(
*get_order_by_field()
),
Q(
Q(manufacturer=manufacturer)
& Q(item_number_slug__exact=search)
),
)
title = "{0}: {1}".format(
manufacturer,
# all returned records must have the same `item_number``;
# just pick it up the first result, otherwise `search`
roster[0].item_number if roster else search,
)
else:
roster = (
RollingStock.objects.get_published(request.user)
.order_by(*get_order_by_field())
.filter(
Q(manufacturer=manufacturer)
| Q(rolling_class__manufacturer=manufacturer)
)
)
title = "Manufacturer: {0}".format(manufacturer)
data = []
for item in roster:
data.append({"type": "roster", "item": item})
paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page)
page_range = paginator.get_elided_page_range(
data.number, on_each_side=1, on_ends=1
)
return render(
request,
"manufacturer.html",
{
"title": title,
"manufacturer": manufacturer,
"search": search,
"data": data,
"matches": paginator.count,
"page_range": page_range,
},
)
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)
elif _filter == "manufacturer":
title = get_object_or_404(Manufacturer, slug__iexact=search)
query = Q(
Q(rolling_class__manufacturer__slug__iexact=search)
| Q(manufacturer__slug__iexact=search)
)
query_2nd = Q(company__slug__iexact=search)
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
rolling_stock = (
RollingStock.objects.filter(query)
roster = (
RollingStock.objects.get_published(request.user)
.filter(query)
.distinct()
.order_by(*order_by_fields())
.order_by(*get_order_by_field())
)
matches = rolling_stock.count()
paginator = Paginator(rolling_stock, site_conf.items_per_page)
rolling_stock = paginator.get_page(page)
data = []
for item in roster:
data.append({"type": "roster", "item": item})
try: # Execute only if query_2nd is defined
consists = (
Consist.objects.get_published(request.user)
.filter(query_2nd)
.distinct()
)
for item in consists:
data.append({"type": "consist", "item": item})
if _filter == "tag": # Books can be filtered only by tag
books = (
Book.objects.get_published(request.user)
.filter(query_2nd)
.distinct()
)
for item in books:
data.append({"type": "book", "item": item})
catalogs = (
Catalog.objects.get_published(request.user)
.filter(query_2nd)
.distinct()
)
for item in catalogs:
data.append({"type": "catalog", "item": item})
except NameError:
pass
paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page)
page_range = paginator.get_elided_page_range(
rolling_stock.number, on_each_side=2, on_ends=1
data.number, on_each_side=1, on_ends=1
)
return rolling_stock, title, matches, page_range
return data, title, paginator.count, page_range
def get(self, request, search, _filter, page=1):
data, title, matches, page_range = self.run_filter(
@@ -219,12 +371,11 @@ class GetRosterFiltered(View):
request,
"filter.html",
{
"title": "{0}: {1}".format(
_filter.capitalize(), title),
"title": "{0}: {1}".format(_filter.capitalize(), title),
"search": search,
"filter": _filter,
"matches": matches,
"data": data,
"matches": matches,
"page_range": page_range,
},
)
@@ -233,28 +384,44 @@ class GetRosterFiltered(View):
class GetRollingStock(View):
def get(self, request, uuid):
try:
rolling_stock = RollingStock.objects.get(uuid=uuid)
rolling_stock = RollingStock.objects.get_published(
request.user
).get(uuid=uuid)
except ObjectDoesNotExist:
raise Http404
class_properties = (
rolling_stock.rolling_class.property.all()
if request.user.is_authenticated
else rolling_stock.rolling_class.property.filter(
property__private=False
# FIXME there's likely a better and more efficient way of doing this
# but keeping KISS for now
decoder_documents = []
class_properties = rolling_stock.rolling_class.property.get_public(
request.user
)
properties = rolling_stock.property.get_public(request.user)
documents = rolling_stock.document.get_public(request.user)
journal = rolling_stock.journal.get_public(request.user)
if rolling_stock.decoder:
decoder_documents = rolling_stock.decoder.document.get_public(
request.user
)
)
rolling_stock_properties = (
rolling_stock.property.all()
if request.user.is_authenticated
else rolling_stock.property.filter(property__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.get_published(request.user).filter(
consist_item__rolling_stock=rolling_stock
)
] # A dict with "item" is required by the consists card
set = [
{"type": "set", "item": s}
for s in RollingStock.objects.get_published(request.user)
.filter(
Q(
Q(item_number__exact=rolling_stock.item_number)
& Q(set=True)
)
)
.order_by(*get_order_by_field())
]
return render(
request,
@@ -263,35 +430,46 @@ 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,
"set": set,
"consists": consists,
},
)
class Consists(GetData):
def __init__(self):
self.title = "Consists"
self.template = "consists.html"
self.data = Consist.objects.all()
title = "Consists"
item_type = "consist"
def get_data(self, request):
return Consist.objects.get_published(request.user).all()
class GetConsist(View):
def get(self, request, uuid, page=1):
site_conf = get_site_conf()
try:
consist = Consist.objects.get(uuid=uuid)
consist = Consist.objects.get_published(request.user).get(
uuid=uuid
)
except ObjectDoesNotExist:
raise Http404
rolling_stock = [
RollingStock.objects.get(uuid=r.rolling_stock_id) for r in
consist.consist_item.all()
data = [
{
"type": "roster",
"item": RollingStock.objects.get_published(request.user).get(
uuid=r.rolling_stock_id
),
}
for r in consist.consist_item.all()
]
paginator = Paginator(rolling_stock, site_conf.items_per_page)
rolling_stock = paginator.get_page(page)
paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page)
page_range = paginator.get_elided_page_range(
rolling_stock.number, on_each_side=2, on_ends=1
data.number, on_each_side=1, on_ends=1
)
return render(
@@ -300,73 +478,97 @@ class GetConsist(View):
{
"title": consist,
"consist": consist,
"data": rolling_stock,
"data": data,
"page_range": page_range,
},
)
class Manufacturers(GetData):
def __init__(self):
self.title = "Manufacturers"
self.template = "manufacturers.html"
self.data = None # Set via method get
title = "Manufacturers"
item_type = "manufacturer"
def get_data(self, request):
return Manufacturer.objects.filter(self.filter)
# overload get method to filter by category
def get(self, request, category, page=1):
if category not in ("real", "model"):
raise Http404
self.data = Manufacturer.objects.filter(category=category)
self.filter = Q(category=category)
return super().get(request, page)
class Companies(GetData):
def __init__(self):
self.title = "Companies"
self.template = "companies.html"
self.data = Company.objects.all()
title = "Companies"
item_type = "company"
def get_data(self, request):
return Company.objects.all()
class Scales(GetData):
def __init__(self):
self.title = "Scales"
self.template = "scales.html"
self.data = Scale.objects.all()
title = "Scales"
item_type = "scale"
queryset = Scale.objects.all()
def get_data(self, request):
return Scale.objects.annotate(
num_items=Count("rollingstock")
) # .filter(num_items__gt=0) to filter data with no items
class Types(GetData):
def __init__(self):
self.title = "Types"
self.template = "types.html"
self.data = RollingStockType.objects.all()
title = "Types"
item_type = "rolling_stock_type"
def get_data(self, request):
return RollingStockType.objects.all()
class Books(GetData):
def __init__(self):
self.title = "Books"
self.template = "bookshelf/books.html"
self.data = Book.objects.all()
title = "Books"
item_type = "book"
def get_data(self, request):
return Book.objects.get_published(request.user).all()
class GetBook(View):
def get(self, request, uuid):
class Catalogs(GetData):
title = "Catalogs"
item_type = "catalog"
def get_data(self, request):
return Catalog.objects.get_published(request.user).all()
class GetBookCatalog(View):
def get_object(self, request, uuid, selector):
if selector == "book":
return Book.objects.get_published(request.user).get(uuid=uuid)
elif selector == "catalog":
return Catalog.objects.get_published(request.user).get(uuid=uuid)
else:
raise Http404
def get(self, request, uuid, selector):
try:
book = Book.objects.get(uuid=uuid)
book = self.get_object(request, uuid, selector)
except ObjectDoesNotExist:
raise Http404
book_properties = (
book.property.all()
if request.user.is_authenticated
else book.property.filter(property__private=False)
)
properties = book.property.get_public(request.user)
documents = book.document.get_public(request.user)
return render(
request,
"bookshelf/book.html",
{
"title": book,
"book_properties": book_properties,
"book": book,
"documents": documents,
"properties": properties,
"type": selector
},
)
@@ -374,8 +576,8 @@ class GetBook(View):
class GetFlatpage(View):
def get(self, request, flatpage):
try:
flatpage = Flatpage.objects.get(
Q(Q(path=flatpage) & Q(published=True))
flatpage = Flatpage.objects.get_published(request.user).get(
path=flatpage
)
except ObjectDoesNotExist:
raise Http404

View File

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

View File

@@ -1,10 +1,4 @@
from django.contrib import admin
from django.db.utils import OperationalError
from portal.utils import get_site_conf
from django.conf import settings
try:
site_name = get_site_conf().site_name
except OperationalError:
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

@@ -0,0 +1,13 @@
from django.core.cache import cache
from django.core.management.base import BaseCommand, CommandError
class Command(BaseCommand):
help = "Clear the application cache"
def handle(self, *args, **options):
try:
cache.clear()
self.stdout.write(self.style.SUCCESS("Cache cleared"))
except Exception:
raise CommandError("Cache is not active")

19
ram/ram/managers.py Normal file
View File

@@ -0,0 +1,19 @@
from django.db import models
from django.core.exceptions import FieldError
class PublicManager(models.Manager):
def get_published(self, user):
if user.is_authenticated:
return self.get_queryset()
else:
return self.get_queryset().filter(published=True)
def get_public(self, user):
if user.is_authenticated:
return self.get_queryset()
else:
try:
return self.get_queryset().filter(private=False)
except FieldError:
return self.get_queryset().filter(property__private=False)

View File

@@ -1,9 +1,25 @@
import os
from uuid import uuid4
from django.db import models
from django.utils.safestring import mark_safe
from tinymce import models as tinymce
from ram.utils import DeduplicatedStorage, get_image_preview
from ram.managers import PublicManager
class BaseModel(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid4, editable=False)
notes = tinymce.HTMLField(blank=True)
creation_time = models.DateTimeField(auto_now_add=True)
updated_time = models.DateTimeField(auto_now=True)
published = models.BooleanField(default=True)
class Meta:
abstract = True
objects = PublicManager()
class Document(models.Model):
@@ -11,12 +27,12 @@ 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
verbose_name_plural = "Documents"
def __str__(self):
return "{0}".format(os.path.basename(self.file.name))
@@ -29,15 +45,18 @@ class Document(models.Model):
'<a href="{0}" target="_blank">Link</a>'.format(self.file.url)
)
objects = PublicManager()
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):
return get_image_preview(self.image.url)
def image_thumbnail(self, max_size=150):
return get_image_preview(self.image.url, max_size)
image_thumbnail.short_description = "Preview"
@@ -47,6 +66,9 @@ class Image(models.Model):
class Meta:
abstract = True
ordering = ["order"]
verbose_name_plural = "Images"
objects = PublicManager()
class PropertyInstance(models.Model):
@@ -62,3 +84,5 @@ class PropertyInstance(models.Model):
class Meta:
abstract = True
verbose_name_plural = "Properties"
objects = PublicManager()

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,17 +141,37 @@ 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"
DECODER_INTERFACES = [
(0, "Built-in"),
(1, "NEM651"),
(2, "NEM652"),
(3, "PluX"),

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
from portal.views import Render404
handler404 = Render404.as_view()
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")),
]

Some files were not shown because too many files have changed in this diff Show More