72 Commits

Author SHA1 Message Date
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
bf8c2331c0 Add link to bookshelf in admin menu 2023-10-03 22:23:22 +02:00
1de4938ae7 Merge pull request #24 from daniviga/bookshelf
Implement a bookshelf
2023-10-03 22:18:11 +02:00
817d53d39a Anchor renaming 2023-10-03 22:13:39 +02:00
12ac33f4a2 Fix books default ordering 2023-10-03 21:58:45 +02:00
cbd76e4f66 Add a book details page in bookshelf 2023-10-03 21:54:47 +02:00
bcfed3534c Minor improvements 2023-10-02 23:27:57 +02:00
22bee7d95d Show booshelf menu 2023-10-02 23:16:54 +02:00
98c696b2d9 Add Books in the main menu 2023-10-02 23:01:43 +02:00
996ddd67ea Web bookshelf first draft 2023-10-02 22:58:15 +02:00
3f905877e7 Extend the bookshelf implementation 2023-10-02 22:19:04 +02:00
968ebeb0b6 First bookshelf implementation 2023-10-02 00:02:24 +02:00
b8572c1701 Rename SKU to 'Item number' 2023-10-01 21:35:14 +02:00
124f3c2a8b Do not show the coming soon image on mobile
To save some extra scrolling...
2023-10-01 19:26:04 +02:00
f4023f105f Add a default card image when no custom one exists (#23)
* Add a default card image when no custom one exists
* Add coming_soon.png source
* Use directly the svg source instead of the png raster
2023-10-01 16:36:30 +02:00
a189646aa5 More minor UX fixes 2023-10-01 11:14:17 +02:00
7a103cca56 Minor fix 2023-10-01 11:10:21 +02:00
2fe221d0f4 Add .table-group-divider to tbody with a custom color 2023-10-01 10:56:09 +02:00
6355460e01 Fix decoder document visualization 2023-10-01 10:39:09 +02:00
75074d5e90 Fix columns size 2023-10-01 10:22:03 +02:00
5d536ce568 Add documents to decoders (#22)
* Add decoder documents support
* Use abstract model for Documents
* Increase version
* Code cleanup
2023-10-01 00:03:41 +02:00
9483648a1f Update arduino/esp32 dependencies 2023-09-26 17:30:13 +02:00
8c15441fe5 More html cleanup to match W3c 2023-09-22 14:30:45 +02:00
5ebce9480e Cleanup html tags 2023-09-21 22:22:06 +02:00
64eefe43aa Update README.md 2023-09-18 22:02:13 +02:00
8e0a18d707 Update theme icon based on the selected one 2023-09-18 21:39:15 +02:00
46a2aa7011 Bump version 2023-09-18 20:23:03 +02:00
a176682615 Improve header html code 2023-09-18 20:21:41 +02:00
ad4591da04 Bootstrap 5.3 (#21)
* Migarte to Bootstrap 5.3
* Cleanup and version bump
2023-09-18 14:24:27 +02:00
2f2b96b2bb Add requirements-prod.txt 2023-05-07 14:52:49 +02:00
6cf3ad03cc Net-to-serial broadcast messages to all clients and other cleanups (#20)
* Cleanup and maintenance

* Net-to-serial broadcast messages to all clients

This will make all clients to stay in sync with any operation
occurring, like when having multiple JMRI instances

* Update README and python version in containers
2023-03-06 18:25:34 +01:00
2c5f0dcd6f Use slugs to filter 2023-01-09 22:54:15 +01:00
3545824016 Merge pull request #19 from daniviga/more-filters
Add more filters
2023-01-09 00:12:55 +01:00
78f9faee5e Switch back from pk filtering to safe name 2023-01-09 00:10:57 +01:00
6fbea294da Add more filters and search refactoring 2023-01-08 19:06:38 +01:00
35bdffdb3f Hotfix for 0.1.0 2023-01-08 01:26:14 +01:00
dccf467d38 Merge pull request #18 from daniviga/manufacturers
Add support for manufacturer filters
2023-01-08 00:44:01 +01:00
9dfa9172f4 Major templates and views refactoring 2023-01-08 00:40:13 +01:00
9279142a41 Add more manufacturers categories 2023-01-06 01:54:14 +01:00
aff1d20260 Add support for manufacturer filters 2023-01-06 01:47:07 +01:00
9b8ec6ba6b Add a favicon 2023-01-05 11:44:02 +01:00
c0b1b0b37b Hotfix some templates 2023-01-05 02:24:00 +01:00
169763e237 Merge pull request #17 from daniviga/ext-link
Support external links and replace font-awesome with bootstrap icons
2023-01-04 18:20:12 +01:00
bbe0758c6b Fix local copy of bootstrap icons 2023-01-04 18:18:47 +01:00
c73305fd85 Add support for external links 2023-01-04 18:17:20 +01:00
4a3fbda3dc Replace font-awesome with bootstrap icons 2023-01-04 18:15:04 +01:00
295965710f Merge pull request #16 from daniviga/cdn
Add cover to consist page and cdn option
2023-01-04 15:21:20 +01:00
c152f43aa6 Fix template indentation 2023-01-04 15:19:43 +01:00
8ed92dc5f0 Bump version 2023-01-04 15:16:02 +01:00
b70aa27a13 Add cover to consist page 2023-01-04 15:14:30 +01:00
3860ed70fd Allow the use of local copies of cdn files 2023-01-04 14:49:00 +01:00
68a18fcf58 Replace thumbnails with carousels in rolling stock pages (#15)
* Replace thumbnails with carousels in rolling stock pages

* Add consist data and notes in page
2023-01-03 01:32:16 +01:00
e45d11d4b1 Raise minimum python version to 3.9 2023-01-02 16:10:15 +01:00
32b5522a1e Change how images and consists are sorted (#14) 2023-01-02 16:08:25 +01:00
89b666dab2 Update README.md 2022-12-30 09:28:08 +01:00
ffad964373 Add possibility to inject js in head (analytics) 2022-12-28 23:54:49 +01:00
538dc0bd80 Add page title in html 2022-12-28 23:36:46 +01:00
8bd2635c28 Change image sort, thumbnails first 2022-12-28 22:14:10 +01:00
feda1f6cb4 [auto update] sync submodules 2022-11-28 18:22:05 +01:00
2c851b2822 Add migrations 2022-11-27 01:11:43 +01:00
e5ba2cfaec Merge pull request #13 from daniviga/dedup
Reuse existing file if content is the same
2022-11-27 01:09:50 +01:00
091f426242 Bump version 2022-11-27 01:09:34 +01:00
f603fd3e2d Reuse existing file if content is the same 2022-11-27 01:07:38 +01:00
a3b2112e03 Fix search query 2022-11-24 16:38:07 +01:00
055b0bab59 Enable "Save as" in roster and consist 2022-11-01 12:30:38 +01:00
3aea2ae340 Merge pull request #12 from daniviga/move-dcc-interface
Move decoder interface def into rolling stock
2022-11-01 00:07:18 +01:00
242fe6814d Move decoder interface def into rolling stock 2022-11-01 00:06:30 +01:00
97 changed files with 5036 additions and 836 deletions

View File

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

View File

@@ -2,7 +2,7 @@
[![Django CI](https://github.com/daniviga/django-rma/actions/workflows/django.yml/badge.svg)](https://github.com/daniviga/django-rma/actions/workflows/django.yml)
![Screenshot 2022-07-23 at 22-40-17 Railroad Assets Manager](https://user-images.githubusercontent.com/1818657/180622177-a4bba00e-47da-42b3-a7f6-b24773e69936.png)
![Screenshot 2023-09-18 at 21-57-33 Company RGS - Railroad Assets Manager](https://github.com/daniviga/django-ram/assets/1818657/d20fbe27-1192-4ab1-a19f-8d2ae50cf781)
A `jff` (just for fun) project that aims to create a
model railroad assets manager that allows to:
@@ -21,7 +21,7 @@ it has been developed with a commitment of few minutes a day;
it lacks any kind of documentation, code review, architectural review,
security assesment, pentest, ISO certification, etc.
This project probably doesn't match you needs nor expectations. Be aware.
This project probably doesn't match your needs nor expectations. Be aware.
Your model train may also catch fire while using this software.
@@ -49,7 +49,7 @@ It has been developed with:
## Requirements
- Python 3.8+
- Python 3.9+
- A USB port when running Arduino hardware (and adaptors if you have a Mac)
## Web portal installation
@@ -96,7 +96,8 @@ Browse to `http://localhost:8000`
The DCC++ EX connector exposes an Arduino board running DCC++ EX Command Station,
connected via serial port, to the network, allowing commands to be sent via a
TCP socket.
TCP socket. A response generated by the DCC++ EX board is sent to all connected clients,
providing synchronization between multiple clients (eg. multiple JMRI instances).
Its use is not needed when running DCC++ EX from a [WiFi](https://dcc-ex.com/get-started/wifi-setup.html) capable board (like when
using an ESP8266 module or a [Mega+WiFi board](https://dcc-ex.com/advanced-setup/supported-microcontrollers/wifi-mega.html)).
@@ -112,7 +113,7 @@ Settings may need to be customized based on your setup.
```bash
$ cd daemons
$ podman build -t dcc/net-to-serial .
$ podman run -d -p 2560:2560 dcc/net-to-serial
$ podman run --group-add keep-groups --device /dev/ttyACM0 -p 2560:2560 dcc/net-to-serial
```
### Manual setup
@@ -139,15 +140,16 @@ To be continued ...
## Screenshots
### Frontend
![Screenshot 2022-07-23 at 22-41-44 Railroad Assets Manager](https://user-images.githubusercontent.com/1818657/180622406-760774a9-f028-44fc-b332-fa74e43307df.png)
![Screenshot 2023-09-18 at 22-00-39 RGS C-19 #40 - Railroad Assets Manager](https://github.com/daniviga/django-ram/assets/1818657/94834b89-5b17-46e7-9494-a1651d72c072)
---
![Screenshot 2022-07-23 at 22-44-35 Railroad Assets Manager](https://user-images.githubusercontent.com/1818657/180622342-40586d75-239a-400c-93a1-1cb9583a7d17.png)
---
![Screenshot 2022-07-23 at 22-44-46 Railroad Assets Manager](https://user-images.githubusercontent.com/1818657/180622321-1ab76440-9c6e-4667-9247-dbbcf6c6055c.png)
![Screenshot 2023-09-18 at 21-59-30 RGS 1930s short train - Railroad Assets Manager](https://github.com/daniviga/django-ram/assets/1818657/77f9b7c9-27b3-4a65-bad0-26e9cf77e623)
#### Dark mode
![Screenshot 2022-07-23 at 22-53-43 Railroad Assets Manager](https://user-images.githubusercontent.com/1818657/180622629-65d81eaf-cca4-4f44-b39b-3b0077b43a34.png)
![Screenshot 2023-09-18 at 21-58-22 Company RGS - Railroad Assets Manager](https://github.com/daniviga/django-ram/assets/1818657/c95697c9-0897-46f4-941c-6092271e4743)
---

View File

@@ -1,4 +1,4 @@
FROM python:3.10-alpine
FROM python:3.11-alpine
RUN mkdir /opt/dcc && pip -q install pyserial
ADD net-to-serial.py config.ini /opt/dcc

3
daemons/README.md Normal file
View File

@@ -0,0 +1,3 @@
## DCC++ EX connector
See [README.md](../README.md)

View File

@@ -2,6 +2,7 @@
LogLevel = debug
ListeningIP = 0.0.0.0
ListeningPort = 2560
MaxClients = 10
[Serial]
# UNO

View File

@@ -1,6 +1,5 @@
#!/usr/bin/env python3
import re
import time
import logging
import serial
import asyncio
@@ -10,11 +9,15 @@ from pathlib import Path
class SerialDaemon:
connected_clients = set()
def __init__(self, config):
self.ser = serial.Serial(
config["Serial"]["Port"],
timeout=int(config["Serial"]["Timeout"])/1000)
timeout=int(config["Serial"]["Timeout"]) / 1000,
)
self.ser.baudrate = config["Serial"]["Baudrate"]
self.max_clients = int(config["Daemon"]["MaxClients"])
def __del__(self):
try:
@@ -43,19 +46,32 @@ class SerialDaemon:
async def handle_echo(self, reader, writer):
"""Process a request from socket and return the response"""
while 1: # keep connection to client open
data = await reader.read(100)
if not data: # client has disconnected
break
logging.info(
"Clients already connected: {} (max: {})".format(
len(self.connected_clients),
self.max_clients,
)
)
addr = writer.get_extra_info('peername')
logging.info("Received {} from {}".format(data, addr[0]))
self.__write_serial(data)
response = self.__read_serial()
writer.write(response)
await writer.drain()
logging.info("Sent: {}".format(response))
addr = writer.get_extra_info("peername")[0]
if len(self.connected_clients) < self.max_clients:
self.connected_clients.add(writer)
while True: # keep connection to client open
data = await reader.read(100)
if not data: # client has disconnected
break
logging.info("Received {} from {}".format(data, addr))
self.__write_serial(data)
response = self.__read_serial()
for client in self.connected_clients:
client.write(response)
await client.drain()
logging.info("Sent: {}".format(response))
self.connected_clients.remove(writer)
else:
logging.warning(
"TooManyClients: client {} disconnected".format(addr)
)
writer.close()
await writer.wait_closed()
@@ -68,33 +84,37 @@ class SerialDaemon:
while "DCC-EX" not in line:
line = self.__read_serial().decode()
board = re.findall(r"<iDCC-EX.*>", line)[0]
return(board)
return board
async def main():
config = configparser.ConfigParser()
config.read(
Path(__file__).resolve().parent / "config.ini") # mimick os.path.join
Path(__file__).resolve().parent / "config.ini"
) # mimick os.path.join
logging.basicConfig(level=config["Daemon"]["LogLevel"].upper())
sd = SerialDaemon(config)
server = await asyncio.start_server(
sd.handle_echo,
config["Daemon"]["ListeningIP"],
config["Daemon"]["ListeningPort"])
config["Daemon"]["ListeningPort"],
)
addr = server.sockets[0].getsockname()
logging.warning("Serving on {} port {}".format(addr[0], addr[1]))
logging.warning(
logging.info("Serving on {} port {}".format(addr[0], addr[1]))
logging.info(
"Proxying to {} (Baudrate: {}, Timeout: {})".format(
config["Serial"]["Port"],
config["Serial"]["Baudrate"],
config["Serial"]["Timeout"]))
logging.warning("Initializing board")
logging.warning("Board {} ready".format(
await sd.return_board()))
config["Serial"]["Timeout"],
)
)
logging.info("Initializing board")
logging.info("Board {} ready".format(await sd.return_board()))
async with server:
await server.serve_forever()
if __name__ == "__main__":
asyncio.run(main())

Binary file not shown.

View File

52
ram/bookshelf/admin.py Normal file
View File

@@ -0,0 +1,52 @@
from django.contrib import admin
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
from bookshelf.models import BookProperty, BookImage, Book, Author, Publisher
class BookImageInline(SortableInlineAdminMixin, admin.TabularInline):
model = BookImage
min_num = 0
extra = 0
readonly_fields = ("image_thumbnail",)
classes = ["collapse"]
class BookPropertyInline(admin.TabularInline):
model = BookProperty
min_num = 0
extra = 0
@admin.register(Book)
class BookAdmin(SortableAdminBase, admin.ModelAdmin):
inlines = (BookImageInline, BookPropertyInline,)
list_display = (
"title",
"get_authors",
"get_publisher",
"publication_year",
"number_of_pages"
)
search_fields = ("title", "publisher__name", "authors__last_name")
list_filter = ("publisher__name", "authors")
@admin.display(description="Publisher")
def get_publisher(self, obj):
return obj.publisher.name
@admin.display(description="Authors")
def get_authors(self, obj):
return ", ".join(a.short_name() for a in obj.authors.all())
@admin.register(Author)
class AuthorAdmin(admin.ModelAdmin):
search_fields = ("first_name", "last_name",)
list_filter = ("last_name",)
@admin.register(Publisher)
class PublisherAdmin(admin.ModelAdmin):
list_display = ("name", "country")
search_fields = ("name",)

6
ram/bookshelf/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class BookshelfConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "bookshelf"

View File

@@ -0,0 +1,119 @@
# Generated by Django 4.2.5 on 2023-10-01 20:16
import ckeditor_uploader.fields
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
("metadata", "0012_alter_decoder_manufacturer_decoderdocument"),
]
operations = [
migrations.CreateModel(
name="Author",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("first_name", models.CharField(max_length=100)),
("last_name", models.CharField(max_length=100)),
],
),
migrations.CreateModel(
name="Book",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("title", models.CharField(max_length=200)),
("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)),
("creation_time", models.DateTimeField(auto_now_add=True)),
("updated_time", models.DateTimeField(auto_now=True)),
("authors", models.ManyToManyField(to="bookshelf.author")),
],
),
migrations.CreateModel(
name="Publisher",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=200)),
("website", models.URLField()),
],
),
migrations.CreateModel(
name="BookProperty",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("value", models.CharField(max_length=256)),
(
"book",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="property",
to="bookshelf.book",
),
),
(
"property",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="metadata.property",
),
),
],
options={
"verbose_name_plural": "Properties",
},
),
migrations.AddField(
model_name="book",
name="publisher",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="bookshelf.publisher"
),
),
migrations.AddField(
model_name="book",
name="tags",
field=models.ManyToManyField(
blank=True, related_name="bookshelf", to="metadata.tag"
),
),
]

View File

@@ -0,0 +1,142 @@
# Generated by Django 4.2.5 on 2023-10-01 21:33
from django.db import migrations, models
import django_countries.fields
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0001_initial"),
]
operations = [
migrations.AddField(
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"),
("uk", "Ukrainian"),
("ur", "Urdu"),
("uz", "Uzbek"),
("vi", "Vietnamese"),
("zh-hans", "Simplified Chinese"),
("zh-hant", "Traditional Chinese"),
],
default="en",
max_length=7,
),
),
migrations.AddField(
model_name="book",
name="numbers_of_pages",
field=models.SmallIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="publisher",
name="country",
field=django_countries.fields.CountryField(blank=True, max_length=2),
),
migrations.AlterField(
model_name="book",
name="ISBN",
field=models.CharField(blank=True, max_length=13),
),
migrations.AlterField(
model_name="publisher",
name="website",
field=models.URLField(blank=True),
),
]

View File

@@ -0,0 +1,51 @@
# Generated by Django 4.2.5 on 2023-10-02 10:36
from django.db import migrations, models
import django.db.models.deletion
import ram.utils
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0002_book_language_book_numbers_of_pages_and_more"),
]
operations = [
migrations.CreateModel(
name="BookImage",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("order", models.PositiveIntegerField(default=0)),
(
"image",
models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to="images/books/",
),
),
(
"book",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="image",
to="bookshelf.book",
),
),
],
options={
"ordering": ["order"],
"abstract": False,
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.5 on 2023-10-02 20:38
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0003_bookimage"),
]
operations = [
migrations.RenameField(
model_name="book",
old_name="numbers_of_pages",
new_name="number_of_pages",
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 4.2.5 on 2023-10-03 19:57
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0004_rename_numbers_of_pages_book_number_of_pages"),
]
operations = [
migrations.AlterModelOptions(
name="book",
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

87
ram/bookshelf/models.py Normal file
View File

@@ -0,0 +1,87 @@
from uuid import uuid4
from django.db import models
from django.conf import settings
from django.urls import reverse
from django_countries.fields import CountryField
from ckeditor_uploader.fields import RichTextUploadingField
from metadata.models import Tag
from ram.utils import DeduplicatedStorage
from ram.models import Image, PropertyInstance
class Publisher(models.Model):
name = models.CharField(max_length=200)
country = CountryField(blank=True)
website = models.URLField(blank=True)
def __str__(self):
return self.name
class Author(models.Model):
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
def __str__(self):
return f"{self.last_name}, {self.first_name}"
def short_name(self):
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=17, blank=True) # 13 + dashes
language = models.CharField(
max_length=7,
choices=settings.LANGUAGES,
default='en'
)
number_of_pages = models.SmallIntegerField(null=True, blank=True)
publication_year = models.SmallIntegerField(null=True, 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)
class Meta:
ordering = ["title"]
def __str__(self):
return self.title
def publisher_name(self):
return self.publisher.name
def get_absolute_url(self):
return reverse("book", kwargs={"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,
on_delete=models.CASCADE,
null=False,
blank=False,
related_name="property",
)

View File

@@ -0,0 +1,26 @@
from rest_framework import serializers
from bookshelf.models import Book, Author, Publisher
from metadata.serializers import TagSerializer
class AuthorSerializer(serializers.ModelSerializer):
class Meta:
model = Author
fields = "__all__"
class PublisherSerializer(serializers.ModelSerializer):
class Meta:
model = Publisher
fields = "__all__"
class BookSerializer(serializers.ModelSerializer):
authors = AuthorSerializer(many=True)
publisher = PublisherSerializer()
tags = TagSerializer(many=True)
class Meta:
model = Book
fields = "__all__"
read_only_fields = ("creation_time", "updated_time")

3
ram/bookshelf/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

7
ram/bookshelf/urls.py Normal file
View File

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

18
ram/bookshelf/views.py Normal file
View File

@@ -0,0 +1,18 @@
from rest_framework.generics import ListAPIView, RetrieveAPIView
from rest_framework.schemas.openapi import AutoSchema
from bookshelf.models import Book
from bookshelf.serializers import BookSerializer
class BookList(ListAPIView):
queryset = Book.objects.all()
serializer_class = BookSerializer
class BookGet(RetrieveAPIView):
queryset = Book.objects.all()
serializer_class = BookSerializer
lookup_field = "uuid"
schema = AutoSchema(operation_id_base="retrieveBookByUUID")

View File

@@ -21,6 +21,7 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
list_display = ("identifier", "company", "era")
list_filter = list_display
search_fields = list_display
save_as = True
fieldsets = (
(

View File

@@ -0,0 +1,24 @@
# Generated by Django 4.1.2 on 2022-11-27 00:10
from django.db import migrations, models
import ram.utils
class Migration(migrations.Migration):
dependencies = [
("consist", "0006_md_to_html"),
]
operations = [
migrations.AlterField(
model_name="consist",
name="image",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to="images/",
),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 4.1.3 on 2023-01-02 15:03
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("consist", "0007_alter_consist_image"),
]
operations = [
migrations.AlterModelOptions(
name="consist",
options={"ordering": ["company", "-creation_time"]},
),
]

View File

@@ -4,6 +4,7 @@ from django.urls import reverse
from ckeditor_uploader.fields import RichTextUploadingField
from ram.utils import DeduplicatedStorage
from metadata.models import Company, Tag
from roster.models import RollingStock
@@ -17,7 +18,9 @@ class Consist(models.Model):
)
company = models.ForeignKey(Company, on_delete=models.CASCADE)
era = models.CharField(max_length=32, blank=True)
image = models.ImageField(upload_to="images/", null=True, blank=True)
image = models.ImageField(
upload_to="images/", 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)
@@ -29,7 +32,7 @@ class Consist(models.Model):
return reverse("consist", kwargs={"uuid": self.uuid})
class Meta:
ordering = ["creation_time"]
ordering = ["company", "-creation_time"]
class ConsistItem(models.Model):

View File

@@ -4,6 +4,7 @@ from adminsortable2.admin import SortableAdminMixin
from metadata.models import (
Property,
Decoder,
DecoderDocument,
Scale,
Manufacturer,
Company,
@@ -17,11 +18,19 @@ class PropertyAdmin(admin.ModelAdmin):
search_fields = ("name",)
class DecoderDocInline(admin.TabularInline):
model = DecoderDocument
min_num = 0
extra = 0
classes = ["collapse"]
@admin.register(Decoder)
class DecoderAdmin(admin.ModelAdmin):
inlines = (DecoderDocInline,)
readonly_fields = ("image_thumbnail",)
list_display = ("__str__", "interface")
list_filter = ("manufacturer", "interface")
list_display = ("__str__", "sound")
list_filter = ("manufacturer", "sound")
search_fields = ("name", "manufacturer__name")

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1.2 on 2022-10-31 23:01
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("metadata", "0007_rename_track_scale_tracks"),
("roster", "0013_rollingstock_decoder_interface"),
]
operations = [
migrations.RemoveField(
model_name="decoder",
name="interface",
),
]

View File

@@ -0,0 +1,44 @@
# Generated by Django 4.1.2 on 2022-11-27 00:10
from django.db import migrations, models
import ram.utils
class Migration(migrations.Migration):
dependencies = [
("metadata", "0008_remove_decoder_interface"),
]
operations = [
migrations.AlterField(
model_name="company",
name="logo",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to="images/",
),
),
migrations.AlterField(
model_name="decoder",
name="image",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to="images/",
),
),
migrations.AlterField(
model_name="manufacturer",
name="logo",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to="images/",
),
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 4.1.5 on 2023-01-06 00:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("metadata", "0009_alter_company_logo_alter_decoder_image_and_more"),
]
operations = [
migrations.AlterField(
model_name="manufacturer",
name="category",
field=models.CharField(
choices=[
("model", "Model"),
("real", "Real"),
("accessory", "Accessory"),
("other", "Other"),
],
max_length=64,
),
),
]

View File

@@ -0,0 +1,93 @@
# Generated by Django 4.1.5 on 2023-01-09 11:22
from django.db import migrations, models
from ram.utils import slugify
def create_slug(apps, schema_editor):
fields = ["Company", "Manufacturer", "RollingStockType", "Scale"]
for m in fields:
model = apps.get_model("metadata", m)
for row in model.objects.all():
if hasattr(row, "type"):
row.slug = slugify("{} {}".format(row.type, row.category))
elif hasattr(row, "scale"):
row.slug = slugify(row.scale)
else:
row.slug = slugify(row.name)
row.save(update_fields=["slug"])
class Migration(migrations.Migration):
dependencies = [
("metadata", "0010_alter_manufacturer_category"),
]
operations = [
migrations.AddField(
model_name="company",
name="slug",
field=models.CharField(
editable=False, max_length=64, blank=True
),
),
migrations.AddField(
model_name="manufacturer",
name="slug",
field=models.CharField(
editable=False, max_length=128, blank=True
),
),
migrations.AddField(
model_name="rollingstocktype",
name="slug",
field=models.CharField(
editable=False, max_length=128, blank=True
),
),
migrations.AddField(
model_name="scale",
name="slug",
field=models.CharField(
editable=False, max_length=32, blank=True
),
),
migrations.RunPython(
create_slug,
reverse_code=migrations.RunPython.noop
),
migrations.AlterField(
model_name="company",
name="slug",
field=models.CharField(
editable=False, max_length=64, unique=True
),
),
migrations.AlterField(
model_name="manufacturer",
name="slug",
field=models.CharField(
editable=False, max_length=128, unique=True
),
),
migrations.AlterField(
model_name="rollingstocktype",
name="slug",
field=models.CharField(
editable=False, max_length=128, unique=True
),
),
migrations.AlterField(
model_name="scale",
name="slug",
field=models.CharField(
editable=False, max_length=32, unique=True
),
),
]

View File

@@ -0,0 +1,59 @@
# Generated by Django 4.2 on 2023-09-30 21:54
from django.db import migrations, models
import django.db.models.deletion
import ram.utils
class Migration(migrations.Migration):
dependencies = [
("metadata", "0011_company_slug_and_more"),
]
operations = [
migrations.AlterField(
model_name="decoder",
name="manufacturer",
field=models.ForeignKey(
limit_choices_to={"category": "accessory"},
on_delete=django.db.models.deletion.CASCADE,
to="metadata.manufacturer",
),
),
migrations.CreateModel(
name="DecoderDocument",
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(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage(),
upload_to="files/",
),
),
(
"decoder",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="document",
to="metadata.decoder",
),
),
],
options={
"unique_together": {("decoder", "file")},
},
),
]

View File

@@ -1,9 +1,11 @@
from django.db import models
from django.urls import reverse
from django.conf import settings
from django.dispatch.dispatcher import receiver
from django_countries.fields import CountryField
from ram.utils import get_image_preview, slugify
from ram.models import Document
from ram.utils import DeduplicatedStorage, get_image_preview, slugify
class Property(models.Model):
@@ -20,11 +22,14 @@ class Property(models.Model):
class Manufacturer(models.Model):
name = models.CharField(max_length=128, unique=True)
slug = models.CharField(max_length=128, unique=True, editable=False)
category = models.CharField(
max_length=64, choices=settings.MANUFACTURER_TYPES
)
website = models.URLField(blank=True)
logo = models.ImageField(upload_to="images/", null=True, blank=True)
logo = models.ImageField(
upload_to="images/", storage=DeduplicatedStorage, null=True, blank=True
)
class Meta:
ordering = ["category", "name"]
@@ -32,6 +37,14 @@ class Manufacturer(models.Model):
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse(
"filtered", kwargs={
"_filter": "manufacturer",
"search": self.slug,
}
)
def logo_thumbnail(self):
return get_image_preview(self.logo.url)
@@ -40,10 +53,13 @@ class Manufacturer(models.Model):
class Company(models.Model):
name = models.CharField(max_length=64, unique=True)
slug = models.CharField(max_length=64, unique=True, editable=False)
extended_name = models.CharField(max_length=128, blank=True)
country = CountryField()
freelance = models.BooleanField(default=False)
logo = models.ImageField(upload_to="images/", null=True, blank=True)
logo = models.ImageField(
upload_to="images/", storage=DeduplicatedStorage, null=True, blank=True
)
class Meta:
verbose_name_plural = "Companies"
@@ -52,6 +68,14 @@ class Company(models.Model):
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse(
"filtered", kwargs={
"_filter": "company",
"search": self.slug,
}
)
def logo_thumbnail(self):
return get_image_preview(self.logo.url)
@@ -63,14 +87,13 @@ class Decoder(models.Model):
manufacturer = models.ForeignKey(
Manufacturer,
on_delete=models.CASCADE,
limit_choices_to={"category": "model"},
limit_choices_to={"category": "accessory"},
)
version = models.CharField(max_length=64, blank=True)
interface = models.PositiveSmallIntegerField(
choices=settings.DECODER_INTERFACES, null=True, blank=True
)
sound = models.BooleanField(default=False)
image = models.ImageField(upload_to="images/", null=True, blank=True)
image = models.ImageField(
upload_to="images/", storage=DeduplicatedStorage, null=True, blank=True
)
def __str__(self):
return "{0} - {1}".format(self.manufacturer, self.name)
@@ -81,8 +104,18 @@ class Decoder(models.Model):
image_thumbnail.short_description = "Preview"
class DecoderDocument(Document):
decoder = models.ForeignKey(
Decoder, on_delete=models.CASCADE, related_name="document"
)
class Meta:
unique_together = ("decoder", "file")
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)
@@ -90,10 +123,42 @@ class Scale(models.Model):
class Meta:
ordering = ["scale"]
def get_absolute_url(self):
return reverse(
"filtered", kwargs={
"_filter": "scale",
"search": self.slug,
}
)
def __str__(self):
return str(self.scale)
class RollingStockType(models.Model):
type = models.CharField(max_length=64)
order = models.PositiveSmallIntegerField()
category = models.CharField(
max_length=64, choices=settings.ROLLING_STOCK_TYPES
)
slug = models.CharField(max_length=128, unique=True, editable=False)
class Meta(object):
unique_together = ("category", "type")
ordering = ["order"]
def get_absolute_url(self):
return reverse(
"filtered", kwargs={
"_filter": "type",
"search": self.slug,
}
)
def __str__(self):
return "{0} {1}".format(self.type, self.category)
class Tag(models.Model):
name = models.CharField(max_length=128, unique=True)
slug = models.CharField(max_length=128, unique=True)
@@ -101,22 +166,19 @@ class Tag(models.Model):
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse(
"filtered", kwargs={
"_filter": "tag",
"search": self.slug,
}
)
@receiver(models.signals.pre_save, sender=Manufacturer)
@receiver(models.signals.pre_save, sender=Company)
@receiver(models.signals.pre_save, sender=Scale)
@receiver(models.signals.pre_save, sender=RollingStockType)
@receiver(models.signals.pre_save, sender=Tag)
def tag_pre_save(sender, instance, **kwargs):
instance.slug = slugify(instance.name)
class RollingStockType(models.Model):
type = models.CharField(max_length=64)
order = models.PositiveSmallIntegerField()
category = models.CharField(
max_length=64, choices=settings.ROLLING_STOCK_TYPES
)
class Meta(object):
unique_together = ("category", "type")
ordering = ["order"]
def __str__(self):
return "{0} {1}".format(self.type, self.category)
def slug_pre_save(sender, instance, **kwargs):
instance.slug = slugify(instance.__str__())

View File

@@ -3,7 +3,36 @@ from solo.admin import SingletonModelAdmin
from portal.models import SiteConfiguration, Flatpage
admin.site.register(SiteConfiguration, SingletonModelAdmin)
@admin.register(SiteConfiguration)
class SiteConfigurationAdmin(SingletonModelAdmin):
fieldsets = (
(
None,
{
"fields": (
"site_name",
"site_author",
"about",
"items_per_page",
"items_ordering",
"footer",
"footer_extended",
)
},
),
(
"Advanced",
{
"classes": ("collapse",),
"fields": (
"show_version",
"use_cdn",
"extra_head",
),
},
),
)
@admin.register(Flatpage)

View File

@@ -0,0 +1,5 @@
from django.conf import settings
def default_card_image(request):
return {"DEFAULT_CARD_IMAGE": settings.DEFAULT_CARD_IMAGE}

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1.3 on 2022-12-28 22:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("portal", "0013_remove_flatpage_draft_flatpage_published"),
]
operations = [
migrations.AddField(
model_name="siteconfiguration",
name="extra_head",
field=models.TextField(blank=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-01-03 15:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("portal", "0014_siteconfiguration_extra_head"),
]
operations = [
migrations.AddField(
model_name="siteconfiguration",
name="use_cdn",
field=models.BooleanField(default=True),
),
]

View File

@@ -35,6 +35,8 @@ class SiteConfiguration(SingletonModel):
footer = RichTextField(blank=True)
footer_extended = RichTextField(blank=True)
show_version = models.BooleanField(default=True)
use_cdn = models.BooleanField(default=True)
extra_head = models.TextField(blank=True)
class Meta:
verbose_name = "Site Configuration"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="420"
height="226"
viewBox="0 0 5.8333333 3.1388889"
version="1.1"
id="svg1"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1">
<linearGradient
id="linearGradient1">
<stop
style="stop-color:#ffffff;stop-opacity:1;"
offset="0.59606808"
id="stop2" />
<stop
style="stop-color:#f5f5f5;stop-opacity:1;"
offset="1"
id="stop1" />
</linearGradient>
<linearGradient
xlink:href="#linearGradient1"
id="linearGradient2"
x1="0"
y1="1.5694444"
x2="5.8333335"
y2="1.5694444"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.53809522,0,0,1.8584071,-3.1388889,0)"
spreadMethod="pad" />
</defs>
<g
id="layer1">
<rect
style="mix-blend-mode:normal;fill:url(#linearGradient2);stroke:none;stroke-width:0.801535"
id="rect1"
width="3.1388888"
height="5.8333335"
x="-3.1388888"
y="-2.220446e-16"
transform="rotate(-90)" />
<text
xml:space="preserve"
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: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>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -1,3 +1,8 @@
/* Switch SVG logo to white on dark mode */
html[data-bs-theme='dark'] .navbar svg {
fill: #fff;
}
.card > a > img {
width: 100%;
}
@@ -11,14 +16,18 @@ a.badge, a.badge:hover {
color: #fff;
}
.tab-pane {
min-height: 300px;
}
.img-thumbnail {
padding: 0;
}
.w-33 {
width: 33% !important;
}
.table-group-divider {
border-top: calc(var(--bs-border-width) * 3) solid var(--bs-border-color);
}
#nav-notes > p {
padding: .5rem;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,9 @@
<svg width="26" height="16" enable-background="new 0 0 26 26" version="1" viewBox="0 0 26 16" xmlns="http://www.w3.org/2000/svg">
<path d="m2.8125 0.0010991a1.0001 1.0001 0 0 0-0.8125 1c0 0.55455-0.44545 1-1 1a1.0001 1.0001 0 0 0-1 1v10a1.0001 1.0001 0 0 0 1 1c0.55455 0 1 0.44546 1 1a1.0001 1.0001 0 0 0 1 1h20a1.0001 1.0001 0 0 0 1-1c0-0.55454 0.44546-1 1-1a1.0001 1.0001 0 0 0 1-1v-10a1.0001 1.0001 0 0 0-1-1c-0.55454 0-1-0.44545-1-1a1.0001 1.0001 0 0 0-1-1h-20a1.0001 1.0001 0 0 0-0.09375 0 1.0001 1.0001 0 0 0-0.09375 0zm0.78125 2h14.406v1h2v-1h2.4062c0.30628 0.76906 0.82469 1.2875 1.5938 1.5938v8.8125c-0.76906 0.30628-1.2875 0.82469-1.5938 1.5938h-2.4062v-1h-2v1h-14.406c-0.30628-0.76906-0.82469-1.2875-1.5938-1.5938v-8.8125c0.76906-0.30628 1.2875-0.82469 1.5938-1.5938zm14.406 2v2h2v-2zm0 3v2h2v-2zm0 3v2h2v-2z" enable-background="accumulate" overflow="visible" stroke-width="2" />
<style>
path {
text-indent:0;
text-transform:none;
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -4,7 +4,7 @@
{% get_solo 'portal.SiteConfiguration' as site_conf %}
<!doctype html>
<html lang="en">
<html lang="en" data-bs-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -12,10 +12,17 @@
<meta name="description" content="{{ site_conf.about}}">
<meta name="author" content="{{ site_conf.site_author }}">
<meta name="generator" content="Django Framework">
<title>{{ site_conf.site_name }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css" rel="stylesheet">
<link href="{% static "css/main.css" %}" rel="stylesheet">
<link href="https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
<title>{% block title %}{{ title }}{% endblock %} - {{ site_conf.site_name }}</title>
<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">
{% 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">
{% endif %}
<link href="{% static "css/main.css" %}?v={{ site_conf.version }}" rel="stylesheet">
<style>
.bd-placeholder-img {
font-size: 1.125rem;
@@ -29,49 +36,143 @@
font-size: 3.5rem;
}
}
.d-light-inline { display: inline !important; }
.d-dark-inline { display: none !important; }
html.dark .d-light-inline { display: none !important; }
html.dark .d-dark-inline { display: inline !important; }
</style>
<script>
/*!
* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
* Copyright 2011-2023 The Bootstrap Authors
* Licensed under the Creative Commons Attribution 3.0 Unported License.
*/
(() => {
'use strict'
const getStoredTheme = () => localStorage.getItem('theme')
const setStoredTheme = theme => localStorage.setItem('theme', theme)
const getPreferredTheme = () => {
const storedTheme = getStoredTheme()
if (storedTheme) {
return storedTheme
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const setTheme = theme => {
if (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-bs-theme', 'dark')
} else {
document.documentElement.setAttribute('data-bs-theme', theme)
}
}
setTheme(getPreferredTheme())
const showActiveTheme = (theme, focus = false) => {
const themeSwitcher = document.querySelector('#bd-theme')
if (!themeSwitcher) {
return
}
const activeThemeIcon = document.querySelector('.theme-icon-active i')
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`)
const biOfActiveBtn = btnToActive.querySelector('.theme-icon i').getAttribute('class')
document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
element.classList.remove('active')
element.setAttribute('aria-pressed', 'false')
})
btnToActive.classList.add('active')
btnToActive.setAttribute('aria-pressed', 'true')
activeThemeIcon.setAttribute('class', biOfActiveBtn)
if (focus) {
themeSwitcher.focus()
}
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const storedTheme = getStoredTheme()
if (storedTheme !== 'light' && storedTheme !== 'dark') {
setTheme(getPreferredTheme())
}
})
window.addEventListener('DOMContentLoaded', () => {
showActiveTheme(getPreferredTheme())
document.querySelectorAll('[data-bs-theme-value]')
.forEach(toggle => {
toggle.addEventListener('click', () => {
const theme = toggle.getAttribute('data-bs-theme-value')
setStoredTheme(theme)
setTheme(theme)
showActiveTheme(theme, true)
})
})
})
})()
</script>
{% block extra_head %}
{{ site_conf.extra_head | safe }}
{% endblock %}
</head>
<body>
<header>
<div class="navbar navbar-light bg-light shadow-sm">
<div class="container">
<a href="{% url 'index' %}" class="navbar-brand d-flex align-items-center">
<svg class="me-2" width="26" height="16" enable-background="new 0 0 26 26" version="1" viewBox="0 0 26 16" xmlns="http://www.w3.org/2000/svg">
<path d="m2.8125 0.0010991a1.0001 1.0001 0 0 0-0.8125 1c0 0.55455-0.44545 1-1 1a1.0001 1.0001 0 0 0-1 1v10a1.0001 1.0001 0 0 0 1 1c0.55455 0 1 0.44546 1 1a1.0001 1.0001 0 0 0 1 1h20a1.0001 1.0001 0 0 0 1-1c0-0.55454 0.44546-1 1-1a1.0001 1.0001 0 0 0 1-1v-10a1.0001 1.0001 0 0 0-1-1c-0.55454 0-1-0.44545-1-1a1.0001 1.0001 0 0 0-1-1h-20a1.0001 1.0001 0 0 0-0.09375 0 1.0001 1.0001 0 0 0-0.09375 0zm0.78125 2h14.406v1h2v-1h2.4062c0.30628 0.76906 0.82469 1.2875 1.5938 1.5938v8.8125c-0.76906 0.30628-1.2875 0.82469-1.5938 1.5938h-2.4062v-1h-2v1h-14.406c-0.30628-0.76906-0.82469-1.2875-1.5938-1.5938v-8.8125c0.76906-0.30628 1.2875-0.82469 1.5938-1.5938zm14.406 2v2h2v-2zm0 3v2h2v-2zm0 3v2h2v-2z" enable-background="accumulate" fill="#000" overflow="visible" stroke-width="2" style="text-indent:0;text-transform:none"/>
</svg>
<strong>{{ site_conf.site_name }}</strong>
</a>
<div class="btn-group" role="group" aria-label="Basic example">
{% include 'includes/login.html' %}
<a id="darkmode-button" class="btn btn-sm btn-outline-dark"><i class="fa fa-moon-o fa-fw d-none d-light-inline" title="Switch to dark mode"></i><i class="fa fa-sun-o fa-fw d-none d-dark-inline" title="Switch to light mode"></i></a>
<nav class="navbar navbar-expand-lg 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">
<svg class="me-2" width="26" height="16" viewBox="0 0 26 16" xmlns="http://www.w3.org/2000/svg">
<path d="m2.8125 0.0010991a1.0001 1.0001 0 0 0-0.8125 1c0 0.55455-0.44545 1-1 1a1.0001 1.0001 0 0 0-1 1v10a1.0001 1.0001 0 0 0 1 1c0.55455 0 1 0.44546 1 1a1.0001 1.0001 0 0 0 1 1h20a1.0001 1.0001 0 0 0 1-1c0-0.55454 0.44546-1 1-1a1.0001 1.0001 0 0 0 1-1v-10a1.0001 1.0001 0 0 0-1-1c-0.55454 0-1-0.44545-1-1a1.0001 1.0001 0 0 0-1-1h-20a1.0001 1.0001 0 0 0-0.09375 0 1.0001 1.0001 0 0 0-0.09375 0zm0.78125 2h14.406v1h2v-1h2.4062c0.30628 0.76906 0.82469 1.2875 1.5938 1.5938v8.8125c-0.76906 0.30628-1.2875 0.82469-1.5938 1.5938h-2.4062v-1h-2v1h-14.406c-0.30628-0.76906-0.82469-1.2875-1.5938-1.5938v-8.8125c0.76906-0.30628 1.2875-0.82469 1.5938-1.5938zm14.406 2v2h2v-2zm0 3v2h2v-2zm0 3v2h2v-2z" stroke-width="2" />
<style>
path {
text-indent:0;
text-transform:none;
}
</style>
</svg>
<strong>{{ site_conf.site_name }}</strong>
</a>
</div>
{% include 'includes/login.html' %}
</div>
</div>
</nav>
</header>
<main>
<div class="container py-2">
<nav class="navbar navbar-expand-lg navbar-light">
<nav class="navbar navbar-expand-lg">
<div class="container-fluid g-0">
<a class="navbar-brand" href="{% url 'index' %}">Home</a>
<a class="navbar-brand" href="{% url 'index' %}">Home</a>
<div class="navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" href="{% url 'index' %}">Roster</a>
<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>
<li class="nav-item">
<a class="nav-link" href="{% url 'consists' %}">Consists</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'companies' %}">Companies</a>
<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>
<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>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'scales' %}">Scales</a>
</li>
{% show_menu %}
{% show_bookshelf_menu %}
{% show_flatpages_menu %}
</ul>
{% include 'includes/search.html' %}
</div>
@@ -81,116 +182,29 @@
<section class="py-4 text-center container">
<div class="row">
<div class="mx-auto">
{% block header %}{% endblock %}
<h1 class="fw-light">{{ title }}</h1>
{% block header %}
{% endblock %}
</div>
</div>
</section>
<div class="album py-4 bg-light">
<div class="album py-4 bg-body-tertiary">
<div class="container">
<a id="rolling-stock"></a>
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3">
{% block cards %}
{% for r in rolling_stock %}
<div class="col">
<div class="card shadow-sm">
{% for i in r.image.all %}
{% if i.is_thumbnail %}<a href="{{r.get_absolute_url}}"><img src="{{ i.image.url }}" alt="Card image cap"></a>{% endif %}
{% endfor %}
<div class="card-body">
<p class="card-text" style="position: relative;">
<strong>{{ r }}</strong>
<a class="stretched-link" href="{{ r.get_absolute_url }}"></a>
</p>
{% if r.tags.all %}
<p class="card-text"><small>Tags:</small>
{% for t in r.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>
<tr>
<th width="35%" scope="row">Type</th>
<td>{{ r.rolling_class.type }}</td>
</tr>
<tr>
<th scope="row">Company</th>
<td><abbr title="{{ r.rolling_class.company.extended_name }}">{{ r.rolling_class.company }}</abbr></td>
</tr>
<tr>
<th scope="row">Class</th>
<td>{{ r.rolling_class.identifier }}</td>
</tr>
<tr>
<th scope="row">Road number</th>
<td>{{ r.road_number }}</td>
</tr>
<tr>
<th scope="row">Era</th>
<td>{{ r.era }}</td>
</tr>
<tr>
<th width="35%" scope="row">Manufacturer</th>
<td>{% if r.manufacturer.website %}<a href="{{ r.manufacturer.website }}">{% endif %}{{ r.manufacturer }}{% if r.manufacturer.website %}</a>{% endif %}</th>
</tr>
<tr>
<th scope="row">Scale</th>
<td><a href="{% url 'filtered' _filter="scale" search=r.scale %}"><abbr title="{{ r.scale.ratio }} - {{ r.scale.tracks }}">{{ r.scale }}</abbr></a></td>
</tr>
<tr>
<th scope="row">SKU</th>
<td>{{ r.sku }}</td>
</tr>
</tbody>
</table>
{% if r.decoder %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">DCC data</th>
</tr>
</thead>
<tbody>
<tr>
<th width="35%" scope="row">Decoder</th>
<td>{{ r.decoder }}</td>
</tr>
<tr>
<th scope="row">Address</th>
<td>{{ r.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="{{r.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' r.pk %}">Edit</a>{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
{% endblock %}
</div>
{% block carousel %}
{% endblock %}
<a id="main-content"></a>
{% block cards_layout %}
{% endblock %}
</div>
<div class="container">{% block pagination %}{% endblock %}</div>
</div>
{% block extra_content %}{% endblock %}
</main>
{% include 'includes/footer.html' %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/js/darkmode.min.js"></script>
<!-- script src="https://cdn.jsdelivr.net/npm/masonry-layout@4.2.2/dist/masonry.pkgd.min.js" integrity="sha384-GNFwBvfVxBkLMJpYMOABq3c+d3KnQxudP/mGPkzpZSTYykLBNsZEnG2D9G/X/+7D" crossorigin="anonymous" async></script -->
<script>
document.querySelector("#darkmode-button").onclick = function(e){
darkmode.toggleDarkMode();
}
</script>
{% if site_conf.use_cdn %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
{% else %}
<script src="{% static "bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" %}"></script>
{% endif %}
</body>
</html>

View File

@@ -0,0 +1,123 @@
{% extends 'base.html' %}
{% block header %}
{% if book.tags.all %}
<p><small>Tags:</small>
{% for t in book.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 %}
<small class="text-muted">Updated {{ book.updated_time | date:"M d, Y H:i" }}</small>
{% endblock %}
{% block carousel %}
<div class="row">
<div id="carouselControls" class="carousel carousel-dark slide" data-bs-ride="carousel" data-bs-interval="10000">
<div class="carousel-inner">
{% for t in book.image.all %}
{% if forloop.first %}
<div class="carousel-item active">
{% else %}
<div class="carousel-item">
{% endif %}
<img src="{{ t.image.url }}" class="d-block w-100 rounded img-thumbnail" alt="...">
</div>
{% endfor %}
</div>
{% 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>
</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>
</button>
{% endif %}
</div>
</div>
{% endblock %}
{% block cards %}
{% endblock %}
{% block extra_content %}
<section class="py-4 text-start container">
<div class="row">
<div class="mx-auto">
<nav class="nav nav-tabs flex-column flex-md-row mb-2" id="nav-tab">
<button class="nav-link active" id="nav-summary-tab" data-bs-toggle="tab" data-bs-target="#nav-summary" type="button" role="tab" aria-controls="nav-summary" aria-selected="true">Summary</button>
{% if book.notes %}<button class="nav-link" id="nav-notes-tab" data-bs-toggle="tab" data-bs-target="#nav-notes" type="button" role="tab" aria-controls="nav-notes" aria-selected="false">Notes</button>{% endif %}
</nav>
<div class="tab-content" id="nav-tabContent">
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Book</th>
</tr>
</thead>
<tbody class="table-group-divider">
<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>
</tr>
<tr>
<th scope="row">Publisher</th>
<td>{{ book.publisher }}</td>
</tr>
<tr>
<th scope="row">ISBN</th>
<td>{{ book.ISBN|default:"-" }}</td>
</tr>
<tr>
<th scope="row">Language</th>
<td>{{ book.get_language_display }}</td>
</tr>
<tr>
<th scope="row">Number of pages</th>
<td>{{ book.number_of_pages|default:"-" }}</td>
</tr>
<tr>
<th scope="row">Publication year</th>
<td>{{ book.publication_year|default:"-" }}</td>
</tr>
<tr>
<th scope="row">Purchase date</th>
<td>{{ book.purchase_date|default:"-" }}</td>
</tr>
</tbody>
</table>
{% if book_properties %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Properties</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for p in book_properties %}
<tr>
<th class="w-33" scope="row">{{ p.property }}</th>
<td>{{ p.value }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</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 %}
</div>
</div>
</div>
</section>
{% endblock %}

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

@@ -0,0 +1,10 @@
{% if bookshelf_menu %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="bookshelfDropdownMenuLink" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Bookshelf
</a>
<ul class="dropdown-menu" aria-labelledby="bookshelfDropdownMenuLink">
<li><a class="dropdown-item" href="{% url 'books' %}">Books</a></li>
</ul>
</li>
{% endif %}

View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% 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">
{% block cards %}
{% for d in data %}
{% if d.type == "rolling_stock" %}
{% include "cards/rolling_stock.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" %}
{% include "cards/book.html" %}
{% endif %}
{% endfor %}
{% endblock %}
</div>
{% endblock %}

View File

@@ -0,0 +1,60 @@
<div class="col">
<div class="card shadow-sm">
{% if d.item.image.all %}
<a href="{{ d.item.get_absolute_url }}">
{% for r in d.item.image.all %}
{% if forloop.first %}<img src="{{ r.image.url }}" alt="Card image cap">{% endif %}
{% endfor %}
</a>
{% 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">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.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>
<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="{% url 'admin:bookshelf_book_change' 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 style="max-height: 48px" src="{{ d.item.logo.url }}" /></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,59 @@
<div class="col">
<div class="card shadow-sm">
<a href="{{ d.item.get_absolute_url }}">
{% if d.item.image %}
<img src="{{ d.item.image.url }}" alt="Card image cap">
{% else %}
{% with d.item.consist_item.first.rolling_stock as r %}
{% for i in r.image.all %}
{% if forloop.first %}<img src="{{ i.image.url }}" alt="Card image cap">{% endif %}
{% endfor %}
{% 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 data</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.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.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 style="max-height: 48px" src="{{ d.item.logo.url }}" /></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,92 @@
{% load static %}
<div class="col">
<div class="card shadow-sm">
{% if d.item.image.count > 0 %}
<a href="{{d.item.get_absolute_url}}"><img class="card-img-top" src="{{ d.item.image.first.image.url }}" alt="{{ d }}"></a>
{% else %}
<!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
<a href="{{d.item.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d }}"></a>
{% 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 }}">{{ d.item.scale }}</abbr></a></td>
</tr>
<tr>
<th scope="row">Item number</th>
<td>{{ d.item.item_number }}</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,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,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">Gauge</th>
<td>{{ d.item.gauge }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Tracks</th>
<td>{{ d.item.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.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,65 +1,11 @@
{% extends "base.html" %}
{% block header %}
<h1 class="fw-light">Companies</h1>
{% endblock %}
{% block cards %}
{% for c in company %}
<div class="col">
<div class="card shadow-sm">
<div class="card-body">
<p class="card-text" style="position: relative;">
<strong>{{ c.name }}</strong>
</p>
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Company</th>
</tr>
</thead>
<tbody>
{% if c.logo %}
<tr>
<th width="35%" scope="row">Logo</th>
<td><img style="max-height: 48px" src="{{ c.logo.url }}" /></td>
</tr>
{% endif %}
<tr>
<th width="35%" scope="row">Name</th>
<td>{{ c.extended_name }}</td>
</tr>
<tr>
<th width="35%" scope="row">Abbreviation</th>
<td>{{ c }}</td>
</tr>
<tr>
<th width="35%" scope="row">Country</th>
<td>{{ c.country.name }} <img src="{{ c.country.flag }}" alt="{{ c.country }}" />
</tr>
{% if c.freelance %}
<tr>
<th width="35%" 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=c %}">Show all rolling stock</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_company_change' c.pk %}">Edit</a>{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
{% endblock %}
{% extends "cards.html" %}
{% block pagination %}
{% if company.has_other_pages %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<ul class="pagination justify-content-center mt-4 mb-0">
{% if company.has_previous %}
{% if data.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'companies_pagination' page=company.previous_page_number %}#rolling-stock" tabindex="-1">Previous</a>
<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">
@@ -67,21 +13,21 @@
</li>
{% endif %}
{% for i in page_range %}
{% if company.number == i %}
{% if data.number == i %}
<li class="page-item active">
<span class="page-link">{{ i }}</span></span>
<span class="page-link">{{ i }}</span>
</li>
{% else %}
{% if i == company.paginator.ELLIPSIS %}
{% 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 %}#rolling-stock">{{ i }}</a></li>
<li class="page-item"><a class="page-link" href="{% url 'companies_pagination' page=i %}#main-content">{{ i }}</a></li>
{% endif %}
{% endif %}
{% endfor %}
{% if company.has_next %}
{% if data.has_next %}
<li class="page-item">
<a class="page-link" href="{% url 'companies_pagination' page=company.next_page_number %}#rolling-stock" tabindex="-1">Next</a>
<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">

View File

@@ -1,7 +1,6 @@
{% extends "base.html" %}
{% extends "cards.html" %}
{% block header %}
<h1 class="fw-light">{{ consist }}</h1>
{% if consist.tags.all %}
<p><small>Tags:</small>
{% for t in consist.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary">
@@ -11,101 +10,26 @@
<small class="text-muted">Updated {{ consist.updated_time | date:"M d, Y H:i" }}</small>
{% endif %}
{% endblock %}
{% block cards %}
{% for r in rolling_stock %}
<div class="col">
<div class="card shadow-sm">
{% for i in r.rolling_stock.image.all %}
{% if i.is_thumbnail %}<a href="{{r.rolling_stock.get_absolute_url}}"><img src="{{ i.image.url }}" alt="Card image cap"></a>{% endif %}
{% endfor %}
<div class="card-body">
<p class="card-text" style="position: relative;">
<strong>{{ r }}</strong>
<a class="stretched-link" href="{{ r.rolling_stock.get_absolute_url }}"></a>
</p>
{% if r.rolling_stock.tags.all %}
<p class="card-text"><small>Tags:</small>
{% for t in r.rolling_stock.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>
<tr>
<th width="35%" scope="row">Type</th>
<td>{{ r.rolling_stock.rolling_class.type }}</td>
</tr>
<tr>
<th scope="row">Company</th>
<td><abbr title="{{ r.rolling_stock.rolling_class.company.extended_name }}">{{ r.rolling_stock.rolling_class.company }}</abbr></td>
</tr>
<tr>
<th scope="row">Class</th>
<td>{{ r.rolling_stock.rolling_class.identifier }}</td>
</tr>
<tr>
<th scope="row">Road number</th>
<td>{{ r.rolling_stock.road_number }}</td>
</tr>
<tr>
<th scope="row">Era</th>
<td>{{ r.rolling_stock.era }}</td>
</tr>
<tr>
<th width="35%" scope="row">Manufacturer</th>
<td>{% if r.rolling_stock.manufacturer.website %}<a href="{{ r.rolling_stock.manufacturer.website }}">{% endif %}{{ r.rolling_stock.manufacturer }}{% if r.rolling_stock.manufacturer.website %}</a>{% endif %}</th>
</tr>
<tr>
<th scope="row">Scale</th>
<td><a href="{% url 'filtered' _filter="scale" search=r.rolling_stock.scale %}"><abbr title="{{ r.rolling_stock.scale.ratio }} - {{ r.rolling_stock.scale.tracks }}">{{ r.rolling_stock.scale }}</abbr></a></td>
</tr>
<tr>
<th scope="row">SKU</th>
<td>{{ r.rolling_stock.sku }}</td>
</tr>
</tbody>
</table>
{% if r.rolling_stock.decoder %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">DCC data</th>
</tr>
</thead>
<tbody>
<tr>
<th width="35%" scope="row">Decoder</th>
<td>{{ r.rolling_stock.decoder }}</td>
</tr>
<tr>
<th scope="row">Address</th>
<td>{{ r.rolling_stock.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="{{r.rolling_stock.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' r.rolling_stock.pk %}">Edit</a>{% endif %}
{% block carousel %}
{% if consist.image %}
<div class="row pb-4">
<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="...">
</div>
</div>
</div>
</div>
{% endfor %}
{% endif %}
{% endblock %}
{% block pagination %}
{% if rolling_stock.has_other_pages %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<ul class="pagination justify-content-center mt-4 mb-0">
{% if rolling_stock.has_previous %}
{% if data.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'consist_pagination' uuid=consist.uuid page=rolling_stock.previous_page_number %}#rolling-stock" tabindex="-1">Previous</a>
<a class="page-link" href="{% url 'consist_pagination' uuid=consist.uuid page=data.previous_page_number %}#main-content" tabindex="-1">Previous</a>
</li>
{% else %}
<li class="page-item disabled">
@@ -113,21 +37,21 @@
</li>
{% endif %}
{% for i in page_range %}
{% if rolling_stock.number == i %}
{% if data.number == i %}
<li class="page-item active">
<span class="page-link">{{ i }}</span></span>
<span class="page-link">{{ i }}</span>
</li>
{% else %}
{% if i == rolling_stock.paginator.ELLIPSIS %}
{% 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 'consist_pagination' uuid=consist.uuid page=i %}#rolling-stock">{{ i }}</a></li>
<li class="page-item"><a class="page-link" href="{% url 'consist_pagination' uuid=consist.uuid page=i %}#main-content">{{ i }}</a></li>
{% endif %}
{% endif %}
{% endfor %}
{% if rolling_stock.has_next %}
{% if data.has_next %}
<li class="page-item">
<a class="page-link" href="{% url 'consist_pagination' uuid=consist.uuid page=rolling_stock.next_page_number %}#rolling-stock" tabindex="-1">Next</a>
<a class="page-link" href="{% url 'consist_pagination' uuid=consist.uuid page=data.next_page_number %}#main-content" tabindex="-1">Next</a>
</li>
{% else %}
<li class="page-item disabled">
@@ -142,6 +66,49 @@
<section class="py-4 text-start container">
<div class="row">
<div class="mx-auto">
<nav class="nav nav-tabs flex-column flex-md-row mb-2" id="nav-tab">
<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>
<div class="tab-content" id="nav-tabContent">
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Data</th>
</tr>
</thead>
<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>
</tr>
<tr>
<th scope="row">Era</th>
<td>{{ consist.era }}</td>
</tr>
<tr>
<th scope="row">Length</th>
<td>{{ data | length }}</td>
</tr>
</tbody>
</table>
</div>
<div class="tab-pane" id="nav-notes" role="tabpanel" aria-labelledby="nav-notes-tab">
<table class="table">
<thead>
<tr>
<th scope="row">Notes</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<td>{{ consist.notes | safe }}</td>
</tr>
</tbody>
</table>
</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:consist_consist_change' consist.pk %}">Edit</a>{% endif %}
</div>

View File

@@ -1,78 +1,11 @@
{% extends "base.html" %}
{% block header %}
<h1 class="fw-light">Consists</h1>
{% endblock %}
{% block cards %}
{% for c in consist %}
<div class="col">
<div class="card shadow-sm">
<a href="{{ c.get_absolute_url }}">
{% if c.image %}
<img src="{{ c.image.url }}" alt="Card image cap">
{% else %}
{% with c.consist_item.first.rolling_stock as r %}
{% for i in r.image.all %}
{% if i.is_thumbnail %}<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>{{ c }}</strong>
<a class="stretched-link" href="{{ c.get_absolute_url }}"></a>
</p>
{% if c.tags.all %}
<p class="card-text"><small>Tags:</small>
{% for t in c.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>
{% if c.address %}
<tr>
<th width="35%" scope="row">Address</th>
<td>{{ c.address }}</td>
</tr>
{% endif %}
<tr>
<th width="35%" scope="row">Company</th>
<td><abbr title="{{ c.company.extended_name }}">{{ c.company }}</abbr></td>
</tr>
<tr>
<th scope="row">Era</th>
<td>{{ c.era }}</td>
</tr>
<tr>
<th scope="row">Length</th>
<td>{{ c.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="{{ c.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' c.pk %}">Edit</a>{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
{% endblock %}
{% extends "cards.html" %}
{% block pagination %}
{% if consist.has_other_pages %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<ul class="pagination justify-content-center mt-4 mb-0">
{% if consist.has_previous %}
{% if data.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'consists_pagination' page=consist.previous_page_number %}#rolling-stock" tabindex="-1">Previous</a>
<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">
@@ -80,21 +13,21 @@
</li>
{% endif %}
{% for i in page_range %}
{% if consist.number == i %}
{% if data.number == i %}
<li class="page-item active">
<span class="page-link">{{ i }}</span></span>
<span class="page-link">{{ i }}</span>
</li>
{% else %}
{% if i == consist.paginator.ELLIPSIS %}
{% 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 %}#rolling-stock">{{ i }}</a></li>
<li class="page-item"><a class="page-link" href="{% url 'consists_pagination' page=i %}#main-content">{{ i }}</a></li>
{% endif %}
{% endif %}
{% endfor %}
{% if consist.has_next %}
{% if data.has_next %}
<li class="page-item">
<a class="page-link" href="{% url 'consists_pagination' page=consist.next_page_number %}#rolling-stock" tabindex="-1">Next</a>
<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">

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 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>
</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 'filtered_pagination' _filter=filter 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 'filtered_pagination' _filter=filter search=search 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,7 +1,6 @@
{% extends 'base.html' %}
{% block header %}
<h1 class="fw-light">{{ flatpage.name }}</h1>
<small class="text-muted">Updated {{ flatpage.updated_time | date:"M d, Y H:i" }}</small>
{% endblock %}
{% block extra_content %}

View File

@@ -1,10 +1,10 @@
{% if menu %}
{% if flatpages_menu %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" role="button" data-bs-toggle="dropdown" aria-expanded="false">
More ...
<a class="nav-link dropdown-toggle" href="#" id="flatpageDropdownMenuLink" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Articles
</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink">
{% for m in menu %}
<ul class="dropdown-menu" aria-labelledby="flatpageDropdownMenuLink">
{% for m in flatpages_menu %}
<li><a class="dropdown-item" href="{{ m.get_absolute_url }}">{{ m.name }}</a></li>
{% endfor %}
</ul>

View File

@@ -1,46 +1,5 @@
{% extends "base.html" %}
{% extends "roster.html" %}
{% block header %}
{% if site_conf.about %}<h1 class="fw-light">About</h1>{% endif %}
<p class="lead text-muted">{{ site_conf.about | safe }}</p>
{% endblock %}
{% block pagination %}
{% if rolling_stock.has_other_pages %}
<nav aria-label="Page navigation example">
<ul class="pagination justify-content-center mt-4 mb-0">
{% if rolling_stock.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'index_pagination' page=rolling_stock.previous_page_number %}#rolling-stock" 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 rolling_stock.number == i %}
<li class="page-item active">
<span class="page-link">{{ i }}</span></span>
</li>
{% else %}
{% if i == rolling_stock.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %}
<li class="page-item"><a class="page-link" href="{% url 'index_pagination' page=i %}#rolling-stock">{{ i }}</a></li>
{% endif %}
{% endif %}
{% endfor %}
{% if rolling_stock.has_next %}
<li class="page-item">
<a class="page-link" href="{% url 'index_pagination' page=rolling_stock.next_page_number %}#rolling-stock" tabindex="-1">Next</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Next</span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
<div class="text-muted">{{ site_conf.about | safe }}</div>
{% endblock %}

View File

@@ -13,6 +13,6 @@
<div class="container">
<p class="small text-muted">Made with ❤️ for 🚂 and <a href="https://github.com/daniviga/django-ram">django-ram</a>
{% if site_conf.show_version %}<br/>Version {{ site_conf.version }}{% endif %}
{% if site_conf.show_version %}<br>Version {{ site_conf.version }}{% endif %}
</div>
</footer>

View File

@@ -1,21 +1,48 @@
{% if request.user.is_staff %}
<div class="dropdown">
<button class="btn btn-sm btn-outline-dark dropdown-toggle" type="button" id="dropdownMenu2" data-bs-toggle="dropdown" aria-expanded="false">
Welcome back, <strong>{{ request.user }}</strong>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="dropdownMenu2">
<li><a class="dropdown-item" href="{% url 'admin:roster_rollingstock_changelist' %}">Rolling stock</a></li>
<li><a class="dropdown-item" href="{% url 'admin:consist_consist_changelist' %}">Consists</a></li>
<li><a class="dropdown-item" href="{% url 'admin:app_list' 'metadata' %}">Metadata</a></li>
<li><a class="dropdown-item" href="{% url 'admin:portal_flatpage_changelist' %}">Pages</a></li>
<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>
<div class="navbar-collapse justify-content-end" id="loginNavbar">
<ul class="navbar-nav">
<li class="nav-item dropdown">
{% if request.user.is_staff %}
<a class="nav-link dropdown-toggle" href="#" role="button" id="dropdownLogin" data-bs-toggle="dropdown" aria-expanded="false">
Welcome back, <strong>{{ request.user }}</strong>
</a>
<ul class="dropdown-menu" aria-labelledby="dropdownLogin">
<li><a class="dropdown-item" href="{% url 'admin:roster_rollingstock_changelist' %}">Rolling stock</a></li>
<li><a class="dropdown-item" href="{% url 'admin:consist_consist_changelist' %}">Consists</a></li>
<li><a class="dropdown-item" href="{% url 'admin:app_list' 'metadata' %}">Metadata</a></li>
<li><a class="dropdown-item" href="{% url 'admin:portal_flatpage_changelist' %}">Pages</a></li>
<li><a class="dropdown-item" href="{% url 'admin:app_list' 'bookshelf' %}">Bookshelf</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'admin:index' %}">Admin</a></li>
<li><a class="dropdown-item" href="{% url 'admin:portal_siteconfiguration_changelist' %}">Site configuration</a></li>
<li><a class="dropdown-item" href="{% url 'admin:driver_driverconfiguration_changelist' %}">DCC configuration</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="{% url 'admin:logout' %}?next={{ request.path }}">Logout</a></li>
</ul>
{% else %}
<a class="nav-link" href="{% url 'admin:login' %}?next={{ request.path }}">Log in</a>
{% endif %}
</li>
<li class="nav-item dropdown">
<a class="theme-icon-active nav-link dropdown-toggle" href="#" role="button" id="bd-theme" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-circle-half"></i>
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="bd-theme">
<li>
<button class="theme-icon dropdown-item" data-bs-theme-value="light" aria-pressed="false">
<span class="me-2"><i class="bi bi-sun-fill"></i></span> Light
</button>
</li>
<li>
<button class="theme-icon dropdown-item" data-bs-theme-value="dark" aria-pressed="false">
<span class="me-2"><i class="bi bi-moon-stars-fill"></i></span> Dark
</button>
</li>
<li>
<button class="theme-icon dropdown-item" data-bs-theme-value="auto" aria-pressed="false">
<span class="me-2"><i class="bi bi-circle-half"></i></span> Auto
</button>
</li>
</ul>
</li>
</ul>
</div>
{% else %}
<a class="btn btn-sm btn-outline-dark" href="{% url 'admin:login' %}?next={{ request.path }}">Log in</a>
{% endif %}

View File

@@ -1,6 +1,12 @@
<form class="d-flex needs-validation" action="{% url 'search' %}" method="post" novalidate>
<div class="input-group has-validation">
<input class="form-control me-2" type="search" placeholder="Search" aria-label="Search" name="search" id="searchValidation" required>
<input class="form-control" type="search" list="datalistOptions" placeholder="Search" aria-label="Search" name="search" id="searchValidation" required>
<datalist id="datalistOptions">
<option value="company: ">
<option value="manufacturer: ">
<option value="scale: ">
<option value="type: ">
</datalist>
<button class="btn btn-outline-primary" type="submit">Search</button>
</div>
</form>

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 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,7 +1,6 @@
{% extends 'base.html' %}
{% block header %}
<h1 class="fw-light">{{ rolling_stock }}</h1>
{% if rolling_stock.tags.all %}
<p><small>Tags:</small>
{% for t in rolling_stock.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary">
@@ -11,61 +10,66 @@
{% endif %}
<small class="text-muted">Updated {{ rolling_stock.updated_time | date:"M d, Y H:i" }}</small>
{% endblock %}
{% block cards %}
{% for t in rolling_stock.image.all %}
<div class="col">
<a href="" data-bs-toggle="modal" data-bs-target="#pictureModal{{ forloop.counter }}"><img class="img-thumbnail" src="{{ t.image.url }}" alt="Rolling stock image"></a>
</div>
<!-- Modal -->
<div class="modal fade" id="pictureModal{{ forloop.counter }}" tabindex="-1" aria-labelledby="pictureModalLabel{{ forloop.counter }}" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="pictureModalLabel{{ forloop.counter }}">{{ rolling_stock }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center">
<img class="rounded img-fluid" src="{{ t.image.url }}" alt="Rolling stock image">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
{% block carousel %}
<div class="row">
<div id="carouselControls" class="carousel carousel-dark slide" data-bs-ride="carousel" data-bs-interval="10000">
<div class="carousel-inner">
{% for t in rolling_stock.image.all %}
{% if forloop.first %}
<div class="carousel-item active">
{% else %}
<div class="carousel-item">
{% endif %}
<img src="{{ t.image.url }}" class="d-block w-100 rounded img-thumbnail" alt="...">
</div>
{% endfor %}
</div>
{% 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>
</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>
</button>
{% endif %}
</div>
</div>
{% endfor %}
{% endblock %}
{% block cards %}
{% endblock %}
{% block extra_content %}
<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.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 rolling_stock.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 %}
</div>
<nav class="nav nav-tabs flex-column flex-md-row mb-2" id="nav-tab">
<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 %}
</nav>
<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>
<th colspan="2" scope="row">Data</th>
</tr>
</thead>
<tbody>
<tbody class="table-group-divider">
<tr>
<th width="35%" scope="row">Type</th>
<th class="w-33" scope="row">Type</th>
<td>{{ rolling_stock.rolling_class.type }}</td>
</tr>
<tr>
<th scope="row">Company</th>
<td><abbr title="{{ rolling_stock.rolling_class.company.extended_name }}">{{ rolling_stock.rolling_class.company }}</abbr></td>
<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>
</td>
</tr>
<tr>
<th scope="row">Class</th>
@@ -87,60 +91,70 @@
<th colspan="2" scope="row">Model data</th>
</tr>
</thead>
<tbody>
<tbody class="table-group-divider">
<tr>
<th width="35%" scope="row">Manufacturer</th>
<td>{% if rolling_stock.manufacturer.website %}<a href="{{ rolling_stock.manufacturer.website }}">{% endif %}{{ rolling_stock.manufacturer|default_if_none:"" }}{% if rolling_stock.manufacturer.website %}</a>{% endif %}</th>
<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>
</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>
</tr>
<tr>
<th scope="row">SKU</th>
<td>{{ rolling_stock.sku }}</td>
<th scope="row">Item number</th>
<td>{{ rolling_stock.item_number }}</td>
</tr>
</tbody>
</table>
{% if rolling_stock.decoder %}
{% if rolling_stock.decoder or rolling_stock.decoder_interface %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">DCC data</th>
</tr>
</thead>
<tbody>
<tbody class="table-group-divider">
<tr>
<th width="35%" scope="row">Decoder</th>
<th class="w-33" scope="row">Interface</th>
<td>{{ rolling_stock.get_decoder_interface_display }}</td>
</tr>
{% if rolling_stock.decoder %}
<tr>
<th scope="row">Decoder</th>
<td>{{ rolling_stock.decoder }}</td>
</tr>
<tr>
<th scope="row">Address</th>
<td>{{ rolling_stock.address }}</td>
</tr>
{% endif %}
</tbody>
</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">
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Model data</th>
</tr>
</thead>
<tbody>
<tbody class="table-group-divider">
<tr>
<th width="35%" scope="row">Manufacturer</th>
<td>{% if rolling_stock.manufacturer.website %}<a href="{{ rolling_stock.manufacturer.website }}">{% endif %}{{ rolling_stock.manufacturer|default_if_none:"" }}{% if rolling_stock.manufacturer.website %}</a>{% endif %}</th>
<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>
</tr>
<tr>
<th scope="row">Scale</th>
<td><abbr title="{{ rolling_stock.scale.ratio }} - {{ rolling_stock.scale.tracks }}">{{ rolling_stock.scale }}</abbr></td>
<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>
</tr>
<tr>
<th scope="row">SKU</th>
<td>{{ rolling_stock.sku }}</td>
<th scope="row">Item number</th>
<td>{{ rolling_stock.item_number }}</td>
</tr>
<tr>
<th scope="row">Era</th>
@@ -148,11 +162,11 @@
</tr>
<tr>
<th scope="row">Production year</th>
<td>{{ rolling_stock.production_year | default_if_none:"" }}</td>
<td>{{ rolling_stock.production_year|default:"-" }}</td>
</tr>
<tr>
<th scope="row">Purchase date</th>
<td>{{ rolling_stock.purchase_date | default_if_none:"" }}</td>
<td>{{ rolling_stock.purchase_date|default:"-" }}</td>
</tr>
</tbody>
</table>
@@ -163,10 +177,10 @@
<th colspan="2" scope="row">Properties</th>
</tr>
</thead>
<tbody>
<tbody class="table-group-divider">
{% for p in rolling_stock_properties %}
<tr>
<th width="35%" scope="row">{{ p.property }}</th>
<th class="w-33" scope="row">{{ p.property }}</th>
<td>{{ p.value }}</td>
</tr>
{% endfor %}
@@ -174,16 +188,16 @@
</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">
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Class data</th>
</tr>
</thead>
<tbody>
<tbody class="table-group-divider">
<tr>
<th width="35%" scope="row">Class</th>
<th class="w-33" scope="row">Class</th>
<td>{{ rolling_stock.rolling_class.identifier }}</td>
</tr>
<tr>
@@ -192,7 +206,9 @@
</tr>
<tr>
<th scope="row">Company</th>
<td>{{ rolling_stock.rolling_class.company }} ({{ rolling_stock.rolling_class.company.extended_name }})</td>
<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>
@@ -200,7 +216,9 @@
</tr>
<tr>
<th scope="row">Manufacturer</th>
<td>{{ rolling_stock.rolling_class.manufacturer|default_if_none:"" }}</td>
<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>
</tr>
</tbody>
</table>
@@ -211,10 +229,10 @@
<th colspan="2" scope="row">Properties</th>
</tr>
</thead>
<tbody>
<tbody class="table-group-divider">
{% for p in class_properties %}
<tr>
<th width="35%" scope="row">{{ p.property }}</th>
<th class="w-33" scope="row">{{ p.property }}</th>
<td>{{ p.value }}</td>
</tr>
{% endfor %}
@@ -222,16 +240,20 @@
</table>
{% endif %}
</div>
<div class="tab-pane fade" id="nav-dcc" role="tabpanel" aria-labelledby="nav-dcc-tab">
<div class="tab-pane" id="nav-dcc" role="tabpanel" aria-labelledby="nav-dcc-tab">
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">DCC data</th>
</tr>
</thead>
<tbody>
<tbody class="table-group-divider">
<tr>
<th width="35%" scope="row">Address</th>
<th scope="row">Interface</th>
<td>{{ rolling_stock.get_decoder_interface_display }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Address</th>
<td>{{ rolling_stock.address }}</td>
</tr>
<tr>
@@ -240,16 +262,12 @@
</tr>
<tr>
<th scope="row">Manufacturer</th>
<td>{{ rolling_stock.decoder.manufacturer|default_if_none:"" }}</td>
<td>{{ rolling_stock.decoder.manufacturer|default:"-" }}</td>
</tr>
<tr>
<th scope="row">Version</th>
<td>{{ rolling_stock.decoder.version }}</td>
</tr>
<tr>
<th scope="row">Interface</th>
<td>{{ rolling_stock.decoder.get_interface_display }}</td>
</tr>
<tr>
<th scope="row">Sound</th>
<td>{{ rolling_stock.decoder.sound | yesno:"Yes,No" }}</td>
@@ -257,44 +275,64 @@
</tbody>
</table>
</div>
<div class="tab-pane fade" id="nav-notes" role="tabpanel" aria-labelledby="nav-notes-tab">
{{ rolling_stock.notes | safe }}
</div>
<div class="tab-pane fade" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
<div class="tab-pane" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
{% if rolling_stock.document.count > 0 %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="3" scope="row">Documents</th>
</tr>
</thead>
<tbody>
<tbody class="table-group-divider">
{% for d in rolling_stock.document.all %}
<tr>
<td>{{ d.description }}</td>
<td><a href="{{ d.file.url }}">{{ d.filename }}</a></td>
<td class="w-33">{{ d.description }}</td>
<td><a href="{{ d.file.url }}" target="_blank">{{ d.filename }}</a></td>
<td class="text-end">{{ d.file.size | filesizeformat }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if rolling_stock.decoder.document.count > 0 %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="3" scope="row">Decoder documents</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for d in rolling_stock.decoder.document.all %}
<tr>
<td class="w-33">{{ d.description }}</td>
<td><a href="{{ d.file.url }}" target="_blank">{{ d.filename }}</a></td>
<td class="text-end">{{ d.file.size | filesizeformat }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
<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>
</tr>
</thead>
<tbody>
<tbody class="table-group-divider">
{% for j in rolling_stock_journal %}
<tr>
<th width="35%" scope="row">{{ j.date }}</th>
<th class="w-33" scope="row">{{ j.date }}</th>
<td>{{ j.log | safe }}</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="tab-pane" id="nav-notes" role="tabpanel" aria-labelledby="nav-notes-tab">
{{ rolling_stock.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:roster_rollingstock_change' rolling_stock.pk %}">Edit</a>{% endif %}

View File

@@ -0,0 +1,41 @@
{% 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">
{% 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>
</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 'roster_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 'roster_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,55 +1,11 @@
{% extends "base.html" %}
{% block header %}
<h1 class="fw-light">Scales</h1>
{% endblock %}
{% block cards %}
{% for s in scale %}
<div class="col">
<div class="card shadow-sm">
<div class="card-body">
<p class="card-text"><strong>{{ s }}</strong></p>
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Scale</th>
</tr>
</thead>
<tbody>
<tr>
<th width="35%" scope="row">Name</th>
<td>{{ s.scale }}</td>
</tr>
<tr>
<th width="35%" scope="row">Ratio</th>
<td>{{ s.ratio }}</td>
</tr>
<tr>
<th width="35%" scope="row">Gauge</th>
<td>{{ s.gauge }}</td>
</tr>
<tr>
<th width="35%" scope="row">Tracks</th>
<td>{{ s.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=s %}">Show all rolling stock</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_scale_change' s.pk %}">Edit</a>{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
{% endblock %}
{% extends "cards.html" %}
{% block pagination %}
{% if scale.has_other_pages %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<ul class="pagination justify-content-center mt-4 mb-0">
{% if scale.has_previous %}
{% if data.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'scale_pagination' page=scale.previous_page_number %}#rolling-stock" tabindex="-1">Previous</a>
<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">
@@ -57,21 +13,21 @@
</li>
{% endif %}
{% for i in page_range %}
{% if scale.number == i %}
{% if data.number == i %}
<li class="page-item active">
<span class="page-link">{{ i }}</span></span>
<span class="page-link">{{ i }}</span>
</li>
{% else %}
{% if i == scale.paginator.ELLIPSIS %}
{% 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 'scale_pagination' page=i %}#rolling-stock">{{ i }}</a></li>
<li class="page-item"><a class="page-link" href="{% url 'scales_pagination' page=i %}#main-content">{{ i }}</a></li>
{% endif %}
{% endif %}
{% endfor %}
{% if scale.has_next %}
{% if data.has_next %}
<li class="page-item">
<a class="page-link" href="{% url 'scale_pagination' page=scale.next_page_number %}#rolling-stock" tabindex="-1">Next</a>
<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">

View File

@@ -1,16 +1,12 @@
{% extends "base.html" %}
{% extends "cards.html" %}
{% block header %}
<h1 class="fw-light">{{ filter | default_if_none:"Search" | title }}: {{ search }}</h1>
<p class="lead text-muted">Results found: {{ matches }}</p>
{% endblock %}
{% block pagination %}
{% if rolling_stock.has_other_pages %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<ul class="pagination justify-content-center mt-4 mb-0">
{% if rolling_stock.has_previous %}
{% if data.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'filtered_pagination' _filter=filter search=search page=rolling_stock.previous_page_number %}#rolling-stock" tabindex="-1">Previous</a>
<a class="page-link" href="{% url 'search_pagination' search=encoded_search page=data.previous_page_number %}#main-content" tabindex="-1">Previous</a>
</li>
{% else %}
<li class="page-item disabled">
@@ -18,21 +14,21 @@
</li>
{% endif %}
{% for i in page_range %}
{% if rolling_stock.number == i %}
{% if data.number == i %}
<li class="page-item active">
<span class="page-link">{{ i }}</span></span>
<span class="page-link">{{ i }}</span>
</li>
{% else %}
{% if i == rolling_stock.paginator.ELLIPSIS %}
{% 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 'filtered_pagination' _filter=filter search=search page=i %}#rolling-stock">{{ i }}</a></li>
<li class="page-item"><a class="page-link" href="{% url 'search_pagination' search=encoded_search page=i %}#main-content">{{ i }}</a></li>
{% endif %}
{% endif %}
{% endfor %}
{% if rolling_stock.has_next %}
{% if data.has_next %}
<li class="page-item">
<a class="page-link" href="{% url 'filtered_pagination' _filter=filter search=search page=rolling_stock.next_page_number %}#rolling-stock" tabindex="-1">Next</a>
<a class="page-link" href="{% url 'search_pagination' search=encoded_search page=data.next_page_number %}#main-content" tabindex="-1">Next</a>
</li>
{% else %}
<li class="page-item disabled">

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

@@ -1,10 +1,16 @@
from django import template
from portal.views import Flatpage
from portal.models import Flatpage
from bookshelf.models import Book
register = template.Library()
@register.inclusion_tag('flatpage_menu.html')
def show_menu():
@register.inclusion_tag('bookshelf/bookshelf_menu.html')
def show_bookshelf_menu():
return {"bookshelf_menu": Book.objects.exists()}
@register.inclusion_tag('flatpages/flatpages_menu.html')
def show_flatpages_menu():
menu = Flatpage.objects.filter(published=True).order_by("name")
return {"menu": menu}
return {"flatpages_menu": menu}

View File

@@ -1,29 +1,31 @@
from django.urls import path
from portal.views import (
GetHome,
GetHomeFiltered,
GetData,
GetRoster,
GetRosterFiltered,
GetFlatpage,
GetRollingStock,
GetConsist,
Consists,
Companies,
Manufacturers,
Scales,
Types,
Books,
GetBook,
SearchRoster,
)
urlpatterns = [
path("", GetHome.as_view(), name="index"),
path("<int:page>", GetHome.as_view(), name="index_pagination"),
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(
"page/<str:flatpage>",
GetFlatpage.as_view(),
name="flatpage",
),
path(
"search",
GetHomeFiltered.as_view(http_method_names=["post"]),
name="search",
),
path("consists", Consists.as_view(), name="consists"),
path(
"consists/<int:page>", Consists.as_view(), name="consists_pagination"
@@ -34,22 +36,75 @@ urlpatterns = [
GetConsist.as_view(),
name="consist_pagination",
),
path("companies", Companies.as_view(), name="companies"),
path(
"companies",
Companies.as_view(template="companies.html"),
name="companies"
),
path(
"companies/<int:page>",
Companies.as_view(),
Companies.as_view(template="companies.html"),
name="companies_pagination",
),
path("scales", Scales.as_view(), name="scales"),
path("scales/<int:page>", Scales.as_view(), name="scales_pagination"),
path(
"manufacturers/<str:category>",
Manufacturers.as_view(template="manufacturers.html"),
name="manufacturers"
),
path(
"manufacturers/<str:category>/<int:page>",
Manufacturers.as_view(template="manufacturers.html"),
name="manufacturers_pagination",
),
path(
"scales",
Scales.as_view(template="scales.html"),
name="scales"
),
path(
"scales/<int:page>",
Scales.as_view(template="scales.html"),
name="scales_pagination"
),
path(
"types",
Types.as_view(template="types.html"),
name="types"
),
path(
"types/<int:page>",
Types.as_view(template="types.html"),
name="types_pagination"
),
path(
"bookshelf/books",
Books.as_view(template="bookshelf/books.html"),
name="books"
),
path(
"bookshelf/books/<int:page>",
Books.as_view(template="bookshelf/books.html"),
name="books_pagination"
),
path("bookshelf/book/<uuid:uuid>", GetBook.as_view(), name="book"),
path(
"search",
SearchRoster.as_view(http_method_names=["post"]),
name="search",
),
path(
"search/<str:search>/<int:page>",
SearchRoster.as_view(),
name="search_pagination",
),
path(
"<str:_filter>/<str:search>",
GetHomeFiltered.as_view(),
GetRosterFiltered.as_view(),
name="filtered",
),
path(
"<str:_filter>/<str:search>/<int:page>",
GetHomeFiltered.as_view(),
GetRosterFiltered.as_view(),
name="filtered_pagination",
),
path("<uuid:uuid>", GetRollingStock.as_view(), name="rolling_stock"),

View File

@@ -1,22 +1,30 @@
import base64
import operator
from functools import reduce
from urllib.parse import unquote
from django.views import View
from django.http import Http404
from django.http import Http404, HttpResponseBadRequest
from django.db.utils import OperationalError, ProgrammingError
from django.db.models import Q
from django.shortcuts import render
from django.shortcuts import render, get_object_or_404
from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator, PageNotAnInteger
from django.core.paginator import Paginator
from portal.utils import get_site_conf
from portal.models import Flatpage
from roster.models import RollingStock
from consist.models import Consist
from metadata.models import Company, Scale
from bookshelf.models import Book
from metadata.models import Company, Manufacturer, Scale, RollingStockType, Tag
def order_by_fields():
order_by = get_site_conf().items_ordering
try:
order_by = get_site_conf().items_ordering
except (OperationalError, ProgrammingError):
order_by = "type"
fields = [
"rolling_class__type",
"rolling_class__company",
@@ -32,28 +40,51 @@ def order_by_fields():
return (fields[2], fields[0], fields[1], fields[3])
class GetHome(View):
class GetData(View):
title = "Home"
template = "roster.html"
item_type = "rolling_stock"
queryset = RollingStock.objects.order_by(*order_by_fields())
def get(self, request, page=1):
site_conf = get_site_conf()
rolling_stock = RollingStock.objects.order_by(*order_by_fields())
paginator = Paginator(rolling_stock, site_conf.items_per_page)
rolling_stock = paginator.get_page(page)
data = []
for item in self.queryset:
data.append({
"type": self.item_type,
"item": item
})
paginator = Paginator(data, site_conf.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=2, on_ends=1
)
return render(
request,
"home.html",
{"rolling_stock": rolling_stock, "page_range": page_range},
self.template,
{
"title": self.title,
"type": self.item_type,
"data": data,
"matches": paginator.count,
"page_range": page_range,
},
)
class GetHomeFiltered(View):
class GetRoster(GetData):
title = "Rolling stock"
item_type = "rolling_stock"
queryset = RollingStock.objects.order_by(*order_by_fields())
class SearchRoster(View):
def run_search(self, request, search, _filter, page=1):
site_conf = get_site_conf()
if _filter == "search":
if _filter is None:
query = reduce(
operator.or_,
(
@@ -62,7 +93,7 @@ class GetHomeFiltered(View):
| Q(rolling_class__description__icontains=s)
| Q(rolling_class__type__type__icontains=s)
| Q(road_number__icontains=s)
| Q(sku=s)
| Q(item_number=s)
| Q(rolling_class__company__name__icontains=s)
| Q(rolling_class__company__country__icontains=s)
| Q(manufacturer__name__icontains=s)
@@ -72,63 +103,196 @@ class GetHomeFiltered(View):
for s in search.split()
),
)
elif _filter == "type":
query = Q(
Q(rolling_class__type__type__icontains=search)
| Q(rolling_class__type__category__icontains=search)
)
elif _filter == "company":
query = Q(
Q(rolling_class__company__name__icontains=search)
| Q(rolling_class__company__extended_name__icontains=search)
)
elif _filter == "manufacturer":
query = Q(
Q(manufacturer__name__icontains=search)
| Q(rolling_class__manufacturer__name__icontains=search)
)
elif _filter == "scale":
query = Q(scale__scale__iexact=search)
elif _filter == "tag":
query = Q(tags__slug__iexact=search)
query = Q(scale__scale__icontains=search)
else:
raise Http404
rolling_stock = RollingStock.objects.filter(query).order_by(
*order_by_fields()
)
matches = len(rolling_stock)
paginator = Paginator(rolling_stock, site_conf.items_per_page)
rolling_stock = paginator.get_page(page)
# FIXME duplicated code!
data = []
rolling_stock = (
RollingStock.objects.filter(query)
.distinct()
.order_by(*order_by_fields())
)
for item in rolling_stock:
data.append({
"type": "rolling_stock",
"item": item
})
if _filter is None:
consists = (
Consist.objects.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.filter(title__icontains=search)
.distinct()
)
for item in books:
data.append({
"type": "book",
"item": item
})
paginator = Paginator(data, site_conf.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=2, on_ends=1
)
return rolling_stock, matches, page_range
return data, paginator.count, page_range
def get(self, request, search, _filter="search", page=1):
rolling_stock, matches, page_range = self.run_search(
request, search, _filter, page
def split_search(self, search):
search = search.strip().split(":")
if not search:
raise Http404
elif len(search) == 1: # no filter
_filter = None
search = search[0].strip()
elif len(search) == 2: # filter: search
_filter = search[0].strip().lower()
search = search[1].strip()
else:
return HttpResponseBadRequest
return _filter, search
def get(self, request, search, page=1):
try:
encoded_search = search
search = base64.b64decode(search.encode()).decode()
except Exception:
encoded_search = base64.b64encode(
search.encode()).decode()
_filter, keyword = self.split_search(search)
data, matches, page_range = self.run_search(
request, keyword, _filter, page
)
return render(
request,
"search.html",
{
"title": "Search: \"{}\"".format(search),
"search": search,
"filter": _filter,
"encoded_search": encoded_search,
"data": data,
"matches": matches,
"rolling_stock": rolling_stock,
"page_range": page_range,
},
)
def post(self, request, _filter="search", page=1):
def post(self, request, page=1):
search = request.POST.get("search")
if not search:
return self.get(request, search, page)
class GetRosterFiltered(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)
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)
)
elif _filter == "scale":
title = get_object_or_404(Scale, slug__iexact=search)
query = Q(scale__slug__iexact=search)
elif _filter == "tag":
title = get_object_or_404(Tag, slug__iexact=search)
query = Q(tags__slug__iexact=search)
else:
raise Http404
rolling_stock, matches, page_range = self.run_search(
request, search, _filter, page
rolling_stock = (
RollingStock.objects.filter(query)
.distinct()
.order_by(*order_by_fields())
)
data = []
for item in rolling_stock:
data.append({
"type": "rolling_stock",
"item": item
})
if _filter == "tag":
consists = (
Consist.objects.filter(query)
.distinct()
)
for item in consists:
data.append({
"type": "consist",
"item": item
})
books = (
Book.objects.filter(query)
.distinct()
)
for item in books:
data.append({
"type": "book",
"item": item
})
paginator = Paginator(data, site_conf.items_per_page)
data = paginator.get_page(page)
page_range = paginator.get_elided_page_range(
data.number, on_each_side=2, on_ends=1
)
return data, title, paginator.count, page_range
def get(self, request, search, _filter, page=1):
data, title, matches, page_range = self.run_filter(
request, unquote(search), _filter, page
)
return render(
request,
"search.html",
"filter.html",
{
"title": "{0}: {1}".format(
_filter.capitalize(), title),
"search": search,
"filter": _filter,
"data": data,
"matches": matches,
"rolling_stock": rolling_stock,
"page_range": page_range,
},
)
@@ -162,8 +326,9 @@ class GetRollingStock(View):
return render(
request,
"page.html",
"rollingstock.html",
{
"title": rolling_stock,
"rolling_stock": rolling_stock,
"class_properties": class_properties,
"rolling_stock_properties": rolling_stock_properties,
@@ -172,22 +337,11 @@ class GetRollingStock(View):
)
class Consists(View):
def get(self, request, page=1):
site_conf = get_site_conf()
consist = Consist.objects.all()
paginator = Paginator(consist, site_conf.items_per_page)
consist = paginator.get_page(page)
page_range = paginator.get_elided_page_range(
consist.number, on_each_side=2, on_ends=1
)
return render(
request,
"consists.html",
{"consist": consist, "page_range": page_range},
)
class Consists(GetData):
def __init__(self):
self.title = "Consists"
self.item_type = "consist"
self.queryset = Consist.objects.all()
class GetConsist(View):
@@ -197,58 +351,86 @@ class GetConsist(View):
consist = Consist.objects.get(uuid=uuid)
except ObjectDoesNotExist:
raise Http404
rolling_stock = consist.consist_item.all()
data = [{
"type": "rolling_stock",
"item": RollingStock.objects.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, site_conf.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=2, on_ends=1
)
return render(
request,
"consist.html",
{
"title": consist,
"consist": consist,
"rolling_stock": rolling_stock,
"data": data,
"page_range": page_range,
},
)
class Companies(View):
def get(self, request, page=1):
site_conf = get_site_conf()
company = Company.objects.all()
class Manufacturers(GetData):
title = "Manufacturers"
item_type = "manufacturer"
queryset = None # Set via method get
paginator = Paginator(company, site_conf.items_per_page)
company = paginator.get_page(page)
page_range = paginator.get_elided_page_range(
company.number, on_each_side=2, on_ends=1
# overload get method to filter by category
def get(self, request, category, page=1):
if category not in ("real", "model"):
raise Http404
self.queryset = Manufacturer.objects.filter(category=category)
return super().get(request, page)
class Companies(GetData):
title = "Companies"
item_type = "company"
queryset = Company.objects.all()
class Scales(GetData):
title = "Scales"
item_type = "scale"
queryset = Scale.objects.all()
class Types(GetData):
title = "Types"
item_type = "rolling_stock_type"
queryset = RollingStockType.objects.all()
class Books(GetData):
title = "Books"
item_type = "book"
queryset = Book.objects.all()
class GetBook(View):
def get(self, request, uuid):
try:
book = Book.objects.get(uuid=uuid)
except ObjectDoesNotExist:
raise Http404
book_properties = (
book.property.all()
if request.user.is_authenticated
else book.property.filter(property__private=False)
)
return render(
request,
"companies.html",
{"company": company, "page_range": page_range},
)
class Scales(View):
def get(self, request, page=1):
site_conf = get_site_conf()
scale = Scale.objects.all()
paginator = Paginator(scale, site_conf.items_per_page)
scale = paginator.get_page(page)
page_range = paginator.get_elided_page_range(
scale.number, on_each_side=2, on_ends=1
)
return render(
request,
"scales.html",
{"scale": scale, "page_range": page_range},
"bookshelf/book.html",
{
"title": book,
"book_properties": book_properties,
"book": book,
},
)
@@ -263,6 +445,6 @@ class GetFlatpage(View):
return render(
request,
"flatpage.html",
{"flatpage": flatpage},
"flatpages/flatpage.html",
{"title": flatpage.name, "flatpage": flatpage},
)

View File

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

View File

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

64
ram/ram/models.py Normal file
View File

@@ -0,0 +1,64 @@
import os
from django.db import models
from django.utils.safestring import mark_safe
from ram.utils import DeduplicatedStorage, get_image_preview
class Document(models.Model):
description = models.CharField(max_length=128, blank=True)
file = models.FileField(
upload_to="files/",
storage=DeduplicatedStorage(),
null=True,
blank=True,
)
class Meta:
abstract = True
def __str__(self):
return "{0}".format(os.path.basename(self.file.name))
def filename(self):
return self.__str__()
def download(self):
return mark_safe(
'<a href="{0}" target="_blank">Link</a>'.format(self.file.url)
)
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
)
def image_thumbnail(self):
return get_image_preview(self.image.url)
image_thumbnail.short_description = "Preview"
def __str__(self):
return "{0}".format(os.path.basename(self.image.name))
class Meta:
abstract = True
ordering = ["order"]
class PropertyInstance(models.Model):
property = models.ForeignKey(
"metadata.Property", # To avoid circular dependencies
on_delete=models.CASCADE
)
value = models.CharField(max_length=256)
def __str__(self):
return self.property.name
class Meta:
abstract = True
verbose_name_plural = "Properties"

View File

@@ -55,6 +55,7 @@ INSTALLED_APPS = [
"metadata",
"roster",
"consist",
"bookshelf",
]
MIDDLEWARE = [
@@ -80,6 +81,7 @@ TEMPLATES = [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"portal.context_processors.default_card_image",
],
},
},
@@ -148,6 +150,10 @@ COUNTRIES_OVERRIDE = {
"ZZ": "Freelance",
}
# 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 = [
(1, "NEM651"),
(2, "NEM652"),
@@ -156,7 +162,12 @@ DECODER_INTERFACES = [
(5, "Next18/Next18S"),
]
MANUFACTURER_TYPES = [("model", "Model"), ("real", "Real")]
MANUFACTURER_TYPES = [
("model", "Model"),
("real", "Real"),
("accessory", "Accessory"),
("other", "Other")
]
ROLLING_STOCK_TYPES = [
("engine", "Engine"),

View File

@@ -28,6 +28,7 @@ urlpatterns = [
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)
if settings.DEBUG:

View File

@@ -1,8 +1,29 @@
import os
import hashlib
import subprocess
from django.utils.html import format_html
from django.utils.text import slugify as django_slugify
from django.core.files.storage import FileSystemStorage
class DeduplicatedStorage(FileSystemStorage):
"""
A derived FileSystemStorage class that compares already existing files
(with the same name) with new uploaded ones and stores new file only if
sha256 hash on is content is different
"""
def save(self, name, content, max_length=None):
if super().exists(name):
new = hashlib.sha256(content.read()).hexdigest()
with open(super().path(name), "rb") as file:
file_binary = file.read()
old = hashlib.sha256(file_binary).hexdigest()
if old == new:
return name
return super().save(name, content, max_length)
def git_suffix(fname):

View File

@@ -1,4 +1,6 @@
from django.contrib import admin
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
from roster.models import (
RollingClass,
RollingClassProperty,
@@ -35,7 +37,7 @@ class RollingStockDocInline(admin.TabularInline):
classes = ["collapse"]
class RollingStockImageInline(admin.TabularInline):
class RollingStockImageInline(SortableInlineAdminMixin, admin.TabularInline):
model = RollingStockImage
min_num = 0
extra = 0
@@ -66,7 +68,7 @@ class RollingStockDocumentAdmin(admin.ModelAdmin):
)
search_fields = (
"rolling_stock__rolling_class__identifier",
"rolling_stock__sku",
"rolling_stock__item_number",
"description",
"file",
)
@@ -87,13 +89,13 @@ class RollingJournalDocumentAdmin(admin.ModelAdmin):
search_fields = (
"rolling_stock__rolling_class__identifier",
"rolling_stock__road_number",
"rolling_stock__sku",
"rolling_stock__item_number",
"log",
)
@admin.register(RollingStock)
class RollingStockAdmin(admin.ModelAdmin):
class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
inlines = (
RollingStockPropertyInline,
RollingStockImageInline,
@@ -106,7 +108,7 @@ class RollingStockAdmin(admin.ModelAdmin):
"address",
"manufacturer",
"scale",
"sku",
"item_number",
"company",
"country",
)
@@ -123,8 +125,9 @@ class RollingStockAdmin(admin.ModelAdmin):
"manufacturer__name",
"road_number",
"address",
"sku",
"item_number",
)
save_as = True
fieldsets = (
(
@@ -135,7 +138,7 @@ class RollingStockAdmin(admin.ModelAdmin):
"road_number",
"scale",
"manufacturer",
"sku",
"item_number",
"era",
"production_year",
"purchase_date",
@@ -148,6 +151,7 @@ class RollingStockAdmin(admin.ModelAdmin):
"DCC",
{
"fields": (
"decoder_interface",
"decoder",
"address",
)

View File

@@ -0,0 +1,42 @@
# Generated by Django 4.1 on 2022-10-31 22:27
from django.db import migrations, models
def meta_to_roster(apps, schema_editor):
model = apps.get_model("roster", "RollingStock")
for row in model.objects.all():
if row.decoder:
decoder_interface = row.decoder.interface
row.__dict__["decoder_interface"] = decoder_interface
row.save(update_fields=["decoder_interface"])
class Migration(migrations.Migration):
dependencies = [
("roster", "0012_rollingstockjournal"),
]
operations = [
migrations.AddField(
model_name="rollingstock",
name="decoder_interface",
field=models.PositiveSmallIntegerField(
blank=True,
choices=[
(1, "NEM651"),
(2, "NEM652"),
(3, "PluX"),
(4, "21MTC"),
(5, "Next18/Next18S"),
],
null=True,
),
),
migrations.RunPython(
meta_to_roster,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 4.1.2 on 2022-11-27 00:10
from django.db import migrations, models
import ram.utils
class Migration(migrations.Migration):
dependencies = [
("roster", "0013_rollingstock_decoder_interface"),
]
operations = [
migrations.AlterField(
model_name="rollingstockdocument",
name="file",
field=models.FileField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage(),
upload_to="files/",
),
),
migrations.AlterField(
model_name="rollingstockimage",
name="image",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to="images/",
),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 4.1.3 on 2022-12-28 21:07
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("roster", "0014_alter_rollingstockdocument_file_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="rollingstockimage",
options={"ordering": ["-is_thumbnail"]},
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 4.1.3 on 2023-01-02 12:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("roster", "0015_alter_rollingstockimage_options"),
]
operations = [
migrations.AlterModelOptions(
name="rollingstockimage",
options={"ordering": ["order"]},
),
migrations.AddField(
model_name="rollingstockimage",
name="order",
field=models.PositiveIntegerField(default=0),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 4.1.3 on 2023-01-02 15:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("roster", "0016_alter_rollingstockimage_options_and_more"),
]
operations = [
migrations.RemoveField(
model_name="rollingstockimage",
name="is_thumbnail",
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 4.2.5 on 2023-10-01 19:32
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("roster", "0017_remove_rollingstockimage_is_thumbnail"),
]
operations = [
migrations.RenameField(
model_name="rollingstock",
old_name="sku",
new_name="item_number",
),
]

View File

@@ -1,16 +1,15 @@
import os
import re
from uuid import uuid4
from django.db import models
from django.urls import reverse
from django.conf import settings
from django.dispatch import receiver
from django.utils.safestring import mark_safe
from ckeditor_uploader.fields import RichTextUploadingField
from ram.models import Document, Image, PropertyInstance
from ram.utils import get_image_preview
from metadata.models import (
Property,
Scale,
Manufacturer,
Decoder,
@@ -19,11 +18,6 @@ from metadata.models import (
RollingStockType,
)
# class OverwriteMixin(FileSystemStorage):
# def get_available_name(self, name, max_length):
# self.delete(name)
# return name
class RollingClass(models.Model):
identifier = models.CharField(max_length=128, unique=False)
@@ -47,7 +41,7 @@ class RollingClass(models.Model):
return "{0} {1}".format(self.company, self.identifier)
class RollingClassProperty(models.Model):
class RollingClassProperty(PropertyInstance):
rolling_class = models.ForeignKey(
RollingClass,
on_delete=models.CASCADE,
@@ -56,14 +50,6 @@ class RollingClassProperty(models.Model):
related_name="property",
verbose_name="Class",
)
property = models.ForeignKey(Property, on_delete=models.CASCADE)
value = models.CharField(max_length=256)
def __str__(self):
return self.property.name
class Meta:
verbose_name_plural = "Properties"
class RollingStock(models.Model):
@@ -86,7 +72,10 @@ class RollingStock(models.Model):
limit_choices_to={"category": "model"},
)
scale = models.ForeignKey(Scale, on_delete=models.CASCADE)
sku = models.CharField(max_length=32, blank=True)
item_number = models.CharField(max_length=32, blank=True)
decoder_interface = models.PositiveSmallIntegerField(
choices=settings.DECODER_INTERFACES, null=True, blank=True
)
decoder = models.ForeignKey(
Decoder, on_delete=models.CASCADE, null=True, blank=True
)
@@ -128,52 +117,22 @@ def pre_save_running_number(sender, instance, *args, **kwargs):
pass
class RollingStockDocument(models.Model):
class RollingStockDocument(Document):
rolling_stock = models.ForeignKey(
RollingStock, on_delete=models.CASCADE, related_name="document"
)
description = models.CharField(max_length=128, blank=True)
file = models.FileField(upload_to="files/", null=True, blank=True)
class Meta(object):
unique_together = ("rolling_stock", "file")
def __str__(self):
return "{0}".format(os.path.basename(self.file.name))
def filename(self):
return self.__str__()
def download(self):
return mark_safe(
'<a href="{0}" target="_blank">Link</a>'.format(self.file.url)
)
class RollingStockImage(models.Model):
class RollingStockImage(Image):
rolling_stock = models.ForeignKey(
RollingStock, on_delete=models.CASCADE, related_name="image"
)
image = models.ImageField(upload_to="images/", null=True, blank=True)
is_thumbnail = models.BooleanField()
def image_thumbnail(self):
return get_image_preview(self.image.url)
image_thumbnail.short_description = "Preview"
def __str__(self):
return "{0}".format(os.path.basename(self.image.name))
def save(self, **kwargs):
if self.is_thumbnail:
RollingStockImage.objects.filter(
rolling_stock=self.rolling_stock
).update(is_thumbnail=False)
super().save(**kwargs)
class RollingStockProperty(models.Model):
class RollingStockProperty(PropertyInstance):
rolling_stock = models.ForeignKey(
RollingStock,
on_delete=models.CASCADE,
@@ -181,14 +140,6 @@ class RollingStockProperty(models.Model):
null=False,
blank=False,
)
property = models.ForeignKey(Property, on_delete=models.CASCADE)
value = models.CharField(max_length=256)
def __str__(self):
return self.property.name
class Meta:
verbose_name_plural = "Properties"
class RollingStockJournal(models.Model):

1
requirements-prod.txt Normal file
View File

@@ -0,0 +1 @@
gunicorn