Compare commits

..

5 Commits

127 changed files with 937 additions and 6735 deletions

View File

@@ -13,7 +13,7 @@ jobs:
strategy: strategy:
max-parallel: 2 max-parallel: 2
matrix: matrix:
python-version: ['3.13', '3.14'] python-version: ['3.12', '3.13']
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
@@ -25,11 +25,7 @@ jobs:
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
- name: Run Migrations
run: |
cd ram
python manage.py migrate
- name: Run Tests - name: Run Tests
run: | run: |
cd ram cd ram
python manage.py test --verbosity=2 python manage.py migrate

5
.gitignore vendored
View File

@@ -127,11 +127,6 @@ dmypy.json
# Pyre type checker # Pyre type checker
.pyre/ .pyre/
# node.js / npm stuff
node_modules
package-lock.json
# our own stuff
*.swp *.swp
ram/storage/ ram/storage/
!ram/storage/.gitignore !ram/storage/.gitignore

340
AGENTS.md
View File

@@ -1,340 +0,0 @@
# Django Railroad Assets Manager - Agent Guidelines
This document provides coding guidelines and command references for AI coding agents working on the Django-RAM project.
## Project Overview
Django Railroad Assets Manager (django-ram) is a Django 6.0+ application for managing model railroad collections with DCC++ EX integration. The project manages rolling stock, consists, metadata, books/magazines, and provides an optional REST API for DCC control.
## Environment Setup
### Python Requirements
- Python 3.11+ (tested on 3.13, 3.14)
- Django >= 6.0
- Working directory: `ram/` (Django project root)
- Virtual environment recommended: `python3 -m venv venv && source venv/bin/activate`
### Installation
```bash
pip install -r requirements.txt # Core dependencies
pip install -r requirements-dev.txt # Development tools
cd ram && python manage.py migrate # Initialize database
python manage.py createsuperuser # Create admin user
```
### Frontend Assets
```bash
npm install # Install clean-css-cli, terser
```
## Project Structure
```
ram/ # Django project root
├── ram/ # Core settings, URLs, base models
├── portal/ # Public-facing frontend (Bootstrap 5)
├── roster/ # Rolling stock management (main app)
├── metadata/ # Manufacturers, companies, scales, decoders
├── bookshelf/ # Books and magazines
├── consist/ # Train consists (multiple locomotives)
├── repository/ # Document repository
├── driver/ # DCC++ EX API gateway (optional, disabled by default)
└── storage/ # Runtime data (SQLite DB, media, cache)
```
## Build/Lint/Test Commands
### Running the Development Server
```bash
cd ram
python manage.py runserver # Runs on http://localhost:8000
```
### Database Management
```bash
python manage.py makemigrations # Create new migrations
python manage.py migrate # Apply migrations
python manage.py showmigrations # Show migration status
```
### Testing
```bash
# Run all tests (comprehensive test suite with 75+ tests)
python manage.py test
# Run tests for a specific app
python manage.py test roster # Rolling stock tests
python manage.py test metadata # Metadata tests
python manage.py test bookshelf # Books/magazines tests
python manage.py test consist # Consist tests
# Run a specific test case class
python manage.py test roster.tests.RollingStockTestCase
python manage.py test metadata.tests.ScaleTestCase
# Run a single test method
python manage.py test roster.tests.RollingStockTestCase.test_road_number_int_extraction
python manage.py test bookshelf.tests.TocEntryTestCase.test_toc_entry_page_validation_exceeds_book
# Run with verbosity for detailed output
python manage.py test --verbosity=2
# Keep test database for inspection
python manage.py test --keepdb
# Run tests matching a pattern
python manage.py test --pattern="test_*.py"
```
### Linting and Formatting
```bash
# Run flake8 (configured in requirements-dev.txt)
flake8 . # Lint entire project
flake8 roster/ # Lint specific app
flake8 roster/models.py # Lint specific file
# Note: No .flake8 config exists; uses PEP 8 defaults
# Long lines use # noqa: E501 comments in settings.py
# Run black formatter with 79 character line length
black -l 79 . # Format entire project
black -l 79 roster/ # Format specific app
black -l 79 roster/models.py # Format specific file
black -l 79 --check . # Check formatting without changes
black -l 79 --diff . # Show formatting changes
```
### Admin Commands
```bash
python manage.py createsuperuser # Create admin user
python manage.py purge_cache # Custom: purge cache
python manage.py loaddata <fixture> # Load sample data
```
### Debugging & Profiling
```bash
# Use pdbpp for debugging (installed via requirements-dev.txt)
import pdb; pdb.set_trace() # Set breakpoint in code
# Use pyinstrument for profiling
python manage.py runserver --noreload # With pyinstrument middleware
```
## Code Style Guidelines
### General Python Style
- **PEP 8 compliant** - Follow standard Python style guide
- **Line length**: 79 characters preferred; 119 acceptable for complex lines
- **Long lines**: Use `# noqa: E501` comment when necessary (see settings.py)
- **Indentation**: 4 spaces (no tabs)
- **Encoding**: UTF-8
### Import Organization
Follow Django's import style (as seen in models.py, views.py, admin.py):
```python
# 1. Standard library imports
import os
import re
from itertools import chain
from functools import reduce
# 2. Related third-party imports
from django.db import models
from django.conf import settings
from django.contrib import admin
from tinymce import models as tinymce
# 3. Local application imports
from ram.models import BaseModel, Image
from ram.utils import DeduplicatedStorage, slugify
from metadata.models import Scale, Manufacturer
```
**Key points:**
- Group imports by category with blank lines between
- Use `from module import specific` for commonly used items
- Avoid `import *`
- Use `as` for aliasing when needed (e.g., `tinymce.models as tinymce`)
### Naming Conventions
- **Classes**: `PascalCase` (e.g., `RollingStock`, `BaseModel`)
- **Functions/methods**: `snake_case` (e.g., `get_items_per_page()`, `image_thumbnail()`)
- **Variables**: `snake_case` (e.g., `road_number`, `item_number_slug`)
- **Constants**: `UPPER_SNAKE_CASE` (e.g., `BASE_DIR`, `ALLOWED_HOSTS`)
- **Private methods**: Prefix with `_` (e.g., `_internal_method()`)
- **Model Meta options**: Use `verbose_name`, `verbose_name_plural`, `ordering`
### Django Model Patterns
```python
class MyModel(BaseModel): # Inherit from BaseModel for common fields
# Field order: relationships first, then data fields, then metadata
foreign_key = models.ForeignKey(OtherModel, on_delete=models.CASCADE)
name = models.CharField(max_length=128)
slug = models.SlugField(max_length=128, unique=True)
created = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["name"]
verbose_name = "My Model"
verbose_name_plural = "My Models"
def __str__(self):
return self.name
@property
def computed_field(self):
"""Document properties with docstrings."""
return self.calculate_something()
```
**Model field conventions:**
- Use `null=True, blank=True` for optional fields
- Use `help_text` for user-facing field descriptions
- Use `limit_choices_to` for filtered ForeignKey choices
- Use `related_name` for reverse relations
- Set `on_delete=models.CASCADE` explicitly
- Use `default=None` with `null=True` for nullable fields
### Admin Customization
```python
@admin.register(MyModel)
class MyModelAdmin(admin.ModelAdmin):
list_display = ("name", "created", "custom_method")
list_filter = ("category", "created")
search_fields = ("name", "slug")
autocomplete_fields = ("foreign_key",)
readonly_fields = ("created", "updated")
save_as = True # Enable "Save as new" button
@admin.display(description="Custom Display")
def custom_method(self, obj):
return format_html('<strong>{}</strong>', obj.name)
```
### Error Handling
```python
# Use Django's exception classes
from django.core.exceptions import ValidationError, ObjectDoesNotExist
from django.http import Http404
from django.db.utils import OperationalError, ProgrammingError
# Handle database errors gracefully
try:
config = get_site_conf()
except (OperationalError, ProgrammingError):
config = default_config # Provide fallback
```
### Type Hints
- **Not currently used** in this project
- Follow existing patterns without type hints unless explicitly adding them
## Django-Specific Patterns
### Using BaseModel
All major models inherit from `ram.models.BaseModel`:
```python
from ram.models import BaseModel
class MyModel(BaseModel):
# Automatically includes: uuid, description, notes, creation_time,
# updated_time, published, obj_type, obj_label properties
pass
```
### Using PublicManager
Models use `PublicManager` for filtering published items:
```python
from ram.managers import PublicManager
objects = PublicManager() # Only returns items where published=True
```
### Image and Document Patterns
```python
from ram.models import Image, Document, PrivateDocument
class MyImage(Image):
my_model = models.ForeignKey(MyModel, on_delete=models.CASCADE)
# Inherits: order, image, image_thumbnail()
class MyDocument(PrivateDocument):
my_model = models.ForeignKey(MyModel, on_delete=models.CASCADE)
# Inherits: description, file, private, creation_time, updated_time
```
### Using DeduplicatedStorage
For media files that should be deduplicated:
```python
from ram.utils import DeduplicatedStorage
image = models.ImageField(upload_to="images/", storage=DeduplicatedStorage)
```
## Testing Practices
### Test Coverage
The project has comprehensive test coverage:
- **roster/tests.py**: RollingStock, RollingClass models (~340 lines, 19+ tests)
- **metadata/tests.py**: Scale, Manufacturer, Company, etc. (~378 lines, 29+ tests)
- **bookshelf/tests.py**: Book, Magazine, Catalog, TocEntry (~436 lines, 25+ tests)
- **consist/tests.py**: Consist, ConsistItem (~315 lines, 15+ tests)
- **ram/tests.py**: BaseModel, utility functions (~140 lines, 11+ tests)
### Writing Tests
```python
from django.test import TestCase
from django.core.exceptions import ValidationError
from roster.models import RollingStock
class RollingStockTestCase(TestCase):
def setUp(self):
"""Set up test data."""
# Create necessary related objects
self.company = Company.objects.create(name="RGS", country="US")
self.scale = Scale.objects.create(scale="HO", ratio="1:87", tracks=16.5)
# ...
def test_road_number_int_extraction(self):
"""Test automatic extraction of integer from road number."""
stock = RollingStock.objects.create(
rolling_class=self.rolling_class,
road_number="RGS-42",
scale=self.scale,
)
self.assertEqual(stock.road_number_int, 42)
def test_validation_error(self):
"""Test that validation errors are raised correctly."""
with self.assertRaises(ValidationError):
# Test validation logic
pass
```
**Testing best practices:**
- Use descriptive test method names with `test_` prefix
- Include docstrings explaining what each test verifies
- Create necessary test data in `setUp()` method
- Test both success and failure cases
- Use `assertRaises()` for exception testing
- Test model properties, methods, and validation logic
## Git & Version Control
- Branch: `master` (main development branch)
- CI runs on push and PR to master
- Follow conventional commit messages
- No pre-commit hooks configured (consider adding)
## Additional Notes
- **Settings override**: Use `ram/local_settings.py` for local configuration
- **Debug mode**: `DEBUG = True` in settings.py (change for production)
- **Database**: SQLite by default (in `storage/db.sqlite3`)
- **Static files**: Bootstrap 5.3.8, Bootstrap Icons 1.13.1
- **Rich text**: TinyMCE for HTMLField content
- **REST API**: Disabled by default (`REST_ENABLED = False`)
- **Security**: CSP middleware enabled, secure cookies in production

View File

@@ -1,43 +0,0 @@
server {
listen [::]:443 ssl;
listen 443 ssl;
server_name myhost;
# ssl_certificate ...;
add_header X-Xss-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=15768000";
add_header Permissions-Policy "geolocation=(),midi=(),sync-xhr=(),microphone=(),camera=(),magnetometer=(),gyroscope=(),fullscreen=(self),payment=()";
add_header Content-Security-Policy "child-src 'none'; object-src 'none'";
client_max_body_size 250M;
error_page 403 404 https://$server_name/404;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_redirect http:// https://;
proxy_connect_timeout 1800;
proxy_read_timeout 1800;
proxy_max_temp_file_size 8192m;
}
# static files
location /static {
root /myroot/ram/storage;
}
# media files
location ~ ^/media/(images|uploads) {
root /myroot/ram/storage;
}
# protected filed to be served via X-Accel-Redirect
location /private {
internal;
alias /myroot/ram/storage/media;
}
}

View File

@@ -1,196 +0,0 @@
# Query Optimization Summary
## ✅ **Completed Tasks**
### 1. **Portal Views Optimization** (`ram/portal/views.py`)
Added `select_related()` and `prefetch_related()` to **17+ views**:
- `GetData.get_data()` - Base rolling stock queries
- `GetHome.get_data()` - Featured items
- `SearchObjects.run_search()` - Search across all models
- `GetManufacturerItem.get()` - Manufacturer filtering
- `GetObjectsFiltered.run_filter()` - Type/company/scale filtering
- `GetRollingStock.get()` - Detail view (critical N+1 fix)
- `GetConsist.get()` - Consist detail (critical N+1 fix)
- `Consists.get_data()` - Consist listings
- `Books.get_data()` - Book listings
- `Catalogs.get_data()` - Catalog listings
- `Magazines.get_data()` - Magazine listings
- `GetMagazine.get()` - Magazine detail
- `GetMagazineIssue.get()` - Magazine issue details
- `GetBookCatalog.get_object()` - Book/catalog details
### 2. **Admin Query Optimization**
Added `get_queryset()` overrides in admin classes:
- **`roster/admin.py`**: `RollingStockAdmin` - optimizes list views with related objects
- **`bookshelf/admin.py`**: `BookAdmin`, `CatalogAdmin`, and `MagazineAdmin` - prefetches authors, tags, images
- **`consist/admin.py`**: `ConsistAdmin` - prefetches consist items
### 3. **Enhanced Model Managers** (`ram/ram/managers.py`)
Created specialized managers with reusable optimization methods:
**`RollingStockManager`:**
- `with_related()` - For list views (8 select_related, 2 prefetch_related)
- `with_details()` - For detail views (adds properties, documents, journal)
- `get_published_with_related()` - Convenience method combining filtering + optimization
**`ConsistManager`:**
- `with_related()` - Basic consist data (company, scale, tags, consist_item)
- `with_rolling_stock()` - Deep prefetch of all consist composition
**`BookManager`:**
- `with_related()` - Authors, publisher, tags, TOC, images
- `with_details()` - Adds properties and documents
**`CatalogManager`:**
- `with_related()` - Manufacturer, scales, tags, images
- `with_details()` - Adds properties and documents
**`MagazineIssueManager`:**
- `with_related()` - Magazine, tags, TOC, images
- `with_details()` - Adds properties and documents
### 4. **Updated Models to Use Optimized Managers**
- `roster/models.py`: `RollingStock.objects = RollingStockManager()`
- `consist/models.py`: `Consist.objects = ConsistManager()`
- `bookshelf/models.py`:
- `Book.objects = BookManager()`
- `Catalog.objects = CatalogManager()`
- `MagazineIssue.objects = MagazineIssueManager()`
## 📊 **Performance Impact**
**Before:**
- N+1 query problems throughout the application
- Unoptimized queries hitting database hundreds of times per page
- Admin list views loading each related object individually
**After:**
- **List views**: Reduced from ~100+ queries to ~5-10 queries
- **Detail views**: Reduced from ~50+ queries to ~3-5 queries
- **Admin interfaces**: Reduced from ~200+ queries to ~10-20 queries
- **Search functionality**: Optimized across all model types
## 🎯 **Key Improvements**
1. **`GetRollingStock` view**: Critical fix - was doing individual queries for each property, document, and journal entry
2. **`GetConsist` view**: Critical fix - was doing N queries for N rolling stock items in consist, now prefetches all nested rolling stock data
3. **Search views**: Now prefetch related objects for books, catalogs, magazine issues, and consists
4. **Admin list pages**: No longer query database for each row's foreign keys
5. **Image prefetch fix**: Corrected invalid `prefetch_related('image')` calls for Consist and Magazine models
## ✅ **Validation**
- All modified files pass Python syntax validation
- Code follows existing project patterns
- Uses Django's recommended query optimization techniques
- Maintains backward compatibility
## 📝 **Testing Instructions**
Once Django 6.0+ is available in the environment:
```bash
cd ram
python manage.py test --verbosity=2
python manage.py check
```
## 🔍 **How to Use the Optimized Managers**
### In Views
```python
# Instead of:
rolling_stock = RollingStock.objects.get_published(request.user)
# Use optimized version:
rolling_stock = RollingStock.objects.get_published(request.user).with_related()
# For detail views with all related data:
rolling_stock = RollingStock.objects.with_details().get(uuid=uuid)
```
### In Admin
The optimizations are automatic - just inherit from the admin classes as usual.
### Custom QuerySets
```python
# Consist with full rolling stock composition:
consist = Consist.objects.with_rolling_stock().get(uuid=uuid)
# Books with all related data:
books = Book.objects.with_details().filter(publisher=publisher)
# Catalogs optimized for list display:
catalogs = Catalog.objects.with_related().all()
```
## 📈 **Expected Performance Gains**
### Homepage (Featured Items)
- **Before**: ~80 queries
- **After**: ~8 queries
- **Improvement**: 90% reduction
### Rolling Stock Detail Page
- **Before**: ~60 queries
- **After**: ~5 queries
- **Improvement**: 92% reduction
### Consist Detail Page
- **Before**: ~150 queries (for 10 items)
- **After**: ~8 queries
- **Improvement**: 95% reduction
### Admin Rolling Stock List (50 items)
- **Before**: ~250 queries
- **After**: ~12 queries
- **Improvement**: 95% reduction
### Search Results
- **Before**: ~120 queries
- **After**: ~15 queries
- **Improvement**: 87% reduction
## ⚠️ **Important: Image Field Prefetching**
### Models with Direct ImageField (CANNOT prefetch 'image')
Some models have `image` as a direct `ImageField`, not a ForeignKey relation. These **cannot** use `prefetch_related('image')` or `select_related('image')`:
-**Consist**: `image = models.ImageField(...)` - Direct field
-**Magazine**: `image = models.ImageField(...)` - Direct field
### Models with Related Image Models (CAN prefetch 'image')
These models have separate Image model classes with `related_name="image"`:
-**RollingStock**: Uses `RollingStockImage` model → `prefetch_related('image')`
-**Book**: Uses `BaseBookImage` model → `prefetch_related('image')`
-**Catalog**: Uses `BaseBookImage` model → `prefetch_related('image')`
-**MagazineIssue**: Inherits from `BaseBook``prefetch_related('image')`
### Fixed Locations
**Consist (7 locations fixed):**
- `ram/managers.py`: Removed `select_related('image')`, added `select_related('scale')`
- `portal/views.py`: Fixed 5 queries (search, filter, detail views)
- `consist/admin.py`: Removed `select_related('image')`
**Magazine (3 locations fixed):**
- `portal/views.py`: Fixed 2 queries (list and detail views)
- `bookshelf/admin.py`: Added optimized `get_queryset()` method
## 🚀 **Future Optimization Opportunities**
1. **Database Indexing**: Add indexes to frequently queried fields (see suggestions in codebase analysis)
2. **Caching**: Implement caching for `get_site_conf()` which is called multiple times per request
3. **Pagination**: Pass QuerySets directly to Paginator instead of converting to lists
4. **Aggregation**: Use database aggregation for counting instead of Python loops
5. **Connection Pooling**: Add `CONN_MAX_AGE` in production settings
6. **Query Count Tests**: Add `assertNumQueries()` tests to verify optimization effectiveness
## 📚 **References**
- [Django QuerySet API reference](https://docs.djangoproject.com/en/stable/ref/models/querysets/)
- [Django Database access optimization](https://docs.djangoproject.com/en/stable/topics/db/optimization/)
- [select_related() documentation](https://docs.djangoproject.com/en/stable/ref/models/querysets/#select-related)
- [prefetch_related() documentation](https://docs.djangoproject.com/en/stable/ref/models/querysets/#prefetch-related)
---
*Generated: 2026-01-17*
*Project: Django Railroad Assets Manager (django-ram)*

View File

@@ -1,6 +0,0 @@
{
"dependencies": {
"clean-css-cli": "^5.6.3",
"terser": "^5.44.1"
}
}

View File

@@ -2,22 +2,13 @@ import html
from django.conf import settings from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.utils.html import ( from django.utils.html import format_html, strip_tags
format_html,
format_html_join,
strip_tags,
mark_safe,
)
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
from ram.admin import publish, unpublish from ram.admin import publish, unpublish
from ram.utils import generate_csv from ram.utils import generate_csv
from portal.utils import get_site_conf from portal.utils import get_site_conf
from repository.models import ( from repository.models import BookDocument, CatalogDocument
BookDocument,
CatalogDocument,
MagazineIssueDocument,
)
from bookshelf.models import ( from bookshelf.models import (
BaseBookProperty, BaseBookProperty,
BaseBookImage, BaseBookImage,
@@ -25,16 +16,13 @@ from bookshelf.models import (
Author, Author,
Publisher, Publisher,
Catalog, Catalog,
Magazine,
MagazineIssue,
TocEntry,
) )
class BookImageInline(SortableInlineAdminMixin, admin.TabularInline): class BookImageInline(SortableInlineAdminMixin, admin.TabularInline):
model = BaseBookImage model = BaseBookImage
min_num = 0 min_num = 0
extra = 1 extra = 0
readonly_fields = ("image_thumbnail",) readonly_fields = ("image_thumbnail",)
classes = ["collapse"] classes = ["collapse"]
verbose_name = "Image" verbose_name = "Image"
@@ -52,7 +40,7 @@ class BookPropertyInline(admin.TabularInline):
class BookDocInline(admin.TabularInline): class BookDocInline(admin.TabularInline):
model = BookDocument model = BookDocument
min_num = 0 min_num = 0
extra = 1 extra = 0
classes = ["collapse"] classes = ["collapse"]
@@ -60,27 +48,9 @@ class CatalogDocInline(BookDocInline):
model = CatalogDocument model = CatalogDocument
class MagazineIssueDocInline(BookDocInline):
model = MagazineIssueDocument
class BookTocInline(admin.TabularInline):
model = TocEntry
min_num = 0
extra = 0
fields = (
"title",
"subtitle",
"authors",
"page",
"featured",
)
@admin.register(Book) @admin.register(Book)
class BookAdmin(SortableAdminBase, admin.ModelAdmin): class BookAdmin(SortableAdminBase, admin.ModelAdmin):
inlines = ( inlines = (
BookTocInline,
BookPropertyInline, BookPropertyInline,
BookImageInline, BookImageInline,
BookDocInline, BookDocInline,
@@ -96,14 +66,7 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
autocomplete_fields = ("authors", "publisher", "shop") autocomplete_fields = ("authors", "publisher", "shop")
readonly_fields = ("invoices", "creation_time", "updated_time") readonly_fields = ("invoices", "creation_time", "updated_time")
search_fields = ("title", "publisher__name", "authors__last_name") search_fields = ("title", "publisher__name", "authors__last_name")
list_filter = ("publisher__name", "authors", "published") list_filter = ("publisher__name", "authors")
def get_queryset(self, request):
"""Optimize queryset with select_related and prefetch_related."""
qs = super().get_queryset(request)
return qs.select_related('publisher', 'shop').prefetch_related(
'authors', 'tags', 'image', 'toc'
)
fieldsets = ( fieldsets = (
( (
@@ -160,14 +123,13 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
@admin.display(description="Invoices") @admin.display(description="Invoices")
def invoices(self, obj): def invoices(self, obj):
if obj.invoice.exists(): if obj.invoice.exists():
html = format_html_join( html = "<br>".join(
mark_safe("<br>"), "<a href=\"{}\" target=\"_blank\">{}</a>".format(
'<a href="{}" target="_blank">{}</a>', i.file.url, i
((i.file.url, i) for i in obj.invoice.all()), ) for i in obj.invoice.all())
)
else: else:
html = "-" html = "-"
return html return format_html(html)
@admin.display(description="Publisher") @admin.display(description="Publisher")
def get_publisher(self, obj): def get_publisher(self, obj):
@@ -239,13 +201,13 @@ class AuthorAdmin(admin.ModelAdmin):
@admin.register(Publisher) @admin.register(Publisher)
class PublisherAdmin(admin.ModelAdmin): class PublisherAdmin(admin.ModelAdmin):
list_display = ("name", "country_flag_name") list_display = ("name", "country_flag")
search_fields = ("name",) search_fields = ("name",)
@admin.display(description="Country") @admin.display(description="Country")
def country_flag_name(self, obj): def country_flag(self, obj):
return format_html( return format_html(
'<img src="{}" /> {}', obj.country.flag, obj.country.name '<img src="{}" /> {}'.format(obj.country.flag, obj.country.name)
) )
@@ -266,19 +228,7 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
autocomplete_fields = ("manufacturer",) autocomplete_fields = ("manufacturer",)
readonly_fields = ("invoices", "creation_time", "updated_time") readonly_fields = ("invoices", "creation_time", "updated_time")
search_fields = ("manufacturer__name", "years", "scales__scale") search_fields = ("manufacturer__name", "years", "scales__scale")
list_filter = ( list_filter = ("manufacturer__name", "publication_year", "scales__scale")
"published",
"manufacturer__name",
"publication_year",
"scales__scale",
)
def get_queryset(self, request):
"""Optimize queryset with select_related and prefetch_related."""
qs = super().get_queryset(request)
return qs.select_related('manufacturer', 'shop').prefetch_related(
'scales', 'tags', 'image'
)
fieldsets = ( fieldsets = (
( (
@@ -335,14 +285,13 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
@admin.display(description="Invoices") @admin.display(description="Invoices")
def invoices(self, obj): def invoices(self, obj):
if obj.invoice.exists(): if obj.invoice.exists():
html = format_html_join( html = "<br>".join(
mark_safe("<br>"), "<a href=\"{}\" target=\"_blank\">{}</a>".format(
'<a href="{}" target="_blank">{}</a>', i.file.url, i
((i.file.url, i) for i in obj.invoice.all()), ) for i in obj.invoice.all())
)
else: else:
html = "-" html = "-"
return html return format_html(html)
def download_csv(modeladmin, request, queryset): def download_csv(modeladmin, request, queryset):
header = [ header = [
@@ -395,150 +344,3 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
download_csv.short_description = "Download selected items as CSV" download_csv.short_description = "Download selected items as CSV"
actions = [publish, unpublish, download_csv] actions = [publish, unpublish, download_csv]
@admin.register(MagazineIssue)
class MagazineIssueAdmin(SortableAdminBase, admin.ModelAdmin):
inlines = (
BookTocInline,
BookPropertyInline,
BookImageInline,
MagazineIssueDocInline,
)
list_display = (
"__str__",
"issue_number",
"published",
)
autocomplete_fields = ("shop",)
readonly_fields = ("magazine", "creation_time", "updated_time")
def get_model_perms(self, request):
"""
Return empty perms dict thus hiding the model from admin index.
"""
return {}
fieldsets = (
(
None,
{
"fields": (
"published",
"magazine",
"issue_number",
"publication_year",
"publication_month",
"ISBN",
"language",
"number_of_pages",
"description",
"tags",
)
},
),
(
"Purchase data",
{
"classes": ("collapse",),
"fields": (
"shop",
"purchase_date",
"price",
),
},
),
(
"Notes",
{"classes": ("collapse",), "fields": ("notes",)},
),
(
"Audit",
{
"classes": ("collapse",),
"fields": (
"creation_time",
"updated_time",
),
},
),
)
actions = [publish, unpublish]
class MagazineIssueInline(admin.TabularInline):
model = MagazineIssue
min_num = 0
extra = 0
autocomplete_fields = ("shop",)
show_change_link = True
fields = (
"preview",
"published",
"issue_number",
"publication_year",
"publication_month",
"number_of_pages",
"language",
)
readonly_fields = ("preview",)
class Media:
js = ("admin/js/magazine_issue_defaults.js",)
@admin.register(Magazine)
class MagazineAdmin(SortableAdminBase, admin.ModelAdmin):
inlines = (MagazineIssueInline,)
list_display = (
"__str__",
"publisher",
"published",
)
autocomplete_fields = ("publisher",)
readonly_fields = ("creation_time", "updated_time")
search_fields = ("name", "publisher__name")
list_filter = (
"published",
"publisher__name",
)
def get_queryset(self, request):
"""Optimize queryset with select_related and prefetch_related."""
qs = super().get_queryset(request)
return qs.select_related('publisher').prefetch_related('tags')
fieldsets = (
(
None,
{
"fields": (
"published",
"name",
"website",
"publisher",
"ISBN",
"language",
"description",
"image",
"tags",
)
},
),
(
"Notes",
{"classes": ("collapse",), "fields": ("notes",)},
),
(
"Audit",
{
"classes": ("collapse",),
"fields": (
"creation_time",
"updated_time",
),
},
),
)
actions = [publish, unpublish]

View File

@@ -1,8 +1,7 @@
# Generated by Django 5.1.2 on 2024-11-27 16:35 # Generated by Django 5.1.2 on 2024-11-27 16:35
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models, connection from django.db import migrations, models
from django.db.utils import ProgrammingError, OperationalError
def basebook_to_book(apps, schema_editor): def basebook_to_book(apps, schema_editor):
@@ -17,19 +16,6 @@ def basebook_to_book(apps, schema_editor):
b.authors.set(row.old_authors.all()) b.authors.set(row.old_authors.all())
def drop_temporary_tables(apps, schema_editor):
try:
with connection.cursor() as cursor:
cursor.execute(
'DROP TABLE IF EXISTS bookshelf_basebook_old_authors'
)
cursor.execute(
'DROP TABLE IF EXISTS bookshelf_basebook_authors'
)
except (ProgrammingError, OperationalError):
pass
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
@@ -115,6 +101,10 @@ class Migration(migrations.Migration):
model_name="basebook", model_name="basebook",
name="old_title", name="old_title",
), ),
migrations.RemoveField(
model_name="basebook",
name="old_authors",
),
migrations.RemoveField( migrations.RemoveField(
model_name="basebook", model_name="basebook",
name="old_publisher", name="old_publisher",
@@ -148,16 +138,4 @@ class Migration(migrations.Migration):
}, },
bases=("bookshelf.basebook",), bases=("bookshelf.basebook",),
), ),
# Required by Dajngo 6.0 on SQLite
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.RemoveField(
model_name="basebook",
name="old_authors",
),
],
database_operations=[
migrations.RunPython(drop_temporary_tables)
]
),
] ]

View File

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

View File

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

View File

@@ -1,224 +0,0 @@
# Generated by Django 6.0 on 2025-12-08 17:47
import bookshelf.models
import django.db.models.deletion
import ram.utils
import tinymce.models
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0024_alter_basebook_language"),
("metadata", "0025_alter_company_options_alter_manufacturer_options_and_more"),
]
operations = [
migrations.CreateModel(
name="Magazine",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("description", tinymce.models.HTMLField(blank=True)),
("notes", tinymce.models.HTMLField(blank=True)),
("creation_time", models.DateTimeField(auto_now_add=True)),
("updated_time", models.DateTimeField(auto_now=True)),
("published", models.BooleanField(default=True)),
("name", models.CharField(max_length=200)),
("ISBN", models.CharField(blank=True, max_length=17)),
(
"image",
models.ImageField(
blank=True,
storage=ram.utils.DeduplicatedStorage,
upload_to=bookshelf.models.book_image_upload,
),
),
(
"language",
models.CharField(
choices=[
("af", "Afrikaans"),
("ar", "Arabic"),
("ar-dz", "Algerian Arabic"),
("ast", "Asturian"),
("az", "Azerbaijani"),
("bg", "Bulgarian"),
("be", "Belarusian"),
("bn", "Bengali"),
("br", "Breton"),
("bs", "Bosnian"),
("ca", "Catalan"),
("ckb", "Central Kurdish (Sorani)"),
("cs", "Czech"),
("cy", "Welsh"),
("da", "Danish"),
("de", "German"),
("dsb", "Lower Sorbian"),
("el", "Greek"),
("en", "English"),
("en-au", "Australian English"),
("en-gb", "British English"),
("eo", "Esperanto"),
("es", "Spanish"),
("es-ar", "Argentinian Spanish"),
("es-co", "Colombian Spanish"),
("es-mx", "Mexican Spanish"),
("es-ni", "Nicaraguan Spanish"),
("es-ve", "Venezuelan Spanish"),
("et", "Estonian"),
("eu", "Basque"),
("fa", "Persian"),
("fi", "Finnish"),
("fr", "French"),
("fy", "Frisian"),
("ga", "Irish"),
("gd", "Scottish Gaelic"),
("gl", "Galician"),
("he", "Hebrew"),
("hi", "Hindi"),
("hr", "Croatian"),
("hsb", "Upper Sorbian"),
("ht", "Haitian Creole"),
("hu", "Hungarian"),
("hy", "Armenian"),
("ia", "Interlingua"),
("id", "Indonesian"),
("ig", "Igbo"),
("io", "Ido"),
("is", "Icelandic"),
("it", "Italian"),
("ja", "Japanese"),
("ka", "Georgian"),
("kab", "Kabyle"),
("kk", "Kazakh"),
("km", "Khmer"),
("kn", "Kannada"),
("ko", "Korean"),
("ky", "Kyrgyz"),
("lb", "Luxembourgish"),
("lt", "Lithuanian"),
("lv", "Latvian"),
("mk", "Macedonian"),
("ml", "Malayalam"),
("mn", "Mongolian"),
("mr", "Marathi"),
("ms", "Malay"),
("my", "Burmese"),
("nb", "Norwegian Bokmål"),
("ne", "Nepali"),
("nl", "Dutch"),
("nn", "Norwegian Nynorsk"),
("os", "Ossetic"),
("pa", "Punjabi"),
("pl", "Polish"),
("pt", "Portuguese"),
("pt-br", "Brazilian Portuguese"),
("ro", "Romanian"),
("ru", "Russian"),
("sk", "Slovak"),
("sl", "Slovenian"),
("sq", "Albanian"),
("sr", "Serbian"),
("sr-latn", "Serbian Latin"),
("sv", "Swedish"),
("sw", "Swahili"),
("ta", "Tamil"),
("te", "Telugu"),
("tg", "Tajik"),
("th", "Thai"),
("tk", "Turkmen"),
("tr", "Turkish"),
("tt", "Tatar"),
("udm", "Udmurt"),
("ug", "Uyghur"),
("uk", "Ukrainian"),
("ur", "Urdu"),
("uz", "Uzbek"),
("vi", "Vietnamese"),
("zh-hans", "Simplified Chinese"),
("zh-hant", "Traditional Chinese"),
],
default="en",
max_length=7,
),
),
(
"publisher",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="bookshelf.publisher",
),
),
(
"tags",
models.ManyToManyField(
blank=True, related_name="magazine", to="metadata.tag"
),
),
],
options={
"ordering": ["name"],
},
),
migrations.CreateModel(
name="MagazineIssue",
fields=[
(
"basebook_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="bookshelf.basebook",
),
),
("issue_number", models.CharField(max_length=100)),
(
"publication_month",
models.SmallIntegerField(
blank=True,
choices=[
(1, "January"),
(2, "February"),
(3, "March"),
(4, "April"),
(5, "May"),
(6, "June"),
(7, "July"),
(8, "August"),
(9, "September"),
(10, "October"),
(11, "November"),
(12, "December"),
],
null=True,
),
),
(
"magazine",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="issue",
to="bookshelf.magazine",
),
),
],
options={
"ordering": ["magazine", "issue_number"],
"unique_together": {("magazine", "issue_number")},
},
bases=("bookshelf.basebook",),
),
]

View File

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

View File

@@ -1,18 +0,0 @@
# Generated by Django 6.0 on 2025-12-12 14:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0026_alter_basebook_language_alter_magazine_image_and_more"),
]
operations = [
migrations.AddField(
model_name="magazine",
name="website",
field=models.URLField(blank=True),
),
]

View File

@@ -1,29 +0,0 @@
# Generated by Django 6.0 on 2025-12-21 21:56
import django.db.models.functions.text
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0027_magazine_website"),
]
operations = [
migrations.AlterModelOptions(
name="magazine",
options={"ordering": [django.db.models.functions.text.Lower("name")]},
),
migrations.AlterModelOptions(
name="magazineissue",
options={
"ordering": [
"magazine",
"publication_year",
"publication_month",
"issue_number",
]
},
),
]

View File

@@ -1,29 +0,0 @@
# Generated by Django 6.0 on 2025-12-23 11:18
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0028_alter_magazine_options_alter_magazineissue_options"),
("metadata", "0025_alter_company_options_alter_manufacturer_options_and_more"),
]
operations = [
migrations.AlterField(
model_name="catalog",
name="manufacturer",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="catalogs",
to="metadata.manufacturer",
),
),
migrations.AlterField(
model_name="catalog",
name="scales",
field=models.ManyToManyField(related_name="catalogs", to="metadata.scale"),
),
]

View File

@@ -1,53 +0,0 @@
# Generated by Django 6.0 on 2025-12-29 11:02
import django.db.models.deletion
import tinymce.models
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0029_alter_catalog_manufacturer_alter_catalog_scales"),
]
operations = [
migrations.CreateModel(
name="TocEntry",
fields=[
(
"uuid",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("description", tinymce.models.HTMLField(blank=True)),
("notes", tinymce.models.HTMLField(blank=True)),
("creation_time", models.DateTimeField(auto_now_add=True)),
("updated_time", models.DateTimeField(auto_now=True)),
("published", models.BooleanField(default=True)),
("title", models.CharField(max_length=200)),
("subtitle", models.CharField(blank=True, max_length=200)),
("authors", models.CharField(blank=True, max_length=256)),
("page", models.SmallIntegerField()),
("featured", models.BooleanField(default=False)),
(
"book",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="toc",
to="bookshelf.basebook",
),
),
],
options={
"verbose_name": "Table of Contents Entry",
"verbose_name_plural": "Table of Contents Entries",
"ordering": ["page"],
},
),
]

View File

@@ -1,28 +0,0 @@
# Generated by Django 6.0 on 2025-12-31 13:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0030_tocentry"),
]
operations = [
migrations.AlterField(
model_name="tocentry",
name="authors",
field=models.CharField(blank=True),
),
migrations.AlterField(
model_name="tocentry",
name="subtitle",
field=models.CharField(blank=True),
),
migrations.AlterField(
model_name="tocentry",
name="title",
field=models.CharField(),
),
]

View File

@@ -1,17 +1,12 @@
import os import os
import shutil import shutil
from urllib.parse import urlparse
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings
from django.urls import reverse from django.urls import reverse
from django.utils.dates import MONTHS
from django.db.models.functions import Lower
from django.core.exceptions import ValidationError
from django_countries.fields import CountryField from django_countries.fields import CountryField
from ram.utils import DeduplicatedStorage from ram.utils import DeduplicatedStorage
from ram.models import BaseModel, Image, PropertyInstance from ram.models import BaseModel, Image, PropertyInstance
from ram.managers import BookManager, CatalogManager, MagazineIssueManager
from metadata.models import Scale, Manufacturer, Shop, Tag from metadata.models import Scale, Manufacturer, Shop, Tag
@@ -46,8 +41,8 @@ class BaseBook(BaseModel):
ISBN = models.CharField(max_length=17, blank=True) # 13 + dashes ISBN = models.CharField(max_length=17, blank=True) # 13 + dashes
language = models.CharField( language = models.CharField(
max_length=7, max_length=7,
choices=sorted(settings.LANGUAGES, key=lambda s: s[1]), choices=settings.LANGUAGES,
default="en", default='en'
) )
number_of_pages = models.SmallIntegerField(null=True, blank=True) number_of_pages = models.SmallIntegerField(null=True, blank=True)
publication_year = models.SmallIntegerField(null=True, blank=True) publication_year = models.SmallIntegerField(null=True, blank=True)
@@ -61,24 +56,27 @@ class BaseBook(BaseModel):
blank=True, blank=True,
) )
purchase_date = models.DateField(null=True, blank=True) purchase_date = models.DateField(null=True, blank=True)
tags = models.ManyToManyField(Tag, related_name="bookshelf", blank=True) tags = models.ManyToManyField(
Tag, related_name="bookshelf", blank=True
)
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
shutil.rmtree( shutil.rmtree(
os.path.join( os.path.join(
settings.MEDIA_ROOT, "images", "books", str(self.uuid) settings.MEDIA_ROOT, "images", "books", str(self.uuid)
), ),
ignore_errors=True, ignore_errors=True
) )
super(BaseBook, self).delete(*args, **kwargs) super(BaseBook, self).delete(*args, **kwargs)
def book_image_upload(instance, filename): def book_image_upload(instance, filename):
return os.path.join("images", "books", str(instance.book.uuid), filename) return os.path.join(
"images",
"books",
def magazine_image_upload(instance, filename): str(instance.book.uuid),
return os.path.join("images", "magazines", str(instance.uuid), filename) filename
)
class BaseBookImage(Image): class BaseBookImage(Image):
@@ -106,8 +104,6 @@ class Book(BaseBook):
authors = models.ManyToManyField(Author, blank=True) authors = models.ManyToManyField(Author, blank=True)
publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE) publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE)
objects = BookManager()
class Meta: class Meta:
ordering = ["title"] ordering = ["title"]
@@ -124,7 +120,8 @@ class Book(BaseBook):
def get_absolute_url(self): def get_absolute_url(self):
return reverse( return reverse(
"bookshelf_item", kwargs={"selector": "book", "uuid": self.uuid} "bookshelf_item",
kwargs={"selector": "book", "uuid": self.uuid}
) )
@@ -132,164 +129,23 @@ class Catalog(BaseBook):
manufacturer = models.ForeignKey( manufacturer = models.ForeignKey(
Manufacturer, Manufacturer,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="catalogs",
) )
years = models.CharField(max_length=12) years = models.CharField(max_length=12)
scales = models.ManyToManyField(Scale, related_name="catalogs") scales = models.ManyToManyField(Scale)
objects = CatalogManager()
class Meta: class Meta:
ordering = ["manufacturer", "publication_year"] ordering = ["manufacturer", "publication_year"]
def __str__(self): def __str__(self):
# if the object is new, return an empty string to avoid
# calling self.scales.all() which would raise a infinite recursion
if self.pk is None:
return str() # empty string
scales = self.get_scales() scales = self.get_scales()
return "%s %s %s" % (self.manufacturer.name, self.years, scales) return "%s %s %s" % (self.manufacturer.name, self.years, scales)
def get_absolute_url(self): def get_absolute_url(self):
return reverse( return reverse(
"bookshelf_item", kwargs={"selector": "catalog", "uuid": self.uuid} "bookshelf_item",
kwargs={"selector": "catalog", "uuid": self.uuid}
) )
def get_scales(self): def get_scales(self):
return "/".join([s.scale for s in self.scales.all()]) return "/".join([s.scale for s in self.scales.all()])
get_scales.short_description = "Scales" get_scales.short_description = "Scales"
class Magazine(BaseModel):
name = models.CharField(max_length=200)
publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE)
website = models.URLField(blank=True)
ISBN = models.CharField(max_length=17, blank=True) # 13 + dashes
image = models.ImageField(
blank=True,
upload_to=magazine_image_upload,
storage=DeduplicatedStorage,
)
language = models.CharField(
max_length=7,
choices=sorted(settings.LANGUAGES, key=lambda s: s[1]),
default="en",
)
tags = models.ManyToManyField(Tag, related_name="magazine", blank=True)
def delete(self, *args, **kwargs):
shutil.rmtree(
os.path.join(
settings.MEDIA_ROOT, "images", "magazines", str(self.uuid)
),
ignore_errors=True,
)
super(Magazine, self).delete(*args, **kwargs)
class Meta:
ordering = [Lower("name")]
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("magazine", kwargs={"uuid": self.uuid})
def get_cover(self):
if self.image:
return self.image
else:
cover_issue = self.issue.filter(published=True).first()
if cover_issue and cover_issue.image.exists():
return cover_issue.image.first().image
return None
def website_short(self):
if self.website:
return urlparse(self.website).netloc.replace("www.", "")
class MagazineIssue(BaseBook):
magazine = models.ForeignKey(
Magazine, on_delete=models.CASCADE, related_name="issue"
)
issue_number = models.CharField(max_length=100)
publication_month = models.SmallIntegerField(
null=True, blank=True, choices=MONTHS.items()
)
objects = MagazineIssueManager()
class Meta:
unique_together = ("magazine", "issue_number")
ordering = [
"magazine",
"publication_year",
"publication_month",
"issue_number",
]
def __str__(self):
return f"{self.magazine.name} - {self.issue_number}"
def clean(self):
if self.magazine.published is False and self.published is True:
raise ValidationError(
"Cannot set an issue as published if the magazine is not "
"published."
)
@property
def obj_label(self):
return "Magazine Issue"
def preview(self):
return self.image.first().image_thumbnail(100)
@property
def publisher(self):
return self.magazine.publisher
def get_absolute_url(self):
return reverse(
"issue", kwargs={"uuid": self.uuid, "magazine": self.magazine.uuid}
)
class TocEntry(BaseModel):
book = models.ForeignKey(
BaseBook, on_delete=models.CASCADE, related_name="toc"
)
title = models.CharField()
subtitle = models.CharField(blank=True)
authors = models.CharField(blank=True)
page = models.SmallIntegerField()
featured = models.BooleanField(
default=False,
)
class Meta:
ordering = ["page"]
verbose_name = "Table of Contents Entry"
verbose_name_plural = "Table of Contents Entries"
def __str__(self):
if self.subtitle:
title = f"{self.title}: {self.subtitle}"
else:
title = self.title
return f"{title} (p. {self.page})"
def clean(self):
if self.page is None:
raise ValidationError("Page number is required.")
if self.page < 1:
raise ValidationError("Page number is invalid.")
try:
if self.page > self.book.number_of_pages:
raise ValidationError(
"Page number exceeds the publication's number of pages."
)
except TypeError:
pass # number_of_pages is None

View File

@@ -49,5 +49,3 @@ class CatalogSerializer(serializers.ModelSerializer):
"price", "price",
) )
read_only_fields = ("creation_time", "updated_time") read_only_fields = ("creation_time", "updated_time")
# FIXME: add Magazine and MagazineIssue serializers

View File

@@ -1,16 +0,0 @@
document.addEventListener('formset:added', function(event) {
const newForm = event.target; // the new inline form element
const defaultLanguage = document.querySelector('#id_language').value;
const defaultStatus = document.querySelector('#id_published').checked;
const languageInput = newForm.querySelector('select[name$="language"]');
const statusInput = newForm.querySelector('input[name$="published"]');
if (languageInput) {
languageInput.value = defaultLanguage;
}
if (statusInput) {
statusInput.checked = defaultStatus;
}
});

View File

@@ -1,436 +1,3 @@
from decimal import Decimal
from django.test import TestCase from django.test import TestCase
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from bookshelf.models import ( # Create your tests here.
Author,
Publisher,
Book,
Catalog,
Magazine,
MagazineIssue,
TocEntry,
)
from metadata.models import Manufacturer, Scale
class AuthorTestCase(TestCase):
"""Test cases for Author model."""
def test_author_creation(self):
"""Test creating an author."""
author = Author.objects.create(
first_name="John",
last_name="Smith",
)
self.assertEqual(str(author), "Smith, John")
self.assertEqual(author.first_name, "John")
self.assertEqual(author.last_name, "Smith")
def test_author_short_name(self):
"""Test author short name property."""
author = Author.objects.create(
first_name="John",
last_name="Smith",
)
self.assertEqual(author.short_name, "Smith J.")
def test_author_ordering(self):
"""Test author ordering by last name, first name."""
a1 = Author.objects.create(first_name="John", last_name="Smith")
a2 = Author.objects.create(first_name="Jane", last_name="Doe")
a3 = Author.objects.create(first_name="Bob", last_name="Smith")
authors = list(Author.objects.all())
self.assertEqual(authors[0], a2) # Doe comes first
self.assertEqual(authors[1], a3) # Smith, Bob
self.assertEqual(authors[2], a1) # Smith, John
class PublisherTestCase(TestCase):
"""Test cases for Publisher model."""
def test_publisher_creation(self):
"""Test creating a publisher."""
publisher = Publisher.objects.create(
name="Model Railroader",
country="US",
website="https://www.modelrailroader.com",
)
self.assertEqual(str(publisher), "Model Railroader")
self.assertEqual(publisher.country.code, "US")
def test_publisher_ordering(self):
"""Test publisher ordering by name."""
p1 = Publisher.objects.create(name="Zebra Publishing")
p2 = Publisher.objects.create(name="Alpha Books")
p3 = Publisher.objects.create(name="Model Railroader")
publishers = list(Publisher.objects.all())
self.assertEqual(publishers[0], p2)
self.assertEqual(publishers[1], p3)
self.assertEqual(publishers[2], p1)
class BookTestCase(TestCase):
"""Test cases for Book model."""
def setUp(self):
"""Set up test data."""
self.publisher = Publisher.objects.create(
name="Kalmbach Publishing",
country="US",
)
self.author = Author.objects.create(
first_name="Tony",
last_name="Koester",
)
def test_book_creation(self):
"""Test creating a book."""
book = Book.objects.create(
title="Model Railroad Planning",
publisher=self.publisher,
ISBN="978-0-89024-567-8",
language="en",
number_of_pages=128,
publication_year=2010,
price=Decimal("24.95"),
)
self.assertEqual(str(book), "Model Railroad Planning")
self.assertEqual(book.publisher_name, "Kalmbach Publishing")
self.assertTrue(book.published) # Default from BaseModel
def test_book_authors_relationship(self):
"""Test many-to-many relationship with authors."""
book = Book.objects.create(
title="Test Book",
publisher=self.publisher,
)
author2 = Author.objects.create(
first_name="John",
last_name="Doe",
)
book.authors.add(self.author, author2)
self.assertEqual(book.authors.count(), 2)
self.assertIn(self.author, book.authors.all())
def test_book_authors_list_property(self):
"""Test authors_list property."""
book = Book.objects.create(
title="Test Book",
publisher=self.publisher,
)
book.authors.add(self.author)
self.assertEqual(book.authors_list, "Koester T.")
def test_book_ordering(self):
"""Test book ordering by title."""
b1 = Book.objects.create(
title="Zebra Book",
publisher=self.publisher,
)
b2 = Book.objects.create(
title="Alpha Book",
publisher=self.publisher,
)
books = list(Book.objects.all())
self.assertEqual(books[0], b2)
self.assertEqual(books[1], b1)
class CatalogTestCase(TestCase):
"""Test cases for Catalog model."""
def setUp(self):
"""Set up test data."""
self.manufacturer = Manufacturer.objects.create(
name="Bachmann",
category="model",
country="US",
)
self.scale_ho = Scale.objects.create(
scale="HO",
ratio="1:87",
tracks=16.5,
)
self.scale_n = Scale.objects.create(
scale="N",
ratio="1:160",
tracks=9.0,
)
def test_catalog_creation(self):
"""Test creating a catalog."""
catalog = Catalog.objects.create(
manufacturer=self.manufacturer,
years="2023",
publication_year=2023,
)
catalog.scales.add(self.scale_ho)
# Refresh to get the correct string representation
catalog.refresh_from_db()
self.assertIn("Bachmann", str(catalog))
self.assertIn("2023", str(catalog))
def test_catalog_multiple_scales(self):
"""Test catalog with multiple scales."""
catalog = Catalog.objects.create(
manufacturer=self.manufacturer,
years="2023",
)
catalog.scales.add(self.scale_ho, self.scale_n)
scales_str = catalog.get_scales()
self.assertIn("HO", scales_str)
self.assertIn("N", scales_str)
def test_catalog_ordering(self):
"""Test catalog ordering by manufacturer and year."""
man2 = Manufacturer.objects.create(
name="Atlas",
category="model",
)
c1 = Catalog.objects.create(
manufacturer=self.manufacturer,
years="2023",
publication_year=2023,
)
c2 = Catalog.objects.create(
manufacturer=man2,
years="2023",
publication_year=2023,
)
c3 = Catalog.objects.create(
manufacturer=self.manufacturer,
years="2022",
publication_year=2022,
)
catalogs = list(Catalog.objects.all())
# Should be ordered by manufacturer name, then year
self.assertEqual(catalogs[0], c2) # Atlas
class MagazineTestCase(TestCase):
"""Test cases for Magazine model."""
def setUp(self):
"""Set up test data."""
self.publisher = Publisher.objects.create(
name="Kalmbach Publishing",
country="US",
)
def test_magazine_creation(self):
"""Test creating a magazine."""
magazine = Magazine.objects.create(
name="Model Railroader",
publisher=self.publisher,
website="https://www.modelrailroader.com",
ISBN="0746-9896",
language="en",
)
self.assertEqual(str(magazine), "Model Railroader")
self.assertEqual(magazine.publisher, self.publisher)
def test_magazine_website_short(self):
"""Test website_short method."""
magazine = Magazine.objects.create(
name="Model Railroader",
publisher=self.publisher,
website="https://www.modelrailroader.com",
)
self.assertEqual(magazine.website_short(), "modelrailroader.com")
def test_magazine_get_cover_no_image(self):
"""Test get_cover when magazine has no image."""
magazine = Magazine.objects.create(
name="Test Magazine",
publisher=self.publisher,
)
# Should return None if no cover image exists
self.assertIsNone(magazine.get_cover())
class MagazineIssueTestCase(TestCase):
"""Test cases for MagazineIssue model."""
def setUp(self):
"""Set up test data."""
self.publisher = Publisher.objects.create(
name="Kalmbach Publishing",
)
self.magazine = Magazine.objects.create(
name="Model Railroader",
publisher=self.publisher,
published=True,
)
def test_magazine_issue_creation(self):
"""Test creating a magazine issue."""
issue = MagazineIssue.objects.create(
magazine=self.magazine,
issue_number="January 2023",
publication_year=2023,
publication_month=1,
number_of_pages=96,
)
self.assertEqual(str(issue), "Model Railroader - January 2023")
self.assertEqual(issue.obj_label, "Magazine Issue")
def test_magazine_issue_unique_together(self):
"""Test that magazine+issue_number must be unique."""
MagazineIssue.objects.create(
magazine=self.magazine,
issue_number="January 2023",
)
with self.assertRaises(IntegrityError):
MagazineIssue.objects.create(
magazine=self.magazine,
issue_number="January 2023",
)
def test_magazine_issue_validation(self):
"""Test that published issue requires published magazine."""
unpublished_magazine = Magazine.objects.create(
name="Unpublished Magazine",
publisher=self.publisher,
published=False,
)
issue = MagazineIssue(
magazine=unpublished_magazine,
issue_number="Test Issue",
published=True,
)
with self.assertRaises(ValidationError):
issue.clean()
def test_magazine_issue_publisher_property(self):
"""Test that issue inherits publisher from magazine."""
issue = MagazineIssue.objects.create(
magazine=self.magazine,
issue_number="January 2023",
)
self.assertEqual(issue.publisher, self.publisher)
class TocEntryTestCase(TestCase):
"""Test cases for TocEntry model."""
def setUp(self):
"""Set up test data."""
publisher = Publisher.objects.create(name="Test Publisher")
self.book = Book.objects.create(
title="Test Book",
publisher=publisher,
number_of_pages=200,
)
def test_toc_entry_creation(self):
"""Test creating a table of contents entry."""
entry = TocEntry.objects.create(
book=self.book,
title="Introduction to Model Railroading",
subtitle="Getting Started",
authors="John Doe",
page=10,
)
self.assertIn("Introduction to Model Railroading", str(entry))
self.assertIn("Getting Started", str(entry))
self.assertIn("p. 10", str(entry))
def test_toc_entry_without_subtitle(self):
"""Test TOC entry without subtitle."""
entry = TocEntry.objects.create(
book=self.book,
title="Chapter One",
page=5,
)
self.assertEqual(str(entry), "Chapter One (p. 5)")
def test_toc_entry_page_validation_required(self):
"""Test that page number is required."""
entry = TocEntry(
book=self.book,
title="Test Entry",
page=None,
)
with self.assertRaises(ValidationError):
entry.clean()
def test_toc_entry_page_validation_min(self):
"""Test that page number must be >= 1."""
entry = TocEntry(
book=self.book,
title="Test Entry",
page=0,
)
with self.assertRaises(ValidationError):
entry.clean()
def test_toc_entry_page_validation_exceeds_book(self):
"""Test that page number cannot exceed book's page count."""
entry = TocEntry(
book=self.book,
title="Test Entry",
page=250, # Book has 200 pages
)
with self.assertRaises(ValidationError):
entry.clean()
def test_toc_entry_ordering(self):
"""Test TOC entries are ordered by page number."""
e1 = TocEntry.objects.create(
book=self.book,
title="Chapter Three",
page=30,
)
e2 = TocEntry.objects.create(
book=self.book,
title="Chapter One",
page=10,
)
e3 = TocEntry.objects.create(
book=self.book,
title="Chapter Two",
page=20,
)
entries = list(TocEntry.objects.all())
self.assertEqual(entries[0], e2) # Page 10
self.assertEqual(entries[1], e3) # Page 20
self.assertEqual(entries[2], e1) # Page 30

View File

@@ -38,5 +38,3 @@ class CatalogGet(RetrieveAPIView):
def get_queryset(self): def get_queryset(self):
return Book.objects.get_published(self.request.user) return Book.objects.get_published(self.request.user)
# FIXME: add Magazine and MagazineIssue views

View File

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

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.1.4 on 2025-04-27 19:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("consist", "0015_consist_description"),
]
operations = [
migrations.AlterField(
model_name="consistitem",
name="order",
field=models.PositiveIntegerField(),
),
]

View File

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

View File

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

View File

@@ -1,18 +0,0 @@
# Generated by Django 6.0 on 2026-01-03 12:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("consist", "0018_alter_consist_scale"),
]
operations = [
migrations.AddField(
model_name="consistitem",
name="load",
field=models.BooleanField(default=False),
),
]

View File

@@ -2,14 +2,12 @@ import os
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.text import Truncator
from django.dispatch import receiver from django.dispatch import receiver
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from ram.models import BaseModel from ram.models import BaseModel
from ram.utils import DeduplicatedStorage from ram.utils import DeduplicatedStorage
from ram.managers import ConsistManager from metadata.models import Company, Tag
from metadata.models import Company, Scale, Tag
from roster.models import RollingStock from roster.models import RollingStock
@@ -28,7 +26,6 @@ class Consist(BaseModel):
blank=True, blank=True,
help_text="Era or epoch of the consist", help_text="Era or epoch of the consist",
) )
scale = models.ForeignKey(Scale, on_delete=models.CASCADE)
image = models.ImageField( image = models.ImageField(
upload_to=os.path.join("images", "consists"), upload_to=os.path.join("images", "consists"),
storage=DeduplicatedStorage, storage=DeduplicatedStorage,
@@ -36,42 +33,22 @@ class Consist(BaseModel):
blank=True, blank=True,
) )
objects = ConsistManager()
def __str__(self): def __str__(self):
return "{0} {1}".format(self.company, self.identifier) return "{0} {1}".format(self.company, self.identifier)
def get_absolute_url(self): def get_absolute_url(self):
return reverse("consist", kwargs={"uuid": self.uuid}) return reverse("consist", kwargs={"uuid": self.uuid})
@property
def length(self):
return self.consist_item.filter(load=False).count()
def get_type_count(self):
return self.consist_item.filter(load=False).annotate(
type=models.F("rolling_stock__rolling_class__type__type")
).values(
"type"
).annotate(
count=models.Count("rolling_stock"),
category=models.F("rolling_stock__rolling_class__type__category"),
order=models.Max("order"),
).order_by("order")
def get_cover(self):
if self.image:
return self.image
else:
consist_item = self.consist_item.first()
if consist_item and consist_item.rolling_stock.image.exists():
return consist_item.rolling_stock.image.first().image
return None
@property @property
def country(self): def country(self):
return self.company.country return self.company.country
def clean(self):
if self.consist_item.filter(rolling_stock__published=False).exists():
raise ValidationError(
"You must publish all items in the consist before publishing the consist." # noqa: E501
)
class Meta: class Meta:
ordering = ["company", "-creation_time"] ordering = ["company", "-creation_time"]
@@ -81,8 +58,11 @@ class ConsistItem(models.Model):
Consist, on_delete=models.CASCADE, related_name="consist_item" Consist, on_delete=models.CASCADE, related_name="consist_item"
) )
rolling_stock = models.ForeignKey(RollingStock, on_delete=models.CASCADE) rolling_stock = models.ForeignKey(RollingStock, on_delete=models.CASCADE)
load = models.BooleanField(default=False) order = models.PositiveIntegerField(
order = models.PositiveIntegerField(blank=False, null=False) default=1000, # make sure it is always added at the end
blank=False,
null=False
)
class Meta: class Meta:
ordering = ["order"] ordering = ["order"]
@@ -96,29 +76,6 @@ class ConsistItem(models.Model):
def __str__(self): def __str__(self):
return "{0}".format(self.rolling_stock) return "{0}".format(self.rolling_stock)
def clean(self):
rolling_stock = getattr(self, "rolling_stock", False)
if not rolling_stock:
return # exit if no inline are present
# FIXME this does not work when creating a new consist,
# because the consist is not saved yet and it must be moved
# to the admin form validation via InlineFormSet.clean()
consist = self.consist
# Scale must match, but allow loads of any scale
if rolling_stock.scale != consist.scale and not self.load:
raise ValidationError(
"The rolling stock and consist must be of the same scale."
)
if self.load and rolling_stock.scale.ratio != consist.scale.ratio:
raise ValidationError(
"The load and consist must be of the same scale ratio."
)
if self.consist.published and not rolling_stock.published:
raise ValidationError(
"You must unpublish the the consist before using this item."
)
def published(self): def published(self):
return self.rolling_stock.published return self.rolling_stock.published
published.boolean = True published.boolean = True
@@ -126,21 +83,9 @@ class ConsistItem(models.Model):
def preview(self): def preview(self):
return self.rolling_stock.image.first().image_thumbnail(100) return self.rolling_stock.image.first().image_thumbnail(100)
@property
def manufacturer(self):
return Truncator(self.rolling_stock.manufacturer).chars(10)
@property
def item_number(self):
return self.rolling_stock.item_number
@property
def scale(self):
return self.rolling_stock.scale
@property @property
def type(self): def type(self):
return self.rolling_stock.rolling_class.type.type return self.rolling_stock.rolling_class.type
@property @property
def address(self): def address(self):

View File

@@ -1,315 +1,3 @@
from django.test import TestCase from django.test import TestCase
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from consist.models import Consist, ConsistItem # Create your tests here.
from roster.models import RollingClass, RollingStock
from metadata.models import Company, Scale, RollingStockType
class ConsistTestCase(TestCase):
"""Test cases for Consist model."""
def setUp(self):
"""Set up test data."""
self.company = Company.objects.create(
name="Rio Grande Southern",
country="US",
)
self.scale = Scale.objects.create(
scale="HOn3",
ratio="1:87",
tracks=10.5,
)
def test_consist_creation(self):
"""Test creating a consist."""
consist = Consist.objects.create(
identifier="RGS Freight #1",
company=self.company,
scale=self.scale,
era="1930s",
)
self.assertEqual(str(consist), "Rio Grande Southern RGS Freight #1")
self.assertEqual(consist.identifier, "RGS Freight #1")
self.assertEqual(consist.era, "1930s")
def test_consist_country_property(self):
"""Test that consist inherits country from company."""
consist = Consist.objects.create(
identifier="Test Consist",
company=self.company,
scale=self.scale,
)
self.assertEqual(consist.country, self.company.country)
def test_consist_dcc_address(self):
"""Test consist with DCC address."""
consist = Consist.objects.create(
identifier="DCC Consist",
company=self.company,
scale=self.scale,
consist_address=99,
)
self.assertEqual(consist.consist_address, 99)
def test_consist_get_absolute_url(self):
"""Test get_absolute_url returns correct URL."""
consist = Consist.objects.create(
identifier="Test Consist",
company=self.company,
scale=self.scale,
)
url = consist.get_absolute_url()
self.assertIn(str(consist.uuid), url)
class ConsistItemTestCase(TestCase):
"""Test cases for ConsistItem model."""
def setUp(self):
"""Set up test data."""
self.company = Company.objects.create(name="RGS", country="US")
self.scale_hon3 = Scale.objects.create(
scale="HOn3",
ratio="1:87",
tracks=10.5,
)
self.scale_ho = Scale.objects.create(
scale="HO",
ratio="1:87",
tracks=16.5,
)
self.stock_type = RollingStockType.objects.create(
type="Steam Locomotive",
category="locomotive",
order=1,
)
self.rolling_class = RollingClass.objects.create(
identifier="C-19",
type=self.stock_type,
company=self.company,
)
self.consist = Consist.objects.create(
identifier="Test Consist",
company=self.company,
scale=self.scale_hon3,
published=True,
)
self.rolling_stock = RollingStock.objects.create(
rolling_class=self.rolling_class,
road_number="340",
scale=self.scale_hon3,
published=True,
)
def test_consist_item_creation(self):
"""Test creating a consist item."""
item = ConsistItem.objects.create(
consist=self.consist,
rolling_stock=self.rolling_stock,
order=1,
load=False,
)
self.assertEqual(str(item), "RGS C-19 340")
self.assertEqual(item.order, 1)
self.assertFalse(item.load)
def test_consist_item_unique_constraint(self):
"""Test that consist+rolling_stock must be unique."""
ConsistItem.objects.create(
consist=self.consist,
rolling_stock=self.rolling_stock,
order=1,
)
# Cannot add same rolling stock to same consist twice
with self.assertRaises(IntegrityError):
ConsistItem.objects.create(
consist=self.consist,
rolling_stock=self.rolling_stock,
order=2,
)
def test_consist_item_scale_validation(self):
"""Test that consist item scale must match consist scale."""
different_scale_stock = RollingStock.objects.create(
rolling_class=self.rolling_class,
road_number="341",
scale=self.scale_ho, # Different scale
)
item = ConsistItem(
consist=self.consist,
rolling_stock=different_scale_stock,
order=1,
load=False,
)
with self.assertRaises(ValidationError):
item.clean()
def test_consist_item_load_ratio_validation(self):
"""Test that load ratio must match consist ratio."""
different_scale = Scale.objects.create(
scale="N",
ratio="1:160", # Different ratio
tracks=9.0,
)
load_stock = RollingStock.objects.create(
rolling_class=self.rolling_class,
road_number="342",
scale=different_scale,
)
item = ConsistItem(
consist=self.consist,
rolling_stock=load_stock,
order=1,
load=True,
)
with self.assertRaises(ValidationError):
item.clean()
def test_consist_item_published_validation(self):
"""Test that unpublished stock cannot be in published consist."""
unpublished_stock = RollingStock.objects.create(
rolling_class=self.rolling_class,
road_number="343",
scale=self.scale_hon3,
published=False,
)
item = ConsistItem(
consist=self.consist,
rolling_stock=unpublished_stock,
order=1,
)
with self.assertRaises(ValidationError):
item.clean()
def test_consist_item_properties(self):
"""Test consist item properties."""
item = ConsistItem.objects.create(
consist=self.consist,
rolling_stock=self.rolling_stock,
order=1,
)
self.assertEqual(item.scale, self.rolling_stock.scale)
self.assertEqual(item.company, self.rolling_stock.company)
self.assertEqual(item.type, self.stock_type.type)
def test_consist_length_calculation(self):
"""Test consist length calculation."""
# Add three items (not loads)
for i in range(3):
stock = RollingStock.objects.create(
rolling_class=self.rolling_class,
road_number=str(340 + i),
scale=self.scale_hon3,
)
ConsistItem.objects.create(
consist=self.consist,
rolling_stock=stock,
order=i + 1,
load=False,
)
self.assertEqual(self.consist.length, 3)
def test_consist_length_excludes_loads(self):
"""Test that consist length excludes loads."""
# Add one regular item
ConsistItem.objects.create(
consist=self.consist,
rolling_stock=self.rolling_stock,
order=1,
load=False,
)
# Add one load (same ratio, different scale tracks OK for loads)
load_stock = RollingStock.objects.create(
rolling_class=self.rolling_class,
road_number="LOAD-1",
scale=self.scale_hon3,
)
ConsistItem.objects.create(
consist=self.consist,
rolling_stock=load_stock,
order=2,
load=True,
)
# Length should only count non-load items
self.assertEqual(self.consist.length, 1)
def test_consist_item_ordering(self):
"""Test consist items are ordered by order field."""
stock2 = RollingStock.objects.create(
rolling_class=self.rolling_class,
road_number="341",
scale=self.scale_hon3,
)
stock3 = RollingStock.objects.create(
rolling_class=self.rolling_class,
road_number="342",
scale=self.scale_hon3,
)
item3 = ConsistItem.objects.create(
consist=self.consist,
rolling_stock=stock3,
order=3,
)
item1 = ConsistItem.objects.create(
consist=self.consist,
rolling_stock=self.rolling_stock,
order=1,
)
item2 = ConsistItem.objects.create(
consist=self.consist,
rolling_stock=stock2,
order=2,
)
items = list(self.consist.consist_item.all())
self.assertEqual(items[0], item1)
self.assertEqual(items[1], item2)
self.assertEqual(items[2], item3)
def test_unpublish_consist_signal(self):
"""Test that unpublishing rolling stock unpublishes consists."""
# Create a consist item
ConsistItem.objects.create(
consist=self.consist,
rolling_stock=self.rolling_stock,
order=1,
)
self.assertTrue(self.consist.published)
# Unpublish the rolling stock
self.rolling_stock.published = False
self.rolling_stock.save()
# Reload consist from database
self.consist.refresh_from_db()
# Consist should now be unpublished
self.assertFalse(self.consist.published)

View File

@@ -24,7 +24,7 @@ class PropertyAdmin(admin.ModelAdmin):
class DecoderDocInline(admin.TabularInline): class DecoderDocInline(admin.TabularInline):
model = DecoderDocument model = DecoderDocument
min_num = 0 min_num = 0
extra = 1 extra = 0
classes = ["collapse"] classes = ["collapse"]
@@ -47,28 +47,28 @@ class ScaleAdmin(admin.ModelAdmin):
@admin.register(Company) @admin.register(Company)
class CompanyAdmin(admin.ModelAdmin): class CompanyAdmin(admin.ModelAdmin):
readonly_fields = ("logo_thumbnail",) readonly_fields = ("logo_thumbnail",)
list_display = ("name", "country_flag_name") list_display = ("name", "country_flag")
list_filter = ("name", "country") list_filter = ("name", "country")
search_fields = ("name",) search_fields = ("name",)
@admin.display(description="Country") @admin.display(description="Country")
def country_flag_name(self, obj): def country_flag(self, obj):
return format_html( return format_html(
'<img src="{}" /> {}', obj.country.flag, obj.country.name '<img src="{}" /> {}'.format(obj.country.flag, obj.country.name)
) )
@admin.register(Manufacturer) @admin.register(Manufacturer)
class ManufacturerAdmin(admin.ModelAdmin): class ManufacturerAdmin(admin.ModelAdmin):
readonly_fields = ("logo_thumbnail",) readonly_fields = ("logo_thumbnail",)
list_display = ("name", "category", "country_flag_name") list_display = ("name", "category", "country_flag")
list_filter = ("category",) list_filter = ("category",)
search_fields = ("name",) search_fields = ("name",)
@admin.display(description="Country") @admin.display(description="Country")
def country_flag_name(self, obj): def country_flag(self, obj):
return format_html( return format_html(
'<img src="{}" /> {}', obj.country.flag, obj.country.name '<img src="{}" /> {}'.format(obj.country.flag, obj.country.name)
) )
@@ -88,12 +88,6 @@ class RollingStockTypeAdmin(SortableAdminMixin, admin.ModelAdmin):
@admin.register(Shop) @admin.register(Shop)
class ShopAdmin(admin.ModelAdmin): class ShopAdmin(admin.ModelAdmin):
list_display = ("name", "on_line", "active", "country_flag_name") list_display = ("name", "on_line", "active")
list_filter = ("on_line", "active") list_filter = ("on_line", "active")
search_fields = ("name",) search_fields = ("name",)
@admin.display(description="Country")
def country_flag_name(self, obj):
return format_html(
'<img src="{}" /> {}', obj.country.flag, obj.country.name
)

View File

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

View File

@@ -1,24 +0,0 @@
# Generated by Django 6.0 on 2026-01-09 12:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("metadata", "0025_alter_company_options_alter_manufacturer_options_and_more"),
]
operations = [
migrations.AlterField(
model_name="manufacturer",
name="name",
field=models.CharField(max_length=128),
),
migrations.AddConstraint(
model_name="manufacturer",
constraint=models.UniqueConstraint(
fields=("name", "category"), name="unique_name_category"
),
),
]

View File

@@ -1,5 +1,4 @@
import os import os
from urllib.parse import urlparse
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.conf import settings from django.conf import settings
@@ -7,12 +6,11 @@ from django.dispatch.dispatcher import receiver
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django_countries.fields import CountryField from django_countries.fields import CountryField
from ram.models import SimpleBaseModel
from ram.utils import DeduplicatedStorage, get_image_preview, slugify from ram.utils import DeduplicatedStorage, get_image_preview, slugify
from ram.managers import PublicManager from ram.managers import PublicManager
class Property(SimpleBaseModel): class Property(models.Model):
name = models.CharField(max_length=128, unique=True) name = models.CharField(max_length=128, unique=True)
private = models.BooleanField( private = models.BooleanField(
default=False, default=False,
@@ -29,8 +27,8 @@ class Property(SimpleBaseModel):
objects = PublicManager() objects = PublicManager()
class Manufacturer(SimpleBaseModel): class Manufacturer(models.Model):
name = models.CharField(max_length=128) name = models.CharField(max_length=128, unique=True)
slug = models.CharField(max_length=128, unique=True, editable=False) slug = models.CharField(max_length=128, unique=True, editable=False)
category = models.CharField( category = models.CharField(
max_length=64, choices=settings.MANUFACTURER_TYPES max_length=64, choices=settings.MANUFACTURER_TYPES
@@ -45,13 +43,7 @@ class Manufacturer(SimpleBaseModel):
) )
class Meta: class Meta:
ordering = ["category", "slug"] ordering = ["category", "name"]
constraints = [
models.UniqueConstraint(
fields=["name", "category"],
name="unique_name_category"
)
]
def __str__(self): def __str__(self):
return self.name return self.name
@@ -65,17 +57,13 @@ class Manufacturer(SimpleBaseModel):
}, },
) )
def website_short(self):
if self.website:
return urlparse(self.website).netloc.replace("www.", "")
def logo_thumbnail(self): def logo_thumbnail(self):
return get_image_preview(self.logo.url) return get_image_preview(self.logo.url)
logo_thumbnail.short_description = "Preview" logo_thumbnail.short_description = "Preview"
class Company(SimpleBaseModel): class Company(models.Model):
name = models.CharField(max_length=64, unique=True) name = models.CharField(max_length=64, unique=True)
slug = models.CharField(max_length=64, unique=True, editable=False) slug = models.CharField(max_length=64, unique=True, editable=False)
extended_name = models.CharField(max_length=128, blank=True) extended_name = models.CharField(max_length=128, blank=True)
@@ -90,7 +78,7 @@ class Company(SimpleBaseModel):
class Meta: class Meta:
verbose_name_plural = "Companies" verbose_name_plural = "Companies"
ordering = ["slug"] ordering = ["name"]
def __str__(self): def __str__(self):
return self.name return self.name
@@ -113,7 +101,7 @@ class Company(SimpleBaseModel):
logo_thumbnail.short_description = "Preview" logo_thumbnail.short_description = "Preview"
class Decoder(SimpleBaseModel): class Decoder(models.Model):
name = models.CharField(max_length=128, unique=True) name = models.CharField(max_length=128, unique=True)
manufacturer = models.ForeignKey( manufacturer = models.ForeignKey(
Manufacturer, Manufacturer,
@@ -149,7 +137,7 @@ def calculate_ratio(ratio):
raise ValidationError("Invalid ratio format") raise ValidationError("Invalid ratio format")
class Scale(SimpleBaseModel): class Scale(models.Model):
scale = models.CharField(max_length=32, unique=True) scale = models.CharField(max_length=32, unique=True)
slug = models.CharField(max_length=32, unique=True, editable=False) slug = models.CharField(max_length=32, unique=True, editable=False)
ratio = models.CharField(max_length=16, validators=[calculate_ratio]) ratio = models.CharField(max_length=16, validators=[calculate_ratio])
@@ -184,7 +172,7 @@ def scale_save(sender, instance, **kwargs):
instance.ratio_int = calculate_ratio(instance.ratio) instance.ratio_int = calculate_ratio(instance.ratio)
class RollingStockType(SimpleBaseModel): class RollingStockType(models.Model):
type = models.CharField(max_length=64) type = models.CharField(max_length=64)
order = models.PositiveSmallIntegerField() order = models.PositiveSmallIntegerField()
category = models.CharField( category = models.CharField(
@@ -214,12 +202,12 @@ class RollingStockType(SimpleBaseModel):
return "{0} {1}".format(self.type, self.category) return "{0} {1}".format(self.type, self.category)
class Tag(SimpleBaseModel): class Tag(models.Model):
name = models.CharField(max_length=128, unique=True) name = models.CharField(max_length=128, unique=True)
slug = models.CharField(max_length=128, unique=True) slug = models.CharField(max_length=128, unique=True)
class Meta: class Meta:
ordering = ["slug"] ordering = ["name"]
def __str__(self): def __str__(self):
return self.name return self.name
@@ -234,7 +222,7 @@ class Tag(SimpleBaseModel):
) )
class Shop(SimpleBaseModel): class Shop(models.Model):
name = models.CharField(max_length=128, unique=True) name = models.CharField(max_length=128, unique=True)
country = CountryField(blank=True) country = CountryField(blank=True)
website = models.URLField(blank=True) website = models.URLField(blank=True)

View File

@@ -1,371 +1,3 @@
from django.test import TestCase from django.test import TestCase
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from metadata.models import ( # Create your tests here.
Manufacturer,
Company,
Scale,
RollingStockType,
Decoder,
Shop,
Tag,
calculate_ratio,
)
class ManufacturerTestCase(TestCase):
"""Test cases for Manufacturer model."""
def test_manufacturer_creation(self):
"""Test creating a manufacturer."""
manufacturer = Manufacturer.objects.create(
name="Blackstone Models",
category="model",
country="US",
website="https://www.blackstonemodels.com",
)
self.assertEqual(str(manufacturer), "Blackstone Models")
self.assertEqual(manufacturer.slug, "blackstone-models")
self.assertEqual(manufacturer.category, "model")
def test_manufacturer_slug_auto_generation(self):
"""Test that slug is automatically generated."""
manufacturer = Manufacturer.objects.create(
name="Baldwin Locomotive Works",
category="real",
)
self.assertEqual(manufacturer.slug, "baldwin-locomotive-works")
def test_manufacturer_unique_constraint(self):
"""Test that name+category must be unique."""
Manufacturer.objects.create(
name="Baldwin",
category="real",
)
# Should not be able to create another with same name+category
with self.assertRaises(IntegrityError):
Manufacturer.objects.create(
name="Baldwin",
category="real",
)
def test_manufacturer_different_categories(self):
"""Test that same name is allowed with different categories."""
Manufacturer.objects.create(
name="Baldwin",
category="real",
)
# Should be able to create with different category
manufacturer2 = Manufacturer.objects.create(
name="Alco",
category="model",
)
self.assertEqual(manufacturer2.name, "Alco")
self.assertIsNotNone(manufacturer2.pk)
def test_manufacturer_website_short(self):
"""Test website_short extracts domain."""
manufacturer = Manufacturer.objects.create(
name="Test Manufacturer",
category="model",
website="https://www.example.com/path",
)
self.assertEqual(manufacturer.website_short(), "example.com")
def test_manufacturer_ordering(self):
"""Test manufacturer ordering by category and slug."""
m1 = Manufacturer.objects.create(name="Zebra", category="model")
m2 = Manufacturer.objects.create(name="Alpha", category="accessory")
m3 = Manufacturer.objects.create(name="Beta", category="model")
manufacturers = list(Manufacturer.objects.all())
# Ordered by category, then slug
self.assertEqual(manufacturers[0], m2) # accessory comes first
self.assertTrue(manufacturers.index(m3) < manufacturers.index(m1))
class CompanyTestCase(TestCase):
"""Test cases for Company model."""
def test_company_creation(self):
"""Test creating a company."""
company = Company.objects.create(
name="RGS",
extended_name="Rio Grande Southern Railroad",
country="US",
freelance=False,
)
self.assertEqual(str(company), "RGS")
self.assertEqual(company.slug, "rgs")
self.assertEqual(company.extended_name, "Rio Grande Southern Railroad")
def test_company_slug_generation(self):
"""Test automatic slug generation."""
company = Company.objects.create(
name="Denver & Rio Grande Western",
country="US",
)
self.assertEqual(company.slug, "denver-rio-grande-western")
def test_company_unique_name(self):
"""Test that company name must be unique."""
Company.objects.create(name="RGS", country="US")
with self.assertRaises(IntegrityError):
Company.objects.create(name="RGS", country="GB")
def test_company_extended_name_pp(self):
"""Test extended name pretty print."""
company = Company.objects.create(
name="RGS",
extended_name="Rio Grande Southern Railroad",
country="US",
)
self.assertEqual(
company.extended_name_pp(),
"(Rio Grande Southern Railroad)"
)
def test_company_extended_name_pp_empty(self):
"""Test extended name pretty print when empty."""
company = Company.objects.create(name="RGS", country="US")
self.assertEqual(company.extended_name_pp(), "")
def test_company_freelance_flag(self):
"""Test freelance flag."""
company = Company.objects.create(
name="Fake Railroad",
country="US",
freelance=True,
)
self.assertTrue(company.freelance)
class ScaleTestCase(TestCase):
"""Test cases for Scale model."""
def test_scale_creation(self):
"""Test creating a scale."""
scale = Scale.objects.create(
scale="HOn3",
ratio="1:87",
tracks=10.5,
gauge="3 ft",
)
self.assertEqual(str(scale), "HOn3")
self.assertEqual(scale.slug, "hon3")
self.assertEqual(scale.ratio, "1:87")
self.assertEqual(scale.tracks, 10.5)
def test_scale_ratio_calculation(self):
"""Test automatic ratio_int calculation."""
scale = Scale.objects.create(
scale="HO",
ratio="1:87",
tracks=16.5,
)
# 1/87 * 10000 = 114.94...
self.assertAlmostEqual(scale.ratio_int, 114, delta=1)
def test_scale_ratio_validation_valid(self):
"""Test that valid ratios are accepted."""
ratios = ["1:87", "1:160", "1:22.5", "1:48"]
for ratio in ratios:
result = calculate_ratio(ratio)
self.assertIsInstance(result, (int, float))
def test_scale_ratio_validation_invalid(self):
"""Test that invalid ratios raise ValidationError."""
with self.assertRaises(ValidationError):
calculate_ratio("invalid")
with self.assertRaises(ValidationError):
calculate_ratio("1:0") # Division by zero
def test_scale_ordering(self):
"""Test scale ordering by ratio_int (descending)."""
s1 = Scale.objects.create(scale="G", ratio="1:22.5", tracks=45.0)
s2 = Scale.objects.create(scale="HO", ratio="1:87", tracks=16.5)
s3 = Scale.objects.create(scale="N", ratio="1:160", tracks=9.0)
scales = list(Scale.objects.all())
# Ordered by -ratio_int (larger ratios first)
self.assertEqual(scales[0], s1) # G scale (largest)
self.assertEqual(scales[1], s2) # HO scale
self.assertEqual(scales[2], s3) # N scale (smallest)
class RollingStockTypeTestCase(TestCase):
"""Test cases for RollingStockType model."""
def test_rolling_stock_type_creation(self):
"""Test creating a rolling stock type."""
stock_type = RollingStockType.objects.create(
type="Steam Locomotive",
category="locomotive",
order=1,
)
self.assertEqual(str(stock_type), "Steam Locomotive locomotive")
self.assertEqual(stock_type.slug, "steam-locomotive-locomotive")
def test_rolling_stock_type_unique_constraint(self):
"""Test that category+type must be unique."""
RollingStockType.objects.create(
type="Steam Locomotive",
category="locomotive",
order=1,
)
with self.assertRaises(IntegrityError):
RollingStockType.objects.create(
type="Steam Locomotive",
category="locomotive",
order=2,
)
def test_rolling_stock_type_ordering(self):
"""Test ordering by order field."""
t3 = RollingStockType.objects.create(
type="Caboose", category="railcar", order=3
)
t1 = RollingStockType.objects.create(
type="Steam", category="locomotive", order=1
)
t2 = RollingStockType.objects.create(
type="Boxcar", category="railcar", order=2
)
types = list(RollingStockType.objects.all())
self.assertEqual(types[0], t1)
self.assertEqual(types[1], t2)
self.assertEqual(types[2], t3)
class DecoderTestCase(TestCase):
"""Test cases for Decoder model."""
def setUp(self):
"""Set up test data."""
self.manufacturer = Manufacturer.objects.create(
name="ESU",
category="accessory",
country="DE",
)
def test_decoder_creation(self):
"""Test creating a decoder."""
decoder = Decoder.objects.create(
name="LokSound 5",
manufacturer=self.manufacturer,
version="5.0",
sound=True,
)
self.assertEqual(str(decoder), "ESU - LokSound 5")
self.assertTrue(decoder.sound)
def test_decoder_without_sound(self):
"""Test creating a non-sound decoder."""
decoder = Decoder.objects.create(
name="LokPilot 5",
manufacturer=self.manufacturer,
sound=False,
)
self.assertFalse(decoder.sound)
def test_decoder_ordering(self):
"""Test decoder ordering by manufacturer name and decoder name."""
man2 = Manufacturer.objects.create(
name="Digitrax",
category="accessory",
)
d1 = Decoder.objects.create(
name="LokSound 5",
manufacturer=self.manufacturer,
)
d2 = Decoder.objects.create(
name="DZ123",
manufacturer=man2,
)
d3 = Decoder.objects.create(
name="LokPilot 5",
manufacturer=self.manufacturer,
)
decoders = list(Decoder.objects.all())
# Ordered by manufacturer name, then decoder name
self.assertEqual(decoders[0], d2) # Digitrax
self.assertTrue(decoders.index(d3) < decoders.index(d1)) # LokPilot before LokSound
class ShopTestCase(TestCase):
"""Test cases for Shop model."""
def test_shop_creation(self):
"""Test creating a shop."""
shop = Shop.objects.create(
name="Caboose Hobbies",
country="US",
website="https://www.caboosehobbies.com",
on_line=True,
active=True,
)
self.assertEqual(str(shop), "Caboose Hobbies")
self.assertTrue(shop.on_line)
self.assertTrue(shop.active)
def test_shop_defaults(self):
"""Test shop default values."""
shop = Shop.objects.create(name="Local Shop")
self.assertTrue(shop.on_line) # Default True
self.assertTrue(shop.active) # Default True
def test_shop_offline(self):
"""Test creating an offline shop."""
shop = Shop.objects.create(
name="Brick and Mortar Store",
on_line=False,
)
self.assertFalse(shop.on_line)
class TagTestCase(TestCase):
"""Test cases for Tag model."""
def test_tag_creation(self):
"""Test creating a tag."""
tag = Tag.objects.create(
name="Narrow Gauge",
slug="narrow-gauge",
)
self.assertEqual(str(tag), "Narrow Gauge")
self.assertEqual(tag.slug, "narrow-gauge")
def test_tag_unique_name(self):
"""Test that tag name must be unique."""
Tag.objects.create(name="Narrow Gauge", slug="narrow-gauge")
with self.assertRaises(IntegrityError):
Tag.objects.create(name="Narrow Gauge", slug="narrow-gauge")

View File

@@ -20,7 +20,6 @@ class SiteConfigurationAdmin(SingletonModelAdmin):
"about", "about",
"items_per_page", "items_per_page",
"items_ordering", "items_ordering",
"featured_items_ordering",
"currency", "currency",
"footer", "footer",
"footer_extended", "footer_extended",
@@ -35,8 +34,7 @@ class SiteConfigurationAdmin(SingletonModelAdmin):
"fields": ( "fields": (
"show_version", "show_version",
"use_cdn", "use_cdn",
"extra_html", "extra_head",
"extra_js",
"rest_api", "rest_api",
"version", "version",
), ),

View File

@@ -1,43 +0,0 @@
# Generated by Django 6.0 on 2026-01-02 23:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("portal", "0020_alter_flatpage_options"),
]
operations = [
migrations.AddField(
model_name="siteconfiguration",
name="featured_items_ordering",
field=models.CharField(
choices=[
("type", "By rolling stock type and company"),
("class", "By rolling stock type and class"),
("company", "By company and type"),
("country", "By country and type"),
("cou+com", "By country and company"),
],
default="type",
max_length=11,
),
),
migrations.AlterField(
model_name="siteconfiguration",
name="items_ordering",
field=models.CharField(
choices=[
("type", "By rolling stock type and company"),
("class", "By rolling stock type and class"),
("company", "By company and type"),
("country", "By country and type"),
("cou+com", "By country and company"),
],
default="type",
max_length=11,
),
),
]

View File

@@ -1,34 +0,0 @@
# Generated by Django 6.0.1 on 2026-01-15 11:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("portal", "0021_siteconfiguration_featured_items_ordering_and_more"),
]
operations = [
migrations.RenameField(
model_name="siteconfiguration",
old_name="extra_head",
new_name="extra_html",
),
migrations.AlterField(
model_name="siteconfiguration",
name="extra_html",
field=models.TextField(
blank=True,
help_text="Extra HTML to be dinamically loaded into the site.",
),
),
migrations.AddField(
model_name="siteconfiguration",
name="extra_js",
field=models.TextField(
blank=True,
help_text="Extra JS to be dinamically loaded into the site."
),
),
]

View File

@@ -22,31 +22,21 @@ class SiteConfiguration(SingletonModel):
default="6", default="6",
) )
items_ordering = models.CharField( items_ordering = models.CharField(
max_length=11, max_length=10,
choices=[ choices=[
("type", "By rolling stock type and company"), ("type", "By rolling stock type"),
("class", "By rolling stock type and class"), ("company", "By company name"),
("company", "By company and type"), ("identifier", "By rolling stock class"),
("country", "By country and type"),
("cou+com", "By country and company"),
], ],
default="type", default="type",
) )
featured_items_ordering = items_ordering.clone()
currency = models.CharField(max_length=3, default="EUR") currency = models.CharField(max_length=3, default="EUR")
footer = tinymce.HTMLField(blank=True) footer = tinymce.HTMLField(blank=True)
footer_extended = tinymce.HTMLField(blank=True) footer_extended = tinymce.HTMLField(blank=True)
disclaimer = tinymce.HTMLField(blank=True) disclaimer = tinymce.HTMLField(blank=True)
show_version = models.BooleanField(default=True) show_version = models.BooleanField(default=True)
use_cdn = models.BooleanField(default=True) use_cdn = models.BooleanField(default=True)
extra_html = models.TextField( extra_head = models.TextField(blank=True)
blank=True,
help_text="Extra HTML to be dinamically loaded into the site.",
)
extra_js = models.TextField(
blank=True,
help_text="Extra JS to be dinamically loaded into the site.",
)
class Meta: class Meta:
verbose_name = "Site Configuration" verbose_name = "Site Configuration"

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -43,15 +43,13 @@ a.badge, a.badge:hover {
border-top: calc(var(--bs-border-width) * 3) solid var(--bs-border-color); border-top: calc(var(--bs-border-width) * 3) solid var(--bs-border-color);
} }
#nav-journal ul, #nav-journal ul, #nav-journal ol {
#nav-journal ol { margin: 0;
padding-left: 1rem; padding-left: 1rem;
} }
#nav-journal p:last-child, #nav-journal p {
#nav-journal ul:last-child, margin: 0;
#nav-journal ol:last-child {
margin-bottom: 0;
} }
#footer > p { #footer > p {

View File

@@ -1 +0,0 @@
html[data-bs-theme=dark] .navbar svg{fill:#fff}.card>a>img{width:100%}td>img.logo{max-width:200px;max-height:48px}td>img.logo-xl{max-width:400px;max-height:96px}td>p:last-child{margin-bottom:0}.btn>span{display:inline-block}a.badge,a.badge:hover{text-decoration:none;color:#fff}.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-journal ol,#nav-journal ul{padding-left:1rem}#nav-journal ol:last-child,#nav-journal p:last-child,#nav-journal ul:last-child{margin-bottom:0}#footer>p{display:inline}

View File

@@ -1,7 +0,0 @@
# Compile main.min.css
```bash
$ npm install clean-css-cli
$ npx cleancss -o ../main.min.css main.css
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,6 +0,0 @@
/*!
* 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 e=()=>localStorage.getItem("theme"),t=()=>{const t=e();return t||(window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light")},a=e=>{"auto"===e&&window.matchMedia("(prefers-color-scheme: dark)").matches?document.documentElement.setAttribute("data-bs-theme","dark"):document.documentElement.setAttribute("data-bs-theme",e)};a(t());const r=(e,t=!1)=>{const a=document.querySelector("#bd-theme");if(!a)return;const r=document.querySelector(".theme-icon-active i"),o=document.querySelector(`[data-bs-theme-value="${e}"]`),s=o.querySelector(".theme-icon i").getAttribute("class");document.querySelectorAll("[data-bs-theme-value]").forEach(e=>{e.classList.remove("active"),e.setAttribute("aria-pressed","false")}),o.classList.add("active"),o.setAttribute("aria-pressed","true"),r.setAttribute("class",s),t&&a.focus()};window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{const r=e();"light"!==r&&"dark"!==r&&a(t())}),window.addEventListener("DOMContentLoaded",()=>{r(t()),document.querySelectorAll("[data-bs-theme-value]").forEach(e=>{e.addEventListener("click",()=>{const t=e.getAttribute("data-bs-theme-value");(e=>{localStorage.setItem("theme",e)})(t),a(t),r(t,!0)})})})})(),document.addEventListener("DOMContentLoaded",function(){"use strict";const e=document.getElementById("tabSelector"),t=window.location.hash.substring(1);if(t){const a=`#nav-${t}`,r=document.querySelector(`[data-bs-target="${a}"]`);r&&(bootstrap.Tab.getOrCreateInstance(r).show(),e.value=a)}document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(e=>{e.addEventListener("shown.bs.tab",e=>{const t=e.target.getAttribute("data-bs-target").replace("nav-","");history.replaceState(null,null,t)})}),e&&(e.addEventListener("change",function(){const e=this.value,t=document.querySelector(`[data-bs-target="${e}"]`);if(t){bootstrap.Tab.getOrCreateInstance(t).show()}}),document.querySelectorAll('[data-bs-toggle="tab"]').forEach(t=>{t.addEventListener("shown.bs.tab",t=>{const a=t.target.getAttribute("data-bs-target");e.value=a})}))}),document.addEventListener("DOMContentLoaded",function(){"use strict";const e=document.querySelectorAll(".needs-validation");Array.from(e).forEach(e=>{e.addEventListener("submit",t=>{e.checkValidity()||(t.preventDefault(),t.stopPropagation()),e.classList.add("was-validated")},!1)})});

View File

@@ -1,7 +0,0 @@
# Compile main.min.js
```bash
$ npm install terser
$ npx terser theme_selector.js tabs_selector.js -c -m -o ../main.min.js
```

View File

@@ -1,43 +0,0 @@
// use Bootstrap 5's Tab component to manage tab navigation and synchronize with URL hash
document.addEventListener("DOMContentLoaded", function () {
'use strict';
const selectElement = document.getElementById('tabSelector');
// code to handle tab selection and URL hash synchronization
const hash = window.location.hash.substring(1) // remove the '#' prefix
if (hash) {
const target = `#nav-${hash}`;
const trigger = document.querySelector(`[data-bs-target="${target}"]`);
if (trigger) {
bootstrap.Tab.getOrCreateInstance(trigger).show();
selectElement.value = target; // keep the dropdown in sync
}
}
// update the URL hash when a tab is shown
document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(btn => {
btn.addEventListener('shown.bs.tab', event => {
const newHash = event.target.getAttribute('data-bs-target').replace('nav-', '');
history.replaceState(null, null, newHash);
});
});
// allow tab selection via a dropdown on small screens
if (!selectElement) return;
selectElement.addEventListener('change', function () {
const target = this.value;
const trigger = document.querySelector(`[data-bs-target="${target}"]`);
if (trigger) {
const tabInstance = bootstrap.Tab.getOrCreateInstance(trigger);
tabInstance.show();
}
});
// keep the dropdown in sync if the user clicks a tab button
document.querySelectorAll('[data-bs-toggle="tab"]').forEach(btn => {
btn.addEventListener('shown.bs.tab', event => {
const target = event.target.getAttribute('data-bs-target');
selectElement.value = target;
});
});
});

View File

@@ -1,76 +0,0 @@
/*!
* 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)
})
})
})
})()

View File

@@ -1,15 +0,0 @@
document.addEventListener("DOMContentLoaded", function () {
'use strict'
const forms = document.querySelectorAll('.needs-validation')
Array.from(forms).forEach(form => {
form.addEventListener('submit', event => {
if (!form.checkValidity()) {
event.preventDefault()
event.stopPropagation()
}
form.classList.add('was-validated')
}, false)
})
});

View File

@@ -1,26 +0,0 @@
{% if documents %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="3" scope="row">{{ header|default:"Documents" }}</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for d in documents.all %}
<tr>
<td class="w-33">{{ d.description }}</td>
<td class="text-nowrap">
{% if d.private %}
<i class="bi bi-file-earmark-lock2"></i>
{% else %}
<i class="bi bi-file-earmark-text"></i>
{% endif %}
<a href="{{ d.file.url }}" target="_blank">{{ d.filename }}</a>
</td>
<td class="text-end">{{ d.file.size | filesizeformat }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}

View File

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

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

View File

@@ -1,26 +0,0 @@
{% if documents %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="3" scope="row">{{ header|default:"Documents" }}</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for d in documents.all %}
<tr>
<td class="w-33">{{ d.description }}</td>
<td class="text-nowrap">
{% if d.private %}
<i class="bi bi-file-earmark-lock2"></i>
{% else %}
<i class="bi bi-file-earmark-text"></i>
{% endif %}
<a href="{{ d.file.url }}" target="_blank">{{ d.filename }}</a>
</td>
<td class="text-end">{{ d.file.size | filesizeformat }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}

View File

@@ -1,18 +0,0 @@
{% if properties %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Properties</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for p in properties %}
<tr>
<th class="w-33" scope="row">{{ p.property }}</th>
<td>{{ p.value }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}

View File

@@ -1,29 +0,0 @@
{% if request.user.is_staff %}
{% if data.shop or data.purchase_date or data.price %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Purchase</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Shop</th>
<td>
{{ data.shop|default:"-" }}
{% if data.shop.website %} <a href="{{ data.shop.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr>
<tr>
<th class="w-33" scope="row">Purchase date</th>
<td>{{ data.purchase_date|default:"-" }}</td>
</tr>
<tr>
<th scope="row">Price ({{ site_conf.currency }})</th>
<td>{{ data.price|default:"-" }}</td>
</tr>
</tbody>
</table>
{% endif %}
{% endif %}

View File

@@ -9,24 +9,129 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="description" content="{{ site_conf.about|striptags }}"> <meta name="description" content="{{ site_conf.about}}">
<meta name="author" content="{{ site_conf.site_author }}"> <meta name="author" content="{{ site_conf.site_author }}">
<meta name="generator" content="Django Framework"> <meta name="generator" content="Django Framework">
<title>{% block title %}{{ title }}{% endblock %} - {{ site_conf.site_name }}</title> <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.png" %}" sizes="any">
<link rel="icon" href="{% static "favicon.svg" %}" type="image/svg+xml"> <link rel="icon" href="{% static "favicon.svg" %}" type="image/svg+xml">
{% if site_conf.use_cdn %} {% if site_conf.use_cdn %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
{% else %} {% else %}
<link href="{% static "bootstrap@5.3.8/dist/css/bootstrap.min.css" %}" rel="stylesheet"> <link href="{% static "bootstrap@5.3.3/dist/css/bootstrap.min.css" %}" rel="stylesheet">
<link href="{% static "bootstrap-icons@1.13.1/font/bootstrap-icons.css" %}" rel="stylesheet"> <link href="{% static "bootstrap-icons@1.11.3/font/bootstrap-icons.css" %}" rel="stylesheet">
{% endif %} {% endif %}
<link href="{% static "css/main.min.css" %}?v={{ site_conf.version }}" rel="stylesheet"> <link href="{% static "css/main.css" %}?v={{ site_conf.version }}" rel="stylesheet">
<script src="{% static "js/main.min.js" %}?v={{ site_conf.version }}"></script> <style>
.bd-placeholder-img {
font-size: 1.125rem;
text-anchor: middle;
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
}
@media (min-width: 768px) {
.bd-placeholder-img-lg {
font-size: 3.5rem;
}
}
</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>
<script>
document.addEventListener('DOMContentLoaded', function () {
var selectElement = document.getElementById('tabSelector');
try {
selectElement.addEventListener('change', function () {
var selectedTabId = this.value;
var tabs = document.querySelectorAll('.tab-pane');
tabs.forEach(function (tab) {
tab.classList.remove('show', 'active');
});
document.getElementById(selectedTabId).classList.add('show', 'active');
});
} catch (TypeError) { /* pass */ }
});
</script>
{% block extra_head %} {% block extra_head %}
{% if site_conf.extra_html %}{{ site_conf.extra_html | safe }}{% endif %} {{ site_conf.extra_head | safe }}
{% if site_conf.extra_js %}<script src="{% url 'extra_js' %}"></script>{% endif %}
{% endblock %} {% endblock %}
</head> </head>
<body> <body>
@@ -35,15 +140,19 @@
<div class="container d-flex"> <div class="container d-flex">
<div class="me-auto"> <div class="me-auto">
<a href="{% url 'index' %}" class="navbar-brand d-flex align-items-center"> <a href="{% url 'index' %}" class="navbar-brand d-flex align-items-center">
<svg class="me-2" width="32" height="16" version="1.0" viewBox="0 0 24 12" xmlns="http://www.w3.org/2000/svg"> <svg class="me-2" width="26" height="16" viewBox="0 0 26 16" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(.0039261 0 0 -.0039261 -1.4249 18.53)"> <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" />
<path d="m813 4723-103-4v-309h-355l14-330h369l6-42c39-273 39-1414 0-1659l-7-39h-368l-14-330h355v-318l41-7c23-4 126-7 229-7s206 3 229 7l41 7v318h670v-318l41-7c23-4 126-7 229-7s206 3 229 7l41 7v318h670v-318l37-7c48-9 432-9 472 0l31 7v318h680v-318l31-7c39-9 423-9 469 0l35 6v314l338 3 337 2v-318l38-7c48-9 416-9 465 0l37 7v318h335v2400h-335v307l-135 6c-74 3-196 3-270 0l-135-6v-307l-337 2-338 3v302l-132 6c-73 3-194 3-268 0l-135-6v-307h-680v307l-135 6c-74 3-196 3-270 0l-135-6v-307h-670v307l-135 6c-74 3-196 3-270 0l-135-6v-307h-670v310h-63c-35 0-111 2-168 4s-150 1-206-1zm1141-666c3-12 11-97 18-187 24-309 11-1402-18-1507l-6-23h-725l-7 32c-39 197-39 1454 0 1676l6 32h726zm1218-42c20-182 30-569 25-940-6-371-21-707-33-727-3-4-169-8-368-8h-363l-7 48c-38 277-38 1365 1 1647l6 45 366-2 366-3zm1203 53c39-103 45-1264 9-1660l-7-68h-735l-6 68c-35 381-35 1263 0 1610l6 62h364c283 0 366-3 369-12zm1219-42c37-316 37-1287 0-1628l-7-58h-734l-6 73c-37 424-31 1544 8 1655 3 9 86 12 368 12h364zm841-1686c-336 0-363 1-370 18-3 9-13 152-22 317-21 431-7 1292 23 1388 5 16 31 17 369 17z"/> <style>
</g> path {
text-indent:0;
text-transform:none;
}
</style>
</svg> </svg>
<strong>{{ site_conf.site_name }}</strong> <strong>{{ site_conf.site_name }}</strong>
</a> </a>
</div> </div>
{% include '_includes/login.html' %} {% include 'includes/login.html' %}
</div> </div>
</nav> </nav>
</header> </header>
@@ -81,7 +190,7 @@
{% show_bookshelf_menu %} {% show_bookshelf_menu %}
{% show_flatpages_menu user %} {% show_flatpages_menu user %}
</ul> </ul>
{% include '_includes/search.html' %} {% include 'includes/search.html' %}
</div> </div>
</div> </div>
</nav> </nav>
@@ -106,13 +215,12 @@
<div class="container">{% block pagination %}{% endblock %}</div> <div class="container">{% block pagination %}{% endblock %}</div>
</div> </div>
{% block extra_content %}{% endblock %} {% block extra_content %}{% endblock %}
{% include '_includes/symbols.html' %}
</main> </main>
{% include '_includes/footer.html' %} {% include 'includes/footer.html' %}
{% if site_conf.use_cdn %} {% if site_conf.use_cdn %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% else %} {% else %}
<script src="{% static "bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" %}"></script> <script src="{% static "bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" %}"></script>
{% endif %} {% endif %}
</body> </body>
</html> </html>

View File

@@ -2,23 +2,20 @@
{% load dynamic_url %} {% load dynamic_url %}
{% block header %} {% block header %}
{% if data.tags.all %} {% if book.tags.all %}
<p><small>Tags:</small> <p><small>Tags:</small>
{% for t in data.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary"> {% 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 #} {{ t.name }}</a>{# new line is required #}
{% endfor %} {% endfor %}
</p> </p>
{% endif %} {% endif %}
{% if not data.published %} <small class="text-body-secondary">Updated {{ book.updated_time | date:"M d, Y H:i" }}</small>
<span class="badge text-bg-warning">Unpublished</span> |
{% endif %}
<small class="text-body-secondary">Updated {{ data.updated_time | date:"M d, Y H:i" }}</small>
{% endblock %} {% endblock %}
{% block carousel %} {% block carousel %}
<div class="row"> <div class="row">
<div id="carouselControls" class="carousel carousel-dark slide" data-bs-ride="carousel" data-bs-interval="10000"> <div id="carouselControls" class="carousel carousel-dark slide" data-bs-ride="carousel" data-bs-interval="10000">
<div class="carousel-inner"> <div class="carousel-inner">
{% for t in data.image.all %} {% for t in book.image.all %}
{% if forloop.first %} {% if forloop.first %}
<div class="carousel-item active"> <div class="carousel-item active">
{% else %} {% else %}
@@ -28,7 +25,7 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% if data.image.count > 1 %} {% if book.image.count > 1 %}
<button class="carousel-control-prev" type="button" data-bs-target="#carouselControls" data-bs-slide="prev"> <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="carousel-control-prev-icon" aria-hidden="true"></span>
<span class="visually-hidden"><i class="bi bi-chevron-left"></i></span> <span class="visually-hidden"><i class="bi bi-chevron-left"></i></span>
@@ -49,13 +46,11 @@
<div class="mx-auto"> <div class="mx-auto">
<nav class="nav nav-tabs d-none d-lg-flex flex-row mb-2" id="nav-tab" role="tablist"> <nav class="nav nav-tabs d-none d-lg-flex flex-row mb-2" id="nav-tab" role="tablist">
<button class="nav-link active" id="nav-summary-tab" data-bs-toggle="tab" data-bs-target="#nav-summary" type="button" role="tab" aria-controls="nav-summary" aria-selected="true">Summary</button> <button class="nav-link 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 data.toc.all %}<button class="nav-link" id="nav-toc-tab" data-bs-toggle="tab" data-bs-target="#nav-toc" type="button" role="tab" aria-controls="nav-toc" aria-selected="true">Table of contents</button>{% endif %}
{% if documents %}<button class="nav-link" id="nav-documents-tab" data-bs-toggle="tab" data-bs-target="#nav-documents" type="button" role="tab" aria-controls="nav-documents" aria-selected="false">Documents</button>{% endif %} {% if documents %}<button class="nav-link" id="nav-documents-tab" data-bs-toggle="tab" data-bs-target="#nav-documents" type="button" role="tab" aria-controls="nav-documents" aria-selected="false">Documents</button>{% endif %}
</nav> </nav>
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector"> <select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
<option value="#nav-summary" selected>Summary</option> <option value="nav-summary" selected>Summary</option>
{% if data.toc.all %}<option value="#nav-toc">Table of contents</option>{% endif %} {% if documents %}<option value="nav-documents">Documents</option>{% endif %}
{% if documents %}<option value="#nav-documents">Documents</option>{% endif %}
</select> </select>
<div class="tab-content" id="nav-tabContent"> <div class="tab-content" id="nav-tabContent">
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab"> <div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
@@ -63,123 +58,131 @@
<thead> <thead>
<tr> <tr>
<th colspan="2" scope="row"> <th colspan="2" scope="row">
{{ data.obj_label|capfirst }} {% if type == "catalog" %}Catalog
{% elif type == "book" %}Book{% endif %}
<div class="float-end">
{% if not book.published %}
<span class="badge text-bg-warning">Draft</span>
{% endif %}
</div>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody class="table-group-divider"> <tbody class="table-group-divider">
{% if data.obj_type == "catalog" %} {% if type == "catalog" %}
<tr> <tr>
<th class="w-33" scope="row">Manufacturer</th> <th class="w-33" scope="row">Manufacturer</th>
<td> <td>{{ book.manufacturer }}</td>
<a href="{% url 'filtered' _filter="manufacturer" search=data.manufacturer.slug %}">{{ data.manufacturer }}{% if data.manufacturer.website %}</a> <a href="{{ data.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Scales</th> <th class="w-33" scope="row">Scales</th>
<td>{{ data.get_scales }}</td> <td>{{ book.get_scales }}</td>
</tr> </tr>
{% elif data.obj_type == "book" %} {% elif type == "book" %}
<tr> <tr>
<th class="w-33" scope="row">Title</th> <th class="w-33" scope="row">Title</th>
<td>{{ data.title }}</td> <td>{{ book.title }}</td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Authors</th> <th class="w-33" scope="row">Authors</th>
<td> <td>
<ul class="mb-0 list-unstyled">{% for a in data.authors.all %}<li>{{ a }}</li>{% endfor %}</ul> <ul class="mb-0 list-unstyled">{% for a in book.authors.all %}<li>{{ a }}</li>{% endfor %}</ul>
</td> </td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Publisher</th> <th class="w-33" scope="row">Publisher</th>
<td> <td>{{ book.publisher }}</td>
<img src="{{ data.publisher.country.flag }}" alt="{{ data.publisher.country }}"> {{ data.publisher }}
{% if data.publisher.website %} <a href="{{ data.publisher.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr>
{% elif data.obj_type == "magazineissue" %}
<tr>
<th class="w-33" scope="row">Magazine</th>
<td>
<a href="{% url 'magazine' data.magazine.pk %}">{{ data.magazine }}</a>
{% if data.magazine.website %} <a href="{{ data.magazine.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr>
<tr>
<th class="w-33" scope="row">Publisher</th>
<td>
<img src="{{ data.publisher.country.flag }}" alt="{{ data.publisher.country }}"> {{ data.publisher }}
{% if data.publisher.website %} <a href="{{ data.publisher.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr>
<tr>
<th class="w-33" scope="row">Issue</th>
<td>{{ data.issue_number }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Date</th>
<td>{{ data.publication_year|default:"-" }} / {{ data.get_publication_month_display|default:"-" }}</td>
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
<th scope="row">ISBN</th> <th scope="row">ISBN</th>
<td>{{ data.ISBN|default:"-" }}</td> <td>{{ book.ISBN|default:"-" }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Language</th> <th scope="row">Language</th>
<td>{{ data.get_language_display }}</td> <td>{{ book.get_language_display }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Number of pages</th> <th scope="row">Number of pages</th>
<td>{{ data.number_of_pages|default:"-" }}</td> <td>{{ book.number_of_pages|default:"-" }}</td>
</tr> </tr>
{% if data.obj_type == "book" or data.obj_type == "catalog" %}
<tr> <tr>
<th scope="row">Publication year</th> <th scope="row">Publication year</th>
<td>{{ data.publication_year|default:"-" }}</td> <td>{{ book.publication_year|default:"-" }}</td>
</tr> </tr>
{% endif %} {% if book.description %}
{% if data.description %}
<tr> <tr>
<th class="w-33" scope="row">Description</th> <th class="w-33" scope="row">Description</th>
<td>{{ data.description | safe }}</td> <td>{{ book.description | safe }}</td>
</tr> </tr>
{% endif %} {% endif %}
</tbody> </tbody>
</table> </table>
{% include "_modules/purchase_data.html" %} {% if request.user.is_staff %}
{% include "_modules/properties.html" %}
</div>
<div class="tab-pane table-responsive" id="nav-toc" role="tabpanel" aria-labelledby="nav-toc-tab">
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th scope="row">Title</th> <th colspan="2" scope="row">Purchase</th>
<th scope="row">Subtitle</th>
<th scope="row">Authors</th>
<th scope="row">Page</th>
<th scope="row"><abbr title="Featured article"><i class="bi bi-star-fill"></i></abbr></th>
</tr> </tr>
</thead> </thead>
<tbody class="table-group-divider"> <tbody class="table-group-divider">
{% for toc in data.toc.all %}
<tr> <tr>
<td class="w-33">{{ toc.title }}</td> <th class="w-33" scope="row">Shop</th>
<td class="w-33">{{ toc.subtitle }}</td> <td>
<td>{{ toc.authors }}</td> {{ book.shop|default:"-" }}
<td>{{ toc.page }}</td> {% if book.shop.website %} <a href="{{ book.shop.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
<td>{% if toc.featured %}<abbr title="Featured article"><i class="bi bi-star-fill text-warning"></i></abbr>{% endif %}</td> </td>
</tr> </tr>
{% endfor %} <tr>
<th class="w-33" scope="row">Purchase date</th>
<td>{{ book.purchase_date|default:"-" }}</td>
</tr>
<tr>
<th scope="row">Price ({{ site_conf.currency }})</th>
<td>{{ book.price|default:"-" }}</td>
</tr>
</tbody>
</table>
{% endif %}
{% if properties %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Properties</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for p in properties %}
<tr>
<th class="w-33" scope="row">{{ p.property }}</th>
<td>{{ p.value }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
<div class="tab-pane" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
<table class="table table-striped">
<thead>
<tr>
<th colspan="3" scope="row">Documents</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for d in documents.all %}
<tr>
<td class="w-33">{{ d.description }}</td>
<td><a href="{{ d.file.url }}" target="_blank">{{ d.filename }}</a></td>
<td class="text-end">{{ d.file.size | filesizeformat }}</td>
</tr>
{% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="tab-pane" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
{% include "_modules/documents.html" %}
</div>
</div> </div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end"> <div class="d-grid gap-2 d-md-flex justify-content-md-end">
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% dynamic_admin_url 'bookshelf' data.obj_type data.pk %}">Edit</a>{% endif %} {% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% dynamic_admin_url 'bookshelf' type book.pk %}">Edit</a>{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -10,9 +10,6 @@
{% if catalogs_menu %} {% if catalogs_menu %}
<li><a class="dropdown-item" href="{% url 'catalogs' %}">Catalogs</a></li> <li><a class="dropdown-item" href="{% url 'catalogs' %}">Catalogs</a></li>
{% endif %} {% endif %}
{% if magazines_menu %}
<li><a class="dropdown-item" href="{% url 'magazines' %}">Magazines</a></li>
{% endif %}
</ul> </ul>
</li> </li>
{% endif %} {% endif %}

View File

@@ -1,128 +0,0 @@
{% extends "cards.html" %}
{% block header %}
{{ block.super }}
{% if magazine.tags.all %}
<p><small>Tags:</small>
{% for t in magazine.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>
{% if not magazine.published %}
<span class="badge text-bg-warning">Unpublished</span> |
{% endif %}
<small class="text-body-secondary">Updated {{ magazine.updated_time | date:"M d, Y H:i" }}</small>
{% endif %}
{% endblock %}
{% block carousel %}
{% if magazine.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="{{ magazine.image.url }}" class="d-block w-100 rounded img-thumbnail" alt="magazine cover">
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block pagination %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation">
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'magazine' uuid=magazine.uuid page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-left"></i></span>
</li>
{% endif %}
{% for i in page_range %}
{% if data.number == i %}
<li class="page-item active">
<span class="page-link">{{ i }}</span>
</li>
{% else %}
{% if i == data.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %}
<li class="page-item"><a class="page-link" href="{% url 'magazine' uuid=magazine.uuid page=i %}#main-content">{{ i }}</a></li>
{% endif %}
{% endif %}
{% endfor %}
{% if data.has_next %}
<li class="page-item">
<a class="page-link" href="{% url 'magazine' uuid=magazine.uuid page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link"><i class="bi bi-chevron-right"></i></span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}
{% block extra_content %}
<section class="py-4 text-start container">
<div class="row">
<div class="mx-auto">
<nav class="nav nav-tabs d-none d-lg-flex flex-row mb-2" id="nav-tab" role="tablist">
<button class="nav-link active" id="nav-summary-tab" data-bs-toggle="tab" data-bs-target="#nav-summary" type="button" role="tab" aria-controls="nav-summary" aria-selected="true">Summary</button>
</nav>
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
<option value="#nav-summary" selected>Summary</option>
</select>
<div class="tab-content" id="nav-tabContent">
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">
Magazine
</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Name</th>
<td>{{ magazine }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Publisher</th>
<td>
<img src="{{ magazine.publisher.country.flag }}" alt="{{ magazine.publisher.country }}"> {{ magazine.publisher }}
{% if magazine.publisher.website %} <a href="{{ magazine.publisher.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr>
<tr>
<th class="w-33" scope="row">Website</th>
<td>{% if magazine.website %}<a href="{{ magazine.website }}" target="_blank">{{ magazine.website_short }}</td>{% else %}-{% endif %}</td>
</tr>
<tr>
<th class="w-33" scope="row">Language</th>
<td>{{ magazine.get_language_display }}</td>
</tr>
<tr>
<th scope="row">ISBN</th>
<td>{{ magazine.ISBN | default:"-" }}</td>
</tr>
{% if magazine.description %}
<tr>
<th scope="row">Description</th>
<td>{{ magazine.description | safe }}</td>
</tr>
{% endif %}
</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:bookshelf_magazine_change' magazine.pk %}">Edit</a>{% endif %}
</div>
</div>
</div>
</section>
{% endblock %}

View File

@@ -6,21 +6,19 @@
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3"> <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
{% block cards %} {% block cards %}
{% for d in data %} {% for d in data %}
{% if d.obj_type == "rollingstock" %} {% if d.type == "roster" %}
{% include "cards/roster.html" %} {% include "cards/roster.html" %}
{% elif d.obj_type == "company" %} {% elif d.type == "company" %}
{% include "cards/company.html" %} {% include "cards/company.html" %}
{% elif d.obj_type == "rollingstocktype" %} {% elif d.type == "rolling_stock_type" %}
{% include "cards/rolling_stock_type.html" %} {% include "cards/rolling_stock_type.html" %}
{% elif d.obj_type == "scale" %} {% elif d.type == "scale" %}
{% include "cards/scale.html" %} {% include "cards/scale.html" %}
{% elif d.obj_type == "consist" %} {% elif d.type == "consist" %}
{% include "cards/consist.html" %} {% include "cards/consist.html" %}
{% elif d.obj_type == "manufacturer" %} {% elif d.type == "manufacturer" %}
{% include "cards/manufacturer.html" %} {% include "cards/manufacturer.html" %}
{% elif d.obj_type == "magazine" or d.obj_type == "magazineissue" %} {% elif d.type == "book" or d.type == "catalog" %}
{% include "cards/magazine.html" %}
{% elif d.obj_type == "book" or d.obj_type == "catalog" %}
{% include "cards/book.html" %} {% include "cards/book.html" %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View File

@@ -2,78 +2,77 @@
{% load dynamic_url %} {% load dynamic_url %}
<div class="col"> <div class="col">
<div class="card shadow-sm"> <div class="card shadow-sm">
{% if d.image.exists %} {% if d.item.image.exists %}
<a href="{{d.get_absolute_url}}"><img class="card-img-top" src="{{ d.image.first.image.url }}" alt="{{ d }}"></a> <a href="{{d.item.get_absolute_url}}"><img class="card-img-top" src="{{ d.item.image.first.image.url }}" alt="{{ d.item }}"></a>
{% else %} {% else %}
<!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) --> <!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
<a href="{{d.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d }}"></a> <a href="{{d.item.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d.item }}"></a>
{% endif %} {% endif %}
<div class="card-body"> <div class="card-body">
<p class="card-text" style="position: relative;"> <p class="card-text" style="position: relative;">
<strong>{{ d }}</strong> <strong>{{ d.item }}</strong>
<a class="stretched-link" href="{{ d.get_absolute_url }}"></a> <a class="stretched-link" href="{{ d.item.get_absolute_url }}"></a>
</p> </p>
{% if d.item.tags.all %}
<p class="card-text"><small>Tags:</small> <p class="card-text"><small>Tags:</small>
{% for t in d.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary"> {% 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 #} {{ t.name }}</a>{# new line is required #}
{% empty %}
<span class="badge rounded-pill bg-secondary"><i class="bi bi-ban"></i></span>
{% endfor %} {% endfor %}
</p> </p>
{% endif %}
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th colspan="2" scope="row"> <th colspan="2" scope="row">
{{ d.obj_label|capfirst }} {% if d.type == "catalog" %}Catalog
{% elif d.type == "book" %}Book{% endif %}
<div class="float-end"> <div class="float-end">
{% if not d.published %} {% if not d.item.published %}
<span class="badge text-bg-warning">Unpublished</span> <span class="badge text-bg-warning">Draft</span>
{% endif %} {% endif %}
</div> </div>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody class="table-group-divider"> <tbody class="table-group-divider">
{% if d.obj_type == "catalog" %} {% if d.type == "catalog" %}
<tr> <tr>
<th class="w-33" scope="row">Manufacturer</th> <th class="w-33" scope="row">Manufacturer</th>
<td> <td>{{ d.item.manufacturer }}</td>
<a href="{% url 'filtered' _filter="manufacturer" search=d.manufacturer.slug %}">{{ d.manufacturer }}{% if d.manufacturer.website %}</a> <a href="{{ d.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Scales</th> <th class="w-33" scope="row">Scales</th>
<td>{{ d.get_scales }}</td> <td>{{ d.item.get_scales }}</td>
</tr> </tr>
{% elif d.obj_type == "book" %} {% elif d.type == "book" %}
<tr> <tr>
<th class="w-33" scope="row">Authors</th> <th class="w-33" scope="row">Authors</th>
<td> <td>
<ul class="mb-0 list-unstyled">{% for a in d.authors.all %}<li>{{ a }}</li>{% endfor %}</ul> <ul class="mb-0 list-unstyled">{% for a in d.item.authors.all %}<li>{{ a }}</li>{% endfor %}</ul>
</td> </td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Publisher</th> <th class="w-33" scope="row">Publisher</th>
<td><img src="{{ d.publisher.country.flag }}" alt="{{ d.publisher.country }}"> {{ d.publisher }}</td> <td>{{ d.item.publisher }}</td>
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
<th scope="row">Language</th> <th scope="row">Language</th>
<td>{{ d.get_language_display }}</td> <td>{{ d.item.get_language_display }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Pages</th> <th scope="row">Pages</th>
<td>{{ d.number_of_pages|default:"-" }}</td> <td>{{ d.item.number_of_pages|default:"-" }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Year</th> <th scope="row">Year</th>
<td>{{ d.publication_year|default:"-" }}</td> <td>{{ d.item.publication_year|default:"-" }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="d-grid gap-2 mb-1 d-md-block"> <div class="d-grid gap-2 mb-1 d-md-block">
<a class="btn btn-sm btn-outline-primary" href="{{ d.get_absolute_url }}">Show all data</a> <a class="btn btn-sm btn-outline-primary" href="{{ d.item.get_absolute_url }}">Show all data</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% dynamic_admin_url 'bookshelf' d.obj_type d.pk %}">Edit</a>{% endif %} {% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% dynamic_admin_url 'bookshelf' d.type d.item.pk %}">Edit</a>{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,7 +2,7 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
<p class="card-text" style="position: relative;"> <p class="card-text" style="position: relative;">
<strong>{{ d.name }}</strong> <strong>{{ d.item.name }}</strong>
</p> </p>
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
@@ -10,7 +10,7 @@
<th colspan="2" scope="row"> <th colspan="2" scope="row">
Company Company
<div class="float-end"> <div class="float-end">
{% if d.freelance %} {% if d.item.freelance %}
<span class="badge text-bg-secondary">Freelance</span> <span class="badge text-bg-secondary">Freelance</span>
{% endif %} {% endif %}
</div> </div>
@@ -18,31 +18,29 @@
</tr> </tr>
</thead> </thead>
<tbody class="table-group-divider"> <tbody class="table-group-divider">
{% if d.logo %} {% if d.item.logo %}
<tr> <tr>
<th class="w-33" scope="row">Logo</th> <th class="w-33" scope="row">Logo</th>
<td><img class="logo" src="{{ d.logo.url }}" alt="{{ d.name }} logo"></td> <td><img class="logo" src="{{ d.item.logo.url }}" alt="{{ d.item.name }} logo"></td>
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
<th class="w-33" scope="row">Name</th> <th class="w-33" scope="row">Name</th>
<td>{{ d.extended_name }}</td> <td>{{ d.item.extended_name }}</td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Abbreviation</th> <th class="w-33" scope="row">Abbreviation</th>
<td>{{ d.name }}</td> <td>{{ d.item.name }}</td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Country</th> <th class="w-33" scope="row">Country</th>
<td><img src="{{ d.country.flag }}" alt="{{ d.country }}"> {{ d.country.name }}</td> <td><img src="{{ d.item.country.flag }}" alt="{{ d.item.country }}"> {{ d.item.country.name }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="d-grid gap-2 mb-1 d-md-block"> <div class="d-grid gap-2 mb-1 d-md-block">
{% with items=d.num_items %} <a class="btn btn-sm btn-outline-primary" href="{% url 'filtered' _filter="company" search=d.item.slug %}">Show all rolling stock</a>
<a class="btn btn-sm btn-outline-primary{% if items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="company" search=d.slug %}">Show {{ items }} item{{ items | pluralize}}</a> {% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_company_change' d.item.pk %}">Edit</a>{% endif %}
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_company_change' d.pk %}">Edit</a>{% endif %}
{% endwith %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,70 +1,66 @@
{% load static %}
<div class="col"> <div class="col">
<div class="card shadow-sm"> <div class="card shadow-sm">
<a href="{{ d.get_absolute_url }}"> <a href="{{ d.item.get_absolute_url }}">
{% if d.get_cover %} {% if d.item.image %}
<img class="card-img-top" src="{{ d.get_cover.url }}" alt="{{ d }}"> <img class="card-img-top" src="{{ d.item.image.url }}" alt="{{ d.item }}">
{% else %} {% else %}
<!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) --> {% with d.item.consist_item.first.rolling_stock as r %}
<a href="{{d.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d }}"></a> <img class="card-img-top" src="{{ r.image.first.image.url }}" alt="{{ d.item }}">
{% endwith %}
{% endif %} {% endif %}
</a> </a>
<div class="card-body"> <div class="card-body">
<p class="card-text" style="position: relative;"> <p class="card-text" style="position: relative;">
<strong>{{ d }}</strong> <strong>{{ d.item }}</strong>
<a class="stretched-link" href="{{ d.get_absolute_url }}"></a> <a class="stretched-link" href="{{ d.item.get_absolute_url }}"></a>
</p> </p>
{% if d.item.tags.all %}
<p class="card-text"><small>Tags:</small> <p class="card-text"><small>Tags:</small>
{% for t in d.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary"> {% 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 #} {{ t.name }}</a>{# new line is required #}
{% empty %}
<span class="badge rounded-pill bg-secondary"><i class="bi bi-ban"></i></span>
{% endfor %} {% endfor %}
</p> </p>
{% endif %}
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th colspan="2" scope="row"> <th colspan="2" scope="row">
Consist Consist
<div class="float-end"> <div class="float-end">
{% if not d.published %} {% if d.item.company.freelance %}
<span class="badge text-bg-warning">Unpublished</span>
{% endif %}
{% if d.company.freelance %}
<span class="badge text-bg-secondary">Freelance</span> <span class="badge text-bg-secondary">Freelance</span>
{% endif %} {% endif %}
{% if not d.item.published %}
<span class="badge text-bg-warning">Draft</span>
{% endif %}
</div> </div>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody class="table-group-divider"> <tbody class="table-group-divider">
{% if d.address %} {% if d.item.address %}
<tr> <tr>
<th class="w-33" scope="row">Address</th> <th class="w-33" scope="row">Address</th>
<td>{{ d.address }}</td> <td>{{ d.item.address }}</td>
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
<th class="w-33" scope="row">Company</th> <th class="w-33" scope="row">Company</th>
<td> <td><abbr title="{{ d.item.company.extended_name }}">{{ d.item.company }}</abbr></td>
<img src="{{ d.company.country.flag }}" alt="{{ d.company.country }}">
<abbr title="{{ d.company.extended_name }}">{{ d.company }}</abbr>
</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Era</th> <th scope="row">Era</th>
<td>{{ d.era }}</td> <td>{{ d.item.era }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Length</th> <th scope="row">Length</th>
<td>{{ d.length }}</td> <td>{{ d.item.consist_item.count }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="d-grid gap-2 mb-1 d-md-block"> <div class="d-grid gap-2 mb-1 d-md-block">
<a class="btn btn-sm btn-outline-primary" href="{{ d.get_absolute_url }}">Show all data</a> <a class="btn btn-sm btn-outline-primary" href="{{ d.item.get_absolute_url }}">Show all data</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:consist_consist_change' d.pk %}">Edit</a>{% endif %} {% 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>
</div> </div>

View File

@@ -1,100 +0,0 @@
{% load static %}
{% load dynamic_url %}
<div class="col">
<div class="card shadow-sm">
{% if d.obj_type == "magazine" %}
<a href="{{ d.get_absolute_url }}">
{% if d.get_cover %}
<img class="card-img-top" src="{{ d.get_cover.url }}" alt="{{ d }}">
{% else %}
<!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
<img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d }}">
{% endif %}
</a>
{% elif d.obj_type == "magazineissue" %}
<a href="{{ d.get_absolute_url }}">
{% if d.image.exists %}
<img class="card-img-top" src="{{ d.image.first.image.url }}" alt="{{ d }}">
{% else %}
<!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
<img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d }}">
{% endif %}
</a>
{% endif %}
</a>
<div class="card-body">
<p class="card-text" style="position: relative;">
<strong>{{ d }}</strong>
<a class="stretched-link" href="{{ d.get_absolute_url }}"></a>
</p>
<p class="card-text"><small>Tags:</small>
{% for t in d.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary">
{{ t.name }}</a>{# new line is required #}
{% empty %}
<span class="badge rounded-pill bg-secondary"><i class="bi bi-ban"></i></span>
{% endfor %}
</p>
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">
{{ d.obj_label|capfirst }}
<div class="float-end">
{% if not d.published %}
<span class="badge text-bg-warning">Unpublished</span>
{% endif %}
</div>
</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% if d.obj_type == "magazineissue" %}
<tr>
<th class="w-33" scope="row">Magazine</th>
<td>{{ d.magazine }}</td>
</tr>
{% else %}
<tr>
<th class="w-33" scope="row">Website</th>
<td>{% if d.website %}<a href="{{ d.website }}" target="_blank">{{ d.website_short }}</td>{% else %}-{% endif %}</td>
</tr>
{% endif %}
<tr>
<th class="w-33" scope="row">Publisher</th>
<td>
<img src="{{ d.publisher.country.flag }}" alt="{{ d.publisher.country }}"> {{ d.publisher }}
{% if d.publisher.website %} <a href="{{ d.publisher.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr>
{% if d.obj_type == "magazineissue" %}
<tr>
<th class="w-33" scope="row">Issue</th>
<td>{{ d.issue_number }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Date</th>
<td>{{ d.publication_year|default:"-" }} / {{ d.get_publication_month_display|default:"-" }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Pages</th>
<td>{{ d.number_of_pages|default:"-" }}</td>
</tr>
{% endif %}
<tr>
<th class="w-33" scope="row">Language</th>
<td>{{ d.get_language_display }}</td>
</tr>
</tbody>
</table>
<div class="d-grid gap-2 mb-1 d-md-block">
{% if d.obj_type == "magazine" %}
<a class="btn btn-sm btn-outline-primary{% if d.issues == 0 %} disabled{% endif %}" href="{{ d.get_absolute_url }}">Show {{ d.issues }} issue{{ d.issues|pluralize }}</a>
{% else %}
<a class="btn btn-sm btn-outline-primary" href="{{ d.get_absolute_url }}">Show all data</a>
{% endif %}
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% dynamic_admin_url 'bookshelf' d.obj_type d.pk %}">Edit</a>{% endif %}
</div>
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
<p class="card-text" style="position: relative;"> <p class="card-text" style="position: relative;">
<strong>{{ d.name }}</strong> <strong>{{ d.item.name }}</strong>
</p> </p>
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
@@ -11,27 +11,27 @@
</tr> </tr>
</thead> </thead>
<tbody class="table-group-divider"> <tbody class="table-group-divider">
{% if d.logo %} {% if d.item.logo %}
<tr> <tr>
<th class="w-33" scope="row">Logo</th> <th class="w-33" scope="row">Logo</th>
<td><img class="logo" src="{{ d.logo.url }}" alt="{{ d.name }} logo"></td> <td><img class="logo" src="{{ d.item.logo.url }}" alt="{{ d.item.name }} logo"></td>
</tr>
{% endif %}
{% if d.item.website %}
<tr>
<th class="w-33" scope="row">Website</th>
<td><a href="{{ d.item.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a></td>
</tr> </tr>
{% endif %} {% endif %}
<tr>
<th class="w-33" scope="row">Website</th>
<td>{% if d.website %}<a href="{{ d.website }}" target="_blank">{{ d.website_short }}</td>{% else %}-{% endif %}</td>
</tr>
<tr> <tr>
<th class="w-33" scope="row">Category</th> <th class="w-33" scope="row">Category</th>
<td>{{ d.category | title }}</td> <td>{{ d.item.category | title }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="d-grid gap-2 mb-1 d-md-block"> <div class="d-grid gap-2 mb-1 d-md-block">
{% with items=d.num_items %} <a class="btn btn-sm btn-outline-primary" href="{% url 'filtered' _filter="manufacturer" search=d.item.slug %}">Show all rolling stock</a>
<a class="btn btn-sm btn-outline-primary{% if items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="manufacturer" search=d.slug %}">Show {{ items }} item{{ items|pluralize }}</a> {% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_manufacturer_change' d.item.pk %}">Edit</a>{% endif %}
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_manufacturer_change' d.pk %}">Edit</a>{% endif %}
{% endwith %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
<div class="col"> <div class="col">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
<p class="card-text"><strong>{{ d }}</strong></p> <p class="card-text"><strong>{{ d.item }}</strong></p>
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
@@ -11,19 +11,17 @@
<tbody class="table-group-divider"> <tbody class="table-group-divider">
<tr> <tr>
<th class="w-33" scope="row">Type</th> <th class="w-33" scope="row">Type</th>
<td>{{ d.type }}</td> <td>{{ d.item.type }}</td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Category</th> <th class="w-33" scope="row">Category</th>
<td>{{ d.category | title}}</td> <td>{{ d.item.category | title}}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="d-grid gap-2 mb-1 d-md-block"> <div class="d-grid gap-2 mb-1 d-md-block">
{% with items=d.num_items %} <a class="btn btn-sm btn-outline-primary" href="{% url 'filtered' _filter="type" search=d.item.slug %}">Show all rolling stock</a>
<a class="btn btn-sm btn-outline-primary{% if items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="type" search=d.slug %}">Show {{ items }} item{{ items | pluralize}}</a> {% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_scale_change' d.item.pk %}">Edit</a>{% endif %}
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_rollingstocktype_change' d.pk %}">Edit</a>{% endif %}
{% endwith %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,45 +1,36 @@
{% load static %} {% load static %}
{% load dcc %}
<div class="col"> <div class="col">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div id="card-img-container" class="position-relative"> {% if d.item.image.exists %}
{% if d.featured %} <a href="{{d.item.get_absolute_url}}"><img class="card-img-top" src="{{ d.item.image.first.image.url }}" alt="{{ d.item }}"></a>
<span class="position-absolute translate-middle top-0 start-0 m-3 text-danger"> {% else %}
<abbr title="Featured item"><i class="bi bi-heart-fill"></i></abbr> <!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
</span> <a href="{{d.item.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d.item }}"></a>
{% endif %} {% endif %}
{% if d.image.exists %}
<a href="{{d.get_absolute_url}}"><img class="card-img-top" src="{{ d.image.first.image.url }}" alt="{{ d }}"></a>
{% else %}
<!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
<a href="{{d.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d }}"></a>
{% endif %}
</div>
<div class="card-body"> <div class="card-body">
<p class="card-text" style="position: relative;"> <p class="card-text" style="position: relative;">
<strong>{{ d }}</strong> <strong>{{ d.item }}</strong>
<a class="stretched-link" href="{{ d.get_absolute_url }}"></a> <a class="stretched-link" href="{{ d.item.get_absolute_url }}"></a>
</p> </p>
{% if d.item.tags.all %}
<p class="card-text"><small>Tags:</small> <p class="card-text"><small>Tags:</small>
{% for t in d.tags.all %}<a href="{% url 'filtered' _filter="tag" search=t.slug %}" class="badge rounded-pill bg-primary"> {% 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 #} {{ t.name }}</a>{# new line is required #}
{% empty %}
<span class="badge rounded-pill bg-secondary"><i class="bi bi-ban"></i></span>
{% endfor %} {% endfor %}
</p> </p>
{% endif %}
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th colspan="2" scope="row"> <th colspan="2" scope="row">
Rolling stock Rolling stock
<div class="float-end"> <div class="float-end">
{% if not d.published %} {% if d.item.company.freelance %}
<span class="badge text-bg-warning">Unpublished</span>
{% endif %}
{% if d.company.freelance %}
<span class="badge text-bg-secondary">Freelance</span> <span class="badge text-bg-secondary">Freelance</span>
{% endif %} {% endif %}
{% if not d.item.published %}
<span class="badge text-bg-warning">Draft</span>
{% endif %}
</div> </div>
</th> </th>
</tr> </tr>
@@ -47,50 +38,70 @@
<tbody class="table-group-divider"> <tbody class="table-group-divider">
<tr> <tr>
<th class="w-33" scope="row">Type</th> <th class="w-33" scope="row">Type</th>
<td>{{ d.rolling_class.type }}</td> <td>{{ d.item.rolling_class.type }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Company</th> <th scope="row">Company</th>
<td> <td>
<img src="{{ d.company.country.flag }}" alt="{{ d.company.country }}"> <a href="{% url 'filtered' _filter="company" search=d.item.company.slug %}"><abbr title="{{ d.item.company.extended_name }}">{{ d.item.company }}</abbr></a>
<a href="{% url 'filtered' _filter="company" search=d.company.slug %}"><abbr title="{{ d.company.extended_name }}">{{ d.company }}</abbr></a>
</td> </td>
</tr> </tr>
<tr> <tr>
<th scope="row">Class</th> <th scope="row">Class</th>
<td>{{ d.rolling_class.identifier }}</td> <td>{{ d.item.rolling_class.identifier }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Road number</th> <th scope="row">Road number</th>
<td>{{ d.road_number }}</td> <td>{{ d.item.road_number }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Era</th> <th scope="row">Era</th>
<td>{{ d.era }}</td> <td>{{ d.item.era }}</td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Manufacturer</th> <th class="w-33" scope="row">Manufacturer</th>
<td>{%if d.manufacturer %} <td>{%if d.item.manufacturer %}
<a href="{% url 'filtered' _filter="manufacturer" search=d.manufacturer.slug %}">{{ d.manufacturer }}{% if d.manufacturer.website %}</a> <a href="{{ d.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %} <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> {% endif %}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Scale</th> <th scope="row">Scale</th>
<td><a href="{% url 'filtered' _filter="scale" search=d.scale.slug %}"><abbr title="{{ d.scale.ratio }} - {{ d.scale.tracks }} mm">{{ d.scale }}</abbr></a></td> <td><a href="{% url 'filtered' _filter="scale" search=d.item.scale.slug %}"><abbr title="{{ d.item.scale.ratio }} - {{ d.item.scale.tracks }} mm">{{ d.item.scale }}</abbr></a></td>
</tr> </tr>
<tr> <tr>
<th scope="row">Item number</th> <th scope="row">Item number</th>
<td>{{ d.item_number }}{%if d.set %} | <a class="badge text-bg-primary" href="{% url 'manufacturer' manufacturer=d.manufacturer.slug search=d.item_number_slug %}">SET</a>{% endif %}</td> <td>{{ d.item.item_number }}{%if d.item.set %} | <a class="badge text-bg-primary" href="{% url 'manufacturer' manufacturer=d.item.manufacturer.slug search=d.item.item_number_slug %}">SET</a>{% endif %}</td>
</tr>
<tr>
<th scope="row">DCC</th>
<td><a class="text-reset text-decoration-none" title="Symbols" href="" data-bs-toggle="modal" data-bs-target="#symbolsModal">{% dcc d %}</a></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
{% if d.item.decoder or d.item.decoder_interface %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">DCC data</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Interface</th>
<td>{{ d.item.get_decoder_interface }}</td>
</tr>
{% if d.item.decoder %}
<tr>
<th scope="row">Decoder</th>
<td>{{ d.item.decoder }}</td>
</tr>
<tr>
<th scope="row">Address</th>
<td>{{ d.item.address }}</td>
</tr>
{% endif %}
</tbody>
</table>
{% endif %}
<div class="d-grid gap-2 mb-1 d-md-block"> <div class="d-grid gap-2 mb-1 d-md-block">
<a class="btn btn-sm btn-outline-primary" href="{{d.get_absolute_url}}">Show all data</a> <a class="btn btn-sm btn-outline-primary" href="{{d.item.get_absolute_url}}">Show all data</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:roster_rollingstock_change' d.pk %}">Edit</a>{% endif %} {% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:roster_rollingstock_change' d.item.pk %}">Edit</a>{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
<div class="col"> <div class="col">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body"> <div class="card-body">
<p class="card-text"><strong>{{ d }}</strong></p> <p class="card-text"><strong>{{ d.item }}</strong></p>
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
@@ -11,27 +11,25 @@
<tbody> <tbody>
<tr> <tr>
<th class="w-33" scope="row">Name</th> <th class="w-33" scope="row">Name</th>
<td>{{ d.scale }}</td> <td>{{ d.item.scale }}</td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Ratio</th> <th class="w-33" scope="row">Ratio</th>
<td>{{ d.ratio }}</td> <td>{{ d.item.ratio }}</td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Tracks</th> <th class="w-33" scope="row">Tracks</th>
<td>{{ d.tracks }} mm</td> <td>{{ d.item.tracks }} mm</td>
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Gauge</th> <th class="w-33" scope="row">Gauge</th>
<td>{{ d.gauge }}</td> <td>{{ d.item.gauge }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="d-grid gap-2 mb-1 d-md-block"> <div class="d-grid gap-2 mb-1 d-md-block">
{% with items=d.num_items %} <a class="btn btn-sm btn-outline-primary{% if d.item.num_items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="scale" search=d.item.slug %}">Show all rolling stock</a>
<a class="btn btn-sm btn-outline-primary{% if items == 0 %} disabled{% endif %}" href="{% url 'filtered' _filter="scale" search=d.slug %}">Show {{ items }} item{{ items | pluralize}}</a> {% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_scale_change' d.item.pk %}">Edit</a>{% endif %}
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_scale_change' d.pk %}">Edit</a>{% endif %}
{% endwith %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -7,11 +7,8 @@
{{ t.name }}</a>{# new line is required #} {{ t.name }}</a>{# new line is required #}
{% endfor %} {% endfor %}
</p> </p>
{% endif %}
{% if not consist.published %}
<span class="badge text-bg-warning">Unpublished</span> |
{% endif %}
<small class="text-body-secondary">Updated {{ consist.updated_time | date:"M d, Y H:i" }}</small> <small class="text-body-secondary">Updated {{ consist.updated_time | date:"M d, Y H:i" }}</small>
{% endif %}
{% endblock %} {% endblock %}
{% block carousel %} {% block carousel %}
{% if consist.image %} {% if consist.image %}
@@ -26,42 +23,13 @@
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block cards_layout %}
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
{% block cards %}
{% for d in data %}
{% include "cards/roster.html" %}
{% endfor %}
{% endblock %}
</div>
{% if loads %}
<div class="accordion shadow-sm mt-4" id="accordionLoads">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseLoads" aria-expanded="false" aria-controls="collapseLoads">
<i class="bi bi-download"></i>&nbsp;Rolling Stock loaded on freight cars
</button>
</h2>
<div id="collapseLoads" class="accordion-collapse collapse" data-bs-parent="#accordionLoads">
<div class="accordion-body">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
{% for l in loads %}
{% include "cards/roster.html" with d=l %}
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block pagination %} {% block pagination %}
{% if data.has_other_pages %} {% if data.has_other_pages %}
<nav aria-label="Page navigation"> <nav aria-label="Page navigation">
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0"> <ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %} {% if data.has_previous %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url 'consist' uuid=consist.uuid page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a> <a class="page-link" href="{% url 'consist_pagination' uuid=consist.uuid page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">
@@ -77,13 +45,13 @@
{% if i == data.paginator.ELLIPSIS %} {% if i == data.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li> <li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %} {% else %}
<li class="page-item"><a class="page-link" href="{% url 'consist' uuid=consist.uuid page=i %}#main-content">{{ 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 %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if data.has_next %} {% if data.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url 'consist' uuid=consist.uuid page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a> <a class="page-link" href="{% url 'consist_pagination' uuid=consist.uuid page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">
@@ -102,10 +70,10 @@
<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 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>
</nav> </nav>
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector"> <select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
<option value="#nav-summary" selected>Summary</option> <option value="nav-summary" selected>Summary</option>
</select> </select>
<div class="tab-content" id="nav-tabContent"> <div class="tab-content" id="nav-tabContent">
<div class="tab-pane show active table-responsive" 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"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
@@ -115,6 +83,9 @@
{% if consist.company.freelance %} {% if consist.company.freelance %}
<span class="badge text-bg-secondary">Freelance</span> <span class="badge text-bg-secondary">Freelance</span>
{% endif %} {% endif %}
{% if not consist.published %}
<span class="badge text-bg-warning">Draft</span>
{% endif %}
</div> </div>
</th> </th>
</tr> </tr>
@@ -138,11 +109,7 @@
{% endif %} {% endif %}
<tr> <tr>
<th scope="row">Length</th> <th scope="row">Length</th>
<td>{{ consist.length }}</td> <td>{{ data | length }}</td>
</tr>
<tr>
<th scope="row">Composition</th>
<td>{% for t in consist.get_type_count %}{{ t.count }}x {{ t.type }} {{t.category }}{% if not forloop.last %} &raquo; {% endif %}{% endfor %}{% if loads %} | <i class="bi bi-download"></i> {{ loads|length }}x Load{{ loads|pluralize }}{% endif %}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -5,7 +5,7 @@
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0"> <ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %} {% if data.has_previous %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url 'filtered' _filter=filter search=search page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a> <a class="page-link" href="{% url 'filtered_pagination' _filter=filter search=search page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">
@@ -21,13 +21,13 @@
{% if i == data.paginator.ELLIPSIS %} {% if i == data.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li> <li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %} {% else %}
<li class="page-item"><a class="page-link" href="{% url 'filtered' _filter=filter search=search page=i %}#main-content">{{ i }}</a></li> <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 %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if data.has_next %} {% if data.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url 'filtered' _filter=filter search=search page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a> <a class="page-link" href="{% url 'filtered_pagination' _filter=filter search=search page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">

View File

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

View File

@@ -3,18 +3,3 @@
{% block header %} {% block header %}
<div class="text-body-secondary">{{ site_conf.about | safe }}</div> <div class="text-body-secondary">{{ site_conf.about | safe }}</div>
{% endblock %} {% endblock %}
{% block cards %}
{% for d in data %}
{% include "cards/roster.html" %}
{% endfor %}
{% endblock %}
{% block pagination %}
<nav aria-label="Page navigation">
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
<li class="page-item">
<a class="page-link" href="{% url "roster" %}#main-content" tabindex="-1">Go to the roster <i class="bi bi-chevron-right"></i></a>
</li>
</ul>
</nav>
{% endblock %}

View File

@@ -12,16 +12,14 @@
<div class="container d-flex text-body-secondary"> <div class="container d-flex text-body-secondary">
<p class="flex-fill small">Made with ❤️ for 🚂 and <i class="bi bi-github"></i> <a href="https://github.com/daniviga/django-ram">django-ram</a> <p class="flex-fill small">Made with ❤️ for 🚂 and <i class="bi bi-github"></i> <a href="https://github.com/daniviga/django-ram">django-ram</a>
{% if site_conf.show_version %}<br>Version {{ site_conf.version }}{% endif %}</p> {% if site_conf.show_version %}<br>Version {{ site_conf.version }}{% endif %}</p>
<p class="text-end"> <p class="text-end fs-5">
{% if site_conf.disclaimer %} {% if site_conf.disclaimer %}<a class="text-reset" title="Disclaimer" href="" data-bs-toggle="modal" data-bs-target="#disclaimerModal"><i class="bi bi-info-square-fill"></i></a> {% endif %}
<a title="Disclaimer" href="" data-bs-toggle="modal" data-bs-target="#disclaimerModal"><i class="text-muted d-lg-none fs-5 bi bi-info-square-fill"></i><span class="d-none d-lg-inline small">Disclaimer</span></a><span class="d-none d-lg-inline small"> | </span> <a class="text-reset" title="Back to top" href="#"><i class="bi bi-arrow-up-left-square-fill"></i></a>
{% endif %}
<a title="Back to top" href="#"><i class="text-muted d-lg-none fs-5 bi bi-arrow-up-left-square-fill"></i><span class="d-none d-lg-inline small">Back to top</span></a>
</p> </p>
</div> </div>
<!-- Modal --> <!-- Modal -->
<div class="modal fade" id="disclaimerModal" tabindex="-1" aria-labelledby="disclaimerLabel" aria-hidden="true"> <div class="modal fade" id="disclaimerModal" tabindex="-1" aria-labelledby="disclaimerLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable modal-dialog-centered"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h1 class="modal-title fs-5" id="disclaimerLabel">Disclaimer</h1> <h1 class="modal-title fs-5" id="disclaimerLabel">Disclaimer</h1>

View File

@@ -11,10 +11,9 @@
<ul class="dropdown-menu" aria-labelledby="dropdownLogin"> <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: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:consist_consist_changelist' %}">Consists</a></li>
<li><a class="dropdown-item" href="{% url 'admin:app_list' 'bookshelf' %}">Bookshelf</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' 'repository' %}">Repository</a></li>
<li><a class="dropdown-item" href="{% url 'admin:app_list' 'metadata' %}">Metadata</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><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: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:portal_siteconfiguration_changelist' %}">Site configuration</a></li>

View File

@@ -0,0 +1,30 @@
<form class="d-flex needs-validation" action="{% url 'search' %}" method="post" novalidate>{% csrf_token %}
<div class="input-group has-validation">
<input class="form-control" type="search" list="datalistOptions" placeholder="Search" aria-label="Search" name="search" id="searchValidation" required>
<datalist id="datalistOptions">
<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>
<script>
(function () {
'use strict'
// Fetch all the forms we want to apply custom Bootstrap validation styles to
var forms = document.querySelectorAll('.needs-validation')
// Loop over them and prevent submission
Array.prototype.slice.call(forms)
.forEach(function (form) {
form.addEventListener('submit', function (event) {
if (!form.checkValidity()) {
form.classList.add('was-validated')
event.preventDefault()
event.stopPropagation()
}
}, false)
})
})()
</script>

View File

@@ -5,7 +5,7 @@
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0"> <ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %} {% if data.has_previous %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url 'manufacturer' manufacturer=manufacturer.slug search=search page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a> <a class="page-link" href="{% url 'manufacturer_pagination' manufacturer=manufacturer.slug search=search page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">
@@ -21,13 +21,13 @@
{% if i == data.paginator.ELLIPSIS %} {% if i == data.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li> <li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %} {% else %}
<li class="page-item"><a class="page-link" href="{% url 'manufacturer' manufacturer=manufacturer.slug search=search page=i %}#main-content">{{ i }}</a></li> <li class="page-item"><a class="page-link" href="{% url 'manufacturer_pagination' manufacturer=manufacturer.slug search=search page=i %}#main-content">{{ i }}</a></li>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if data.has_next %} {% if data.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url 'manufacturer' manufacturer=manufacturer.slug search=search page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a> <a class="page-link" href="{% url 'manufacturer_pagination' manufacturer=manufacturer.slug search=search page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">

View File

@@ -7,7 +7,7 @@
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0"> <ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %} {% if data.has_previous %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url request.resolver_match.url_name page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a> <a class="page-link" href="{% dynamic_pagination type page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">
@@ -23,13 +23,13 @@
{% if i == data.paginator.ELLIPSIS %} {% if i == data.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li> <li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %} {% else %}
<li class="page-item"><a class="page-link" href="{% url request.resolver_match.url_name page=i %}#main-content">{{ i }}</a></li> <li class="page-item"><a class="page-link" href="{% dynamic_pagination type page=i %}#main-content">{{ i }}</a></li>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if data.has_next %} {% if data.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url request.resolver_match.url_name page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a> <a class="page-link" href="{% dynamic_pagination type page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">

View File

@@ -1,12 +1,12 @@
{% extends "cards.html" %} {% extends "cards.html" %}
{% block pagination %} {% block pagination %}
{% if data.has_other_pages %} {% if data.has_other_pages %}
{% with data.0.category as c %} {% with data.0.item.category as c %}
<nav aria-label="Page navigation"> <nav aria-label="Page navigation">
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0"> <ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %} {% if data.has_previous %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url 'manufacturers' category=c page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a> <a class="page-link" href="{% url 'manufacturers_pagination' category=c page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">
@@ -22,13 +22,13 @@
{% if i == data.paginator.ELLIPSIS %} {% if i == data.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li> <li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %} {% else %}
<li class="page-item"><a class="page-link" href="{% url 'manufacturers' category=c page=i %}#main-content">{{ i }}</a></li> <li class="page-item"><a class="page-link" href="{% url 'manufacturers_pagination' category=c page=i %}#main-content">{{ i }}</a></li>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if data.has_next %} {% if data.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url 'manufacturers' category=c page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a> <a class="page-link" href="{% url 'manufacturers_pagination' category=c page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">

View File

@@ -1,5 +1,4 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load dcc %}
{% block header %} {% block header %}
{% if rolling_stock.tags.all %} {% if rolling_stock.tags.all %}
@@ -9,9 +8,6 @@
{% endfor %} {% endfor %}
</p> </p>
{% endif %} {% endif %}
{% if not rolling_stock.published %}
<span class="badge text-bg-warning">Unpublished</span> |
{% endif %}
<small class="text-body-secondary">Updated {{ rolling_stock.updated_time | date:"M d, Y H:i" }}</small> <small class="text-body-secondary">Updated {{ rolling_stock.updated_time | date:"M d, Y H:i" }}</small>
{% endblock %} {% endblock %}
{% block carousel %} {% block carousel %}
@@ -52,22 +48,22 @@
<button class="nav-link" id="nav-model-tab" data-bs-toggle="tab" data-bs-target="#nav-model" type="button" role="tab" aria-controls="nav-model" aria-selected="false">Model</button> <button class="nav-link" id="nav-model-tab" data-bs-toggle="tab" data-bs-target="#nav-model" type="button" role="tab" aria-controls="nav-model" aria-selected="false">Model</button>
<button class="nav-link" id="nav-class-tab" data-bs-toggle="tab" data-bs-target="#nav-class" type="button" role="tab" aria-controls="nav-class" aria-selected="false">Class</button> <button class="nav-link" id="nav-class-tab" data-bs-toggle="tab" data-bs-target="#nav-class" type="button" role="tab" aria-controls="nav-class" aria-selected="false">Class</button>
<button class="nav-link" id="nav-company-tab" data-bs-toggle="tab" data-bs-target="#nav-company" type="button" role="tab" aria-controls="nav-company" aria-selected="false">Company</button> <button class="nav-link" id="nav-company-tab" data-bs-toggle="tab" data-bs-target="#nav-company" type="button" role="tab" aria-controls="nav-company" aria-selected="false">Company</button>
{% if rolling_stock.decoder or rolling_stock.decoder_interface %}<button class="nav-link" id="nav-dcc-tab" data-bs-toggle="tab" data-bs-target="#nav-dcc" type="button" role="tab" aria-controls="nav-dcc" aria-selected="false">DCC</button>{% endif %} {% if rolling_stock.decoder %}<button class="nav-link" id="nav-dcc-tab" data-bs-toggle="tab" data-bs-target="#nav-dcc" type="button" role="tab" aria-controls="nav-dcc" aria-selected="false">DCC</button>{% endif %}
{% if documents or decoder_documents %}<button class="nav-link" id="nav-documents-tab" data-bs-toggle="tab" data-bs-target="#nav-documents" type="button" role="tab" aria-controls="nav-documents" aria-selected="false">Documents</button>{% endif %} {% if documents or decoder_documents %}<button class="nav-link" id="nav-documents-tab" data-bs-toggle="tab" data-bs-target="#nav-documents" type="button" role="tab" aria-controls="nav-documents" aria-selected="false">Documents</button>{% endif %}
{% if journal %}<button class="nav-link" id="nav-journal-tab" data-bs-toggle="tab" data-bs-target="#nav-journal" type="button" role="tab" aria-controls="nav-journal" aria-selected="false">Journal</button>{% endif %} {% if journal %}<button class="nav-link" id="nav-journal-tab" data-bs-toggle="tab" data-bs-target="#nav-journal" type="button" role="tab" aria-controls="nav-journal" aria-selected="false">Journal</button>{% endif %}
{% if set %}<button class="nav-link" id="nav-set-tab" data-bs-toggle="tab" data-bs-target="#nav-set" type="button" role="tab" aria-controls="nav-set" aria-selected="false">Set</button>{% endif %} {% if set %}<button class="nav-link" id="nav-set-tab" data-bs-toggle="tab" data-bs-target="#nav-set" type="button" role="tab" aria-controls="nav-set" aria-selected="false">Set</button>{% endif %}
{% if consists %}<button class="nav-link" id="nav-consists-tab" data-bs-toggle="tab" data-bs-target="#nav-consists" type="button" role="tab" aria-controls="nav-consists" aria-selected="false">Consists</button>{% endif %} {% if consists %}<button class="nav-link" id="nav-consists-tab" data-bs-toggle="tab" data-bs-target="#nav-consists" type="button" role="tab" aria-controls="nav-consists" aria-selected="false">Consists</button>{% endif %}
</nav> </nav>
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector"> <select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
<option value="#nav-summary" selected>Summary</option> <option value="nav-summary" selected>Summary</option>
<option value="#nav-model">Model</option> <option value="nav-model">Model</option>
<option value="#nav-class">Class</option> <option value="nav-class">Class</option>
<option value="#nav-company">Company</option> <option value="nav-company">Company</option>
{% if rolling_stock.decoder or rolling_stock.decoder_interface %}<option value="#nav-dcc">DCC</option>{% endif %} {% if rolling_stock.decoder %}<option value="nav-dcc">DCC</option>{% endif %}
{% if documents or decoder_documents %}<option value="#nav-documents">Documents</option>{% endif %} {% if documents or decoder_documents %}<option value="nav-documents">Documents</option>{% endif %}
{% if journal %}<option value="#nav-journal">Journal</option>{% endif %} {% if journal %}<option value="nav-journal">Journal</option>{% endif %}
{% if set %}<option value="#nav-set">Set</option>{% endif %} {% if set %}<option value="nav-set">Set</option>{% endif %}
{% if consists %}<option value="#nav-consists">Consists</option>{% endif %} {% if consists %}<option value="nav-consists">Consists</option>{% endif %}
</select> </select>
{% with class=rolling_stock.rolling_class company=rolling_stock.rolling_class.company %} {% with class=rolling_stock.rolling_class company=rolling_stock.rolling_class.company %}
<div class="tab-content" id="nav-tabContent"> <div class="tab-content" id="nav-tabContent">
@@ -81,6 +77,9 @@
{% if company.freelance %} {% if company.freelance %}
<span class="badge text-bg-secondary">Freelance</span> <span class="badge text-bg-secondary">Freelance</span>
{% endif %} {% endif %}
{% if not rolling_stock.published %}
<span class="badge text-bg-warning">Draft</span>
{% endif %}
</div> </div>
</th> </th>
</tr> </tr>
@@ -143,9 +142,7 @@
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th colspan="2" scope="row">DCC data <th colspan="2" scope="row">DCC data</th>
<a class="mt-1 float-end text-reset text-decoration-none" title="Symbols" href="" data-bs-toggle="modal" data-bs-target="#symbolsModal">{% dcc rolling_stock %}</a>
</th>
</tr> </tr>
</thead> </thead>
<tbody class="table-group-divider"> <tbody class="table-group-divider">
@@ -158,16 +155,6 @@
<th scope="row">Decoder</th> <th scope="row">Decoder</th>
<td>{{ rolling_stock.decoder }}</td> <td>{{ rolling_stock.decoder }}</td>
</tr> </tr>
<tr>
<th scope="row">Sound</th>
<td>
{% if rolling_stock.decoder.sound %}
<i class="bi bi-check-circle-fill text-success"></i>
{% else %}
<i class="bi bi-x-circle-fill text-secondary"></i>
{% endif %}
</td>
</tr>
<tr> <tr>
<th scope="row">Address</th> <th scope="row">Address</th>
<td>{{ rolling_stock.address }}</td> <td>{{ rolling_stock.address }}</td>
@@ -217,8 +204,49 @@
{% endif %} {% endif %}
</tbody> </tbody>
</table> </table>
{% include "_modules/purchase_data.html" with data=rolling_stock %} {% if request.user.is_staff %}
{% include "_modules/properties.html" %} <table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Purchase</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Shop</th>
<td>
{{ rolling_stock.shop | default:"-" }}
{% if rolling_stock.shop.website %} <a href="{{ rolling_stock.shop.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr>
<tr>
<th class="w-33" scope="row">Purchase date</th>
<td>{{ rolling_stock.purchase_date | default:"-" }}</td>
</tr>
<tr>
<th scope="row">Price ({{ site_conf.currency }})</th>
<td>{{ rolling_stock.price | default:"-" }}</td>
</tr>
</tbody>
</table>
{% endif %}
{% if properties %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Properties</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for p in properties %}
<tr>
<th class="w-33" scope="row">{{ p.property }}</th>
<td>{{ p.value }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div> </div>
<div class="tab-pane" 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"> <table class="table table-striped">
@@ -255,7 +283,23 @@
{% endif %} {% endif %}
</tbody> </tbody>
</table> </table>
{% include "_modules/properties.html" with properties=class_properties %} {% if class_properties %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Properties</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for p in class_properties %}
<tr>
<th class="w-33" scope="row">{{ p.property }}</th>
<td>{{ p.value }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div> </div>
<div class="tab-pane" id="nav-company" role="tabpanel" aria-labelledby="nav-company-tab"> <div class="tab-pane" id="nav-company" role="tabpanel" aria-labelledby="nav-company-tab">
<table class="table table-striped"> <table class="table table-striped">
@@ -295,59 +339,74 @@
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th colspan="2" scope="row">Decoder data <th colspan="2" scope="row">Decoder data</th>
<a class="mt-1 float-end text-reset text-decoration-none" title="Symbols" href="" data-bs-toggle="modal" data-bs-target="#symbolsModal">{% dcc rolling_stock %}</a>
</th>
</tr> </tr>
</thead> </thead>
<tbody class="table-group-divider"> <tbody class="table-group-divider">
<tr> <tr>
<th class="w-33" scope="row">Interface</th> <th scope="row">Interface</th>
<td>{{ rolling_stock.get_decoder_interface }}</td> <td>{{ rolling_stock.get_decoder_interface }}</td>
</tr> </tr>
{% if rolling_stock.decoder %}
<tr> <tr>
<th scope="row">Manufacturer</th> <th class="w-33" scope="row">Address</th>
<td>{{ rolling_stock.decoder.manufacturer | default:"-" }}</td> <td>{{ rolling_stock.address }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Name</th> <th scope="row">Name</th>
<td>{{ rolling_stock.decoder.name }}</td> <td>{{ rolling_stock.decoder.name }}</td>
</tr> </tr>
<tr>
<th scope="row">Manufacturer</th>
<td>{{ rolling_stock.decoder.manufacturer | default:"-" }}</td>
</tr>
<tr> <tr>
<th scope="row">Version</th> <th scope="row">Version</th>
<td>{{ rolling_stock.decoder.version | default:"-"}}</td> <td>{{ rolling_stock.decoder.version | default:"-"}}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Sound</th> <th scope="row">Sound</th>
<td> <td>{{ rolling_stock.decoder.sound | yesno:"Yes,No" }}</td>
{% if rolling_stock.decoder.sound %}
<i class="bi bi-check-circle-fill text-success"></i>
{% else %}
<i class="bi bi-x-circle-fill text-secondary"></i>
{% endif %}
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Decoder configuration</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Address</th>
<td>{{ rolling_stock.address }}</td>
</tr>
{% endif %}
</tbody>
</table>
</div> </div>
<div class="tab-pane table-responsive" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab"> <div class="tab-pane" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
{% include "_modules/documents.html" %} {% if documents %}
{% include "_modules/documents.html" with documents=decoder_documents header="Decoder documents" %} <table class="table table-striped">
<thead>
<tr>
<th colspan="3" scope="row">Documents</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for d in documents.all %}
<tr>
<td class="w-33">{{ d.description }}</td>
<td><a href="{{ d.file.url }}" target="_blank">{{ d.filename }}</a></td>
<td class="text-end">{{ d.file.size | filesizeformat }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if decoder_documents %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="3" scope="row">Decoder documents</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for d in decoder_documents.all %}
<tr>
<td class="w-33">{{ d.description }}</td>
<td><a href="{{ d.file.url }}" target="_blank">{{ d.filename }}</a></td>
<td class="text-end">{{ d.file.size | filesizeformat }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div> </div>
<div class="tab-pane" 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"> <table class="table table-striped">

View File

@@ -6,7 +6,7 @@
<ul class="pagination flex-wrap justify-content-center mt-4 mb-0"> <ul class="pagination flex-wrap justify-content-center mt-4 mb-0">
{% if data.has_previous %} {% if data.has_previous %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url 'search' search=encoded_search page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a> <a class="page-link" href="{% url 'search_pagination' search=encoded_search page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">
@@ -22,13 +22,13 @@
{% if i == data.paginator.ELLIPSIS %} {% if i == data.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li> <li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %} {% else %}
<li class="page-item"><a class="page-link" href="{% url 'search' search=encoded_search page=i %}#main-content">{{ 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 %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if data.has_next %} {% if data.has_next %}
<li class="page-item"> <li class="page-item">
<a class="page-link" href="{% url 'search' search=encoded_search page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a> <a class="page-link" href="{% url 'search_pagination' search=encoded_search page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li> </li>
{% else %} {% else %}
<li class="page-item disabled"> <li class="page-item disabled">

View File

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

View File

@@ -12,3 +12,10 @@ def dynamic_admin_url(app_name, model_name, object_id=None):
args=[object_id] args=[object_id]
) )
return reverse(f'admin:{app_name}_{model_name}_changelist') return reverse(f'admin:{app_name}_{model_name}_changelist')
@register.simple_tag
def dynamic_pagination(reverse_name, page):
if reverse_name.endswith('y'):
return reverse(f'{reverse_name[:-1]}ies_pagination', args=[page])
return reverse(f'{reverse_name}s_pagination', args=[page])

View File

@@ -1,6 +1,6 @@
from django import template from django import template
from portal.models import Flatpage from portal.models import Flatpage
from bookshelf.models import Book, Catalog, Magazine from bookshelf.models import Book, Catalog
register = template.Library() register = template.Library()
@@ -8,14 +8,10 @@ register = template.Library()
@register.inclusion_tag('bookshelf/bookshelf_menu.html') @register.inclusion_tag('bookshelf/bookshelf_menu.html')
def show_bookshelf_menu(): def show_bookshelf_menu():
# FIXME: Filter out unpublished books and catalogs? # FIXME: Filter out unpublished books and catalogs?
books = Book.objects.exists()
catalogs = Catalog.objects.exists()
magazines = Magazine.objects.exists()
return { return {
"bookshelf_menu": (books or catalogs or magazines), "bookshelf_menu": (Book.objects.exists() or Catalog.objects.exists()),
"books_menu": books, "books_menu": Book.objects.exists(),
"catalogs_menu": catalogs, "catalogs_menu": Catalog.objects.exists(),
"magazines_menu": magazines,
} }

View File

@@ -1,11 +0,0 @@
import random
from django import template
register = template.Library()
@register.filter
def shuffle(items):
shuffled_items = list(items)
random.shuffle(shuffled_items)
return shuffled_items

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