Compare commits

...

93 Commits

Author SHA1 Message Date
b9e55936e1 Add black to AGENTS.md 2026-01-17 22:40:21 +01:00
268fe8f9a7 Add tests to github workflows 2026-01-17 22:35:29 +01:00
289ace4a49 Add AGENTS.md and tests generated via opencode 2026-01-17 22:33:16 +01:00
8c216c7e56 Fix search form validation 2026-01-15 15:17:20 +01:00
d1e741ebfd Remove the need of inline scripting 2026-01-15 12:42:52 +01:00
650a93676e Implement CSP via Django 6.0 2026-01-15 10:36:07 +01:00
265aed56fe Further hardening 2026-01-15 10:06:52 +01:00
167a0593de Cookies hardening 2026-01-15 10:02:57 +01:00
a254786ddc Fix a bug with deduplicated file download 2026-01-14 11:36:09 +01:00
8d899e4d9f Minor improvements 2026-01-09 13:08:47 +01:00
40df9eb376 Make the js versioned 2026-01-08 18:49:34 +01:00
226f0b32ba Fix order in the DCC interfaces 2026-01-08 13:20:34 +01:00
3c121a60a4 Extend DCC definitions and clean-up config. Fix a typo in site name (!!) 2026-01-08 12:22:22 +01:00
ab606859d1 Fix a small regression in the admin introduced with Django 6 2026-01-08 12:21:39 +01:00
a16801eb4b Fix a bug in tab's javascript and cleanup the code 2026-01-07 23:24:16 +01:00
b8d10a68ca Minor fix to site description exposed in the html header 2026-01-07 18:33:46 +01:00
e690ded04f Include npm dependencies 2026-01-07 18:29:58 +01:00
15a7ffaf4f Implement deep links for tabs and template cleanup 2026-01-07 18:28:25 +01:00
a11f97bcad Reduce number of clicks to add images or documents to objects 2026-01-06 18:15:47 +01:00
3c854bda1b Fix a bug in consists admin filtering 2026-01-05 18:02:14 +01:00
564416b3d5 Bump to v0.19.8 2026-01-05 15:46:35 +01:00
967ea5d495 Hide accordion in consists if no load 2026-01-05 15:45:52 +01:00
7656aa8b68 Simplify consist cards cover generator 2026-01-05 15:38:51 +01:00
1be102b9d4 Better 404 handling 2026-01-05 14:54:38 +01:00
4ec7b8fc18 Fix support for X-Accel-Redirect 2026-01-05 14:39:45 +01:00
9a469378df Add support for X-Accel-Redirect 2026-01-05 00:04:44 +01:00
ede8741473 Enforce file access permissions 2026-01-04 23:48:52 +01:00
49c8d804d6 Implement support for rolling stock load in consists 2026-01-03 14:18:46 +01:00
2ab2d00585 Improve ordering 2026-01-03 00:54:21 +01:00
c95064ddec More templates modularization 2026-01-02 23:19:18 +01:00
16bd82de39 Improve tables behavior on small screen (mobile) 2026-01-02 22:19:22 +01:00
2ae7f2685d Make documents UI modular 2026-01-02 19:12:00 +01:00
29f9a213b4 Make the TOC table responsive 2025-12-31 18:16:08 +01:00
884661d4e1 Enforce page number in TOC 2025-12-31 14:57:15 +01:00
c7cace96f7 Extend lenght of TOC items 2025-12-31 14:49:37 +01:00
d3c099c05b Extend search to toc titles 2025-12-30 22:21:43 +01:00
903633b5a7 Extend TOC to books 2025-12-30 22:15:29 +01:00
ee775d737e Make sure that cache is always cleaned while performing an update 2025-12-30 21:53:04 +01:00
8087ab5997 Implement TOC in UI 2025-12-30 00:44:06 +01:00
1899747909 Minor fix 2025-12-29 12:16:58 +01:00
0880bd0817 Initial implemntation of TOC for books et al. 2025-12-29 12:05:37 +01:00
74d7df2c8b Add an icon for fetured items 2025-12-29 11:44:54 +01:00
c81508bbd5 Fix a bug in featured count limit 2025-12-25 19:46:18 +01:00
b4f69d8a34 Fix a regression in a template 2025-12-25 11:13:14 +01:00
676418cb67 Code refactoring to simplify template data contexts (#55)
* Fix a search filter when no catalogs are returned
* Code refactoring to simplify templates
* Remove duplicated code
* Remove dead code
* More improvements, clean up and add featured items in homepage
* Fix a type and better page navigation
2025-12-24 15:38:07 +01:00
98d2e7beab Extend search to catalogs and scales 2025-12-23 12:19:26 +01:00
fb17dc2a7c Add some utils to generate cards via imagemagick 2025-12-21 23:01:04 +01:00
5a71dc36fa Improve sorting and extend search to magazines 2025-12-21 22:56:45 +01:00
c539255bf9 More UI improvements and fix a regression on manufacturer filtering 2025-12-12 23:55:09 +01:00
fc527d5cd1 Minor fixes to labels and dates 2025-12-12 00:08:43 +01:00
f45d754c91 More fixes to lables 2025-12-10 23:38:04 +01:00
e9c9ede357 Fix a bug in magazine edit 2025-12-10 23:03:48 +01:00
39b0a9378b Magazine UI (#54)
* Work in progress to implement magazines and issues UI

* Fully implement UI for magazines
2025-12-10 22:58:39 +01:00
6b10051bc4 Add support for magazines, backend only (#53)
* Initial work to support magazines

* Change editor default height to 300px from 500px

* Stabilize the magazine repository app

* Switch from stacked to tabular inlines for magazines

* Update submodules
2025-12-08 23:18:57 +01:00
3804c3379b Add 'repository' to the login menu 2025-12-08 14:06:17 +01:00
1b769da553 Update python versions in pipeline 2025-12-07 23:08:18 +01:00
f655900411 Better migration fix for Django 6.0 2025-12-07 23:05:44 +01:00
3e69b9ae6e Remove a migration operation failing on Django 6.0 (safe) 2025-12-04 00:07:05 +01:00
66c3c3f51c Extend compatibility with Django 6.0 2025-12-03 23:24:53 +01:00
935c439084 Introduce compatibility with Django 6.0 2025-12-03 23:07:56 +01:00
d757388ca8 Restore missing DeduplicatedStorage for documents 2025-10-28 22:19:05 +01:00
536101d2ff Update bootstrap to 5.3.8 (#52) 2025-10-05 22:39:15 +02:00
955397acd5 Update site icon 2025-06-01 21:51:51 +02:00
672cadd7e1 Introduce symbols legend 2025-05-28 23:38:02 +02:00
464fe57536 Fix an abbr in DCC 2025-05-27 23:57:21 +02:00
bd16c7eee7 Still improve the DCC section 2025-05-27 23:49:22 +02:00
cc2e374558 Simplify cards, use icons for DCC 2025-05-26 00:04:07 +02:00
1c25ac9b14 Minor UI improvements 2025-05-25 19:13:28 +02:00
de126a735d Reverse field renaming 2025-05-24 15:00:52 +02:00
18b5ab8053 Bump DCC-EX submodule 2025-05-24 14:52:36 +02:00
3acc80e2ad Fix another counting issue 2025-05-24 14:44:42 +02:00
552ba39970 Upgrade bootstrap to 5.3.6 and icons to 1.13.1 2025-05-12 14:06:37 +02:00
222e2075ec Change the behavior of the modal 2025-05-12 13:51:31 +02:00
b5c57dcd94 Rely on slugs to have a more natural sorting 2025-05-04 22:46:23 +02:00
b81c63898f Add more information in consist_item rows 2025-05-04 22:05:47 +02:00
76b266b1f9 Extend search on company to slug field to better manage accented and utf names 2025-05-04 19:13:43 +02:00
86657a3b9f Manually fix a migration to correctly bootsrap a new DB 2025-05-04 19:12:50 +02:00
d0d25424fb Fix road_number_int field sizing 2025-05-04 19:12:05 +02:00
292b95b8ed Fix a bug in roster search via scale 2025-05-03 21:02:44 +02:00
dea7a594bc Implement CSV export for cosists 2025-05-02 22:25:59 +02:00
60195bc99f Simplify the logic about scales in the consist and remove async updates 2025-05-02 13:40:09 +02:00
7673f0514a Fix filter by scale counters and add consist constrains
Still to be improved, see FIXME
2025-05-01 23:49:22 +02:00
40f42a9ee9 Reformat portal/views.py 2025-04-30 22:52:51 +02:00
2e06e94fde Add counters to cards 2025-04-30 22:50:43 +02:00
ece8d1ad94 Minor UI improvement 2025-04-29 22:34:46 +02:00
e9ec126ada Fix a bug in the consist admin search 2025-04-27 22:22:47 +02:00
1222116874 Improve consist counter, fix a bug with unpublished stock 2025-04-27 22:12:43 +02:00
85741f090c Provide consist composition 2025-04-27 18:22:13 +02:00
88d718fa94 Minor footer improvement on large screens 2025-04-26 00:16:09 +02:00
a2c857a3cd Fix a couple of bugs 2025-04-25 23:14:10 +02:00
647894bca7 Add country flag to cards 2025-03-20 22:07:12 +01:00
c8cc8c5ed0 Minor improvement in the CSS
Update the CommandStation-EX tag as well
2025-03-02 22:31:07 +01:00
e80dc604a7 Improve docs management and add invoices repo (#51)
* Create a repository app for documents, first step

* Step two (broken)

* Complete the implementation of document repository and add invoices

* Add support for invoices

* Update submodules
2025-02-17 23:25:19 +01:00
136 changed files with 6512 additions and 1051 deletions

View File

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

5
.gitignore vendored
View File

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

340
AGENTS.md Normal file
View File

@@ -0,0 +1,340 @@
# 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

43
docs/nginx/nginx.conf Normal file
View File

@@ -0,0 +1,43 @@
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;
}
}

6
package.json Normal file
View File

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

View File

@@ -2,39 +2,44 @@ import html
from django.conf import settings
from django.contrib import admin
from django.utils.html import format_html, strip_tags
from django.utils.html import (
format_html,
format_html_join,
strip_tags,
mark_safe,
)
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
from ram.admin import publish, unpublish
from ram.utils import generate_csv
from portal.utils import get_site_conf
from repository.models import (
BookDocument,
CatalogDocument,
MagazineIssueDocument,
)
from bookshelf.models import (
BaseBookProperty,
BaseBookImage,
BaseBookDocument,
Book,
Author,
Publisher,
Catalog,
Magazine,
MagazineIssue,
TocEntry,
)
class BookImageInline(SortableInlineAdminMixin, admin.TabularInline):
model = BaseBookImage
min_num = 0
extra = 0
extra = 1
readonly_fields = ("image_thumbnail",)
classes = ["collapse"]
verbose_name = "Image"
class BookDocInline(admin.TabularInline):
model = BaseBookDocument
min_num = 0
extra = 0
classes = ["collapse"]
class BookPropertyInline(admin.TabularInline):
model = BaseBookProperty
min_num = 0
@@ -44,9 +49,38 @@ class BookPropertyInline(admin.TabularInline):
verbose_name_plural = "Properties"
class BookDocInline(admin.TabularInline):
model = BookDocument
min_num = 0
extra = 1
classes = ["collapse"]
class CatalogDocInline(BookDocInline):
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)
class BookAdmin(SortableAdminBase, admin.ModelAdmin):
inlines = (
BookTocInline,
BookPropertyInline,
BookImageInline,
BookDocInline,
@@ -60,9 +94,9 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
"published",
)
autocomplete_fields = ("authors", "publisher", "shop")
readonly_fields = ("creation_time", "updated_time")
readonly_fields = ("invoices", "creation_time", "updated_time")
search_fields = ("title", "publisher__name", "authors__last_name")
list_filter = ("publisher__name", "authors")
list_filter = ("publisher__name", "authors", "published")
fieldsets = (
(
@@ -89,6 +123,7 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
"shop",
"purchase_date",
"price",
"invoices",
)
},
),
@@ -115,6 +150,18 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
)
return form
@admin.display(description="Invoices")
def invoices(self, obj):
if obj.invoice.exists():
html = format_html_join(
mark_safe("<br>"),
'<a href="{}" target="_blank">{}</a>',
((i.file.url, i) for i in obj.invoice.all()),
)
else:
html = "-"
return html
@admin.display(description="Publisher")
def get_publisher(self, obj):
return obj.publisher.name
@@ -185,13 +232,13 @@ class AuthorAdmin(admin.ModelAdmin):
@admin.register(Publisher)
class PublisherAdmin(admin.ModelAdmin):
list_display = ("name", "country_flag")
list_display = ("name", "country_flag_name")
search_fields = ("name",)
@admin.display(description="Country")
def country_flag(self, obj):
def country_flag_name(self, obj):
return format_html(
'<img src="{}" /> {}'.format(obj.country.flag, obj.country.name)
'<img src="{}" /> {}', obj.country.flag, obj.country.name
)
@@ -200,7 +247,7 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
inlines = (
BookPropertyInline,
BookImageInline,
BookDocInline,
CatalogDocInline,
)
list_display = (
"__str__",
@@ -210,9 +257,14 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
"published",
)
autocomplete_fields = ("manufacturer",)
readonly_fields = ("creation_time", "updated_time")
readonly_fields = ("invoices", "creation_time", "updated_time")
search_fields = ("manufacturer__name", "years", "scales__scale")
list_filter = ("manufacturer__name", "publication_year", "scales__scale")
list_filter = (
"published",
"manufacturer__name",
"publication_year",
"scales__scale",
)
fieldsets = (
(
@@ -236,8 +288,10 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
"Purchase data",
{
"fields": (
"shop",
"purchase_date",
"price",
"invoices",
)
},
),
@@ -264,6 +318,18 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
)
return form
@admin.display(description="Invoices")
def invoices(self, obj):
if obj.invoice.exists():
html = format_html_join(
mark_safe("<br>"),
'<a href="{}" target="_blank">{}</a>',
((i.file.url, i) for i in obj.invoice.all()),
)
else:
html = "-"
return html
def download_csv(modeladmin, request, queryset):
header = [
"Catalog",
@@ -315,3 +381,145 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
download_csv.short_description = "Download selected items as 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",
)
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,7 +1,8 @@
# Generated by Django 5.1.2 on 2024-11-27 16:35
import django.db.models.deletion
from django.db import migrations, models
from django.db import migrations, models, connection
from django.db.utils import ProgrammingError, OperationalError
def basebook_to_book(apps, schema_editor):
@@ -16,6 +17,19 @@ def basebook_to_book(apps, schema_editor):
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):
dependencies = [
@@ -101,10 +115,6 @@ class Migration(migrations.Migration):
model_name="basebook",
name="old_title",
),
migrations.RemoveField(
model_name="basebook",
name="old_authors",
),
migrations.RemoveField(
model_name="basebook",
name="old_publisher",
@@ -138,4 +148,16 @@ class Migration(migrations.Migration):
},
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,6 +29,10 @@ class Migration(migrations.Migration):
]
operations = [
migrations.RemoveConstraint(
model_name="basebookdocument",
name="unique_book_file",
),
migrations.AddField(
model_name="basebook",
name="shop",

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.1.4 on 2025-02-09 13:47
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0022_basebook_shop"),
("repository", "0001_initial"),
]
operations = [
migrations.DeleteModel(
name="BaseBookDocument",
),
]

View File

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

View File

@@ -0,0 +1,224 @@
# 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

@@ -0,0 +1,244 @@
# 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

@@ -0,0 +1,18 @@
# 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

@@ -0,0 +1,29 @@
# 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

@@ -0,0 +1,29 @@
# 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

@@ -0,0 +1,53 @@
# 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

@@ -0,0 +1,28 @@
# 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,12 +1,16 @@
import os
import shutil
from urllib.parse import urlparse
from django.db import models
from django.conf import settings
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 ram.utils import DeduplicatedStorage
from ram.models import BaseModel, Image, Document, PropertyInstance
from ram.models import BaseModel, Image, PropertyInstance
from metadata.models import Scale, Manufacturer, Shop, Tag
@@ -41,8 +45,8 @@ class BaseBook(BaseModel):
ISBN = models.CharField(max_length=17, blank=True) # 13 + dashes
language = models.CharField(
max_length=7,
choices=settings.LANGUAGES,
default='en'
choices=sorted(settings.LANGUAGES, key=lambda s: s[1]),
default="en",
)
number_of_pages = models.SmallIntegerField(null=True, blank=True)
publication_year = models.SmallIntegerField(null=True, blank=True)
@@ -56,27 +60,24 @@ class BaseBook(BaseModel):
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):
shutil.rmtree(
os.path.join(
settings.MEDIA_ROOT, "images", "books", str(self.uuid)
),
ignore_errors=True
ignore_errors=True,
)
super(BaseBook, self).delete(*args, **kwargs)
def book_image_upload(instance, filename):
return os.path.join(
"images",
"books",
str(instance.book.uuid),
filename
)
return os.path.join("images", "books", str(instance.book.uuid), filename)
def magazine_image_upload(instance, filename):
return os.path.join("images", "magazines", str(instance.uuid), filename)
class BaseBookImage(Image):
@@ -89,21 +90,6 @@ class BaseBookImage(Image):
)
class BaseBookDocument(Document):
book = models.ForeignKey(
BaseBook, on_delete=models.CASCADE, related_name="document"
)
class Meta:
verbose_name_plural = "Documents"
constraints = [
models.UniqueConstraint(
fields=["book", "file"],
name="unique_book_file"
)
]
class BaseBookProperty(PropertyInstance):
book = models.ForeignKey(
BaseBook,
@@ -135,8 +121,7 @@ class Book(BaseBook):
def get_absolute_url(self):
return reverse(
"bookshelf_item",
kwargs={"selector": "book", "uuid": self.uuid}
"bookshelf_item", kwargs={"selector": "book", "uuid": self.uuid}
)
@@ -144,23 +129,160 @@ class Catalog(BaseBook):
manufacturer = models.ForeignKey(
Manufacturer,
on_delete=models.CASCADE,
related_name="catalogs",
)
years = models.CharField(max_length=12)
scales = models.ManyToManyField(Scale)
scales = models.ManyToManyField(Scale, related_name="catalogs")
class Meta:
ordering = ["manufacturer", "publication_year"]
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()
return "%s %s %s" % (self.manufacturer.name, self.years, scales)
def get_absolute_url(self):
return reverse(
"bookshelf_item",
kwargs={"selector": "catalog", "uuid": self.uuid}
"bookshelf_item", kwargs={"selector": "catalog", "uuid": self.uuid}
)
def get_scales(self):
return "/".join([s.scale for s in self.scales.all()])
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()
)
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,3 +49,5 @@ class CatalogSerializer(serializers.ModelSerializer):
"price",
)
read_only_fields = ("creation_time", "updated_time")
# FIXME: add Magazine and MagazineIssue serializers

View File

@@ -0,0 +1,16 @@
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,3 +1,436 @@
from decimal import Decimal
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.db import IntegrityError
# Create your tests here.
from bookshelf.models import (
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,3 +38,5 @@ class CatalogGet(RetrieveAPIView):
def get_queryset(self):
return Book.objects.get_published(self.request.user)
# FIXME: add Magazine and MagazineIssue views

View File

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

View File

@@ -0,0 +1,18 @@
# 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

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

View File

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

View File

@@ -0,0 +1,18 @@
# 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,12 +2,13 @@ import os
from django.db import models
from django.urls import reverse
from django.utils.text import Truncator
from django.dispatch import receiver
from django.core.exceptions import ValidationError
from ram.models import BaseModel
from ram.utils import DeduplicatedStorage
from metadata.models import Company, Tag
from metadata.models import Company, Scale, Tag
from roster.models import RollingStock
@@ -26,6 +27,7 @@ class Consist(BaseModel):
blank=True,
help_text="Era or epoch of the consist",
)
scale = models.ForeignKey(Scale, on_delete=models.CASCADE)
image = models.ImageField(
upload_to=os.path.join("images", "consists"),
storage=DeduplicatedStorage,
@@ -39,16 +41,34 @@ class Consist(BaseModel):
def get_absolute_url(self):
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
def country(self):
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:
ordering = ["company", "-creation_time"]
@@ -58,11 +78,8 @@ class ConsistItem(models.Model):
Consist, on_delete=models.CASCADE, related_name="consist_item"
)
rolling_stock = models.ForeignKey(RollingStock, on_delete=models.CASCADE)
order = models.PositiveIntegerField(
default=1000, # make sure it is always added at the end
blank=False,
null=False
)
load = models.BooleanField(default=False)
order = models.PositiveIntegerField(blank=False, null=False)
class Meta:
ordering = ["order"]
@@ -76,6 +93,29 @@ class ConsistItem(models.Model):
def __str__(self):
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):
return self.rolling_stock.published
published.boolean = True
@@ -83,9 +123,21 @@ class ConsistItem(models.Model):
def preview(self):
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
def type(self):
return self.rolling_stock.rolling_class.type
return self.rolling_stock.rolling_class.type.type
@property
def address(self):

View File

@@ -1,3 +1,315 @@
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.db import IntegrityError
# Create your tests here.
from consist.models import Consist, ConsistItem
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

@@ -2,18 +2,16 @@ from django.contrib import admin
from django.utils.html import format_html
from adminsortable2.admin import SortableAdminMixin
from ram.admin import publish, unpublish
from repository.models import DecoderDocument
from metadata.models import (
Property,
Decoder,
DecoderDocument,
Scale,
Shop,
Manufacturer,
Company,
Tag,
RollingStockType,
GenericDocument,
)
@@ -26,7 +24,7 @@ class PropertyAdmin(admin.ModelAdmin):
class DecoderDocInline(admin.TabularInline):
model = DecoderDocument
min_num = 0
extra = 0
extra = 1
classes = ["collapse"]
@@ -49,28 +47,28 @@ class ScaleAdmin(admin.ModelAdmin):
@admin.register(Company)
class CompanyAdmin(admin.ModelAdmin):
readonly_fields = ("logo_thumbnail",)
list_display = ("name", "country_flag")
list_display = ("name", "country_flag_name")
list_filter = ("name", "country")
search_fields = ("name",)
@admin.display(description="Country")
def country_flag(self, obj):
def country_flag_name(self, obj):
return format_html(
'<img src="{}" /> {}'.format(obj.country.flag, obj.country.name)
'<img src="{}" /> {}', obj.country.flag, obj.country.name
)
@admin.register(Manufacturer)
class ManufacturerAdmin(admin.ModelAdmin):
readonly_fields = ("logo_thumbnail",)
list_display = ("name", "category", "country_flag")
list_display = ("name", "category", "country_flag_name")
list_filter = ("category",)
search_fields = ("name",)
@admin.display(description="Country")
def country_flag(self, obj):
def country_flag_name(self, obj):
return format_html(
'<img src="{}" /> {}'.format(obj.country.flag, obj.country.name)
'<img src="{}" /> {}', obj.country.flag, obj.country.name
)
@@ -88,53 +86,14 @@ class RollingStockTypeAdmin(SortableAdminMixin, admin.ModelAdmin):
search_fields = ("type", "category")
@admin.register(GenericDocument)
class GenericDocumentAdmin(admin.ModelAdmin):
readonly_fields = ("size", "creation_time", "updated_time")
list_display = (
"__str__",
"description",
"private",
"size",
"download",
)
search_fields = (
"description",
"file",
)
fieldsets = (
(
None,
{
"fields": (
"private",
"description",
"file",
"size",
"tags",
)
},
),
(
"Notes",
{"classes": ("collapse",), "fields": ("notes",)},
),
(
"Audit",
{
"classes": ("collapse",),
"fields": (
"creation_time",
"updated_time",
),
},
),
)
actions = [publish, unpublish]
@admin.register(Shop)
class ShopAdmin(admin.ModelAdmin):
list_display = ("name", "on_line", "active")
list_display = ("name", "on_line", "active", "country_flag_name")
list_filter = ("on_line", "active")
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

@@ -0,0 +1,24 @@
# Generated by Django 5.1.4 on 2025-02-09 13:47
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("metadata", "0023_shop"),
("repository", "0001_initial"),
]
operations = [
migrations.RemoveField(
model_name="genericdocument",
name="tags",
),
migrations.DeleteModel(
name="DecoderDocument",
),
migrations.DeleteModel(
name="GenericDocument",
),
]

View File

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

View File

@@ -0,0 +1,24 @@
# 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,4 +1,5 @@
import os
from urllib.parse import urlparse
from django.db import models
from django.urls import reverse
from django.conf import settings
@@ -6,14 +7,12 @@ from django.dispatch.dispatcher import receiver
from django.core.exceptions import ValidationError
from django_countries.fields import CountryField
from tinymce import models as tinymce
from ram.models import Document
from ram.models import SimpleBaseModel
from ram.utils import DeduplicatedStorage, get_image_preview, slugify
from ram.managers import PublicManager
class Property(models.Model):
class Property(SimpleBaseModel):
name = models.CharField(max_length=128, unique=True)
private = models.BooleanField(
default=False,
@@ -30,8 +29,8 @@ class Property(models.Model):
objects = PublicManager()
class Manufacturer(models.Model):
name = models.CharField(max_length=128, unique=True)
class Manufacturer(SimpleBaseModel):
name = models.CharField(max_length=128)
slug = models.CharField(max_length=128, unique=True, editable=False)
category = models.CharField(
max_length=64, choices=settings.MANUFACTURER_TYPES
@@ -46,7 +45,13 @@ class Manufacturer(models.Model):
)
class Meta:
ordering = ["category", "name"]
ordering = ["category", "slug"]
constraints = [
models.UniqueConstraint(
fields=["name", "category"],
name="unique_name_category"
)
]
def __str__(self):
return self.name
@@ -60,13 +65,17 @@ class Manufacturer(models.Model):
},
)
def website_short(self):
if self.website:
return urlparse(self.website).netloc.replace("www.", "")
def logo_thumbnail(self):
return get_image_preview(self.logo.url)
logo_thumbnail.short_description = "Preview"
class Company(models.Model):
class Company(SimpleBaseModel):
name = models.CharField(max_length=64, unique=True)
slug = models.CharField(max_length=64, unique=True, editable=False)
extended_name = models.CharField(max_length=128, blank=True)
@@ -81,7 +90,7 @@ class Company(models.Model):
class Meta:
verbose_name_plural = "Companies"
ordering = ["name"]
ordering = ["slug"]
def __str__(self):
return self.name
@@ -104,7 +113,7 @@ class Company(models.Model):
logo_thumbnail.short_description = "Preview"
class Decoder(models.Model):
class Decoder(SimpleBaseModel):
name = models.CharField(max_length=128, unique=True)
manufacturer = models.ForeignKey(
Manufacturer,
@@ -132,20 +141,6 @@ class Decoder(models.Model):
image_thumbnail.short_description = "Preview"
class DecoderDocument(Document):
decoder = models.ForeignKey(
Decoder, on_delete=models.CASCADE, related_name="document"
)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["decoder", "file"],
name="unique_decoder_file"
)
]
def calculate_ratio(ratio):
try:
num, den = ratio.split(":")
@@ -154,7 +149,7 @@ def calculate_ratio(ratio):
raise ValidationError("Invalid ratio format")
class Scale(models.Model):
class Scale(SimpleBaseModel):
scale = models.CharField(max_length=32, unique=True)
slug = models.CharField(max_length=32, unique=True, editable=False)
ratio = models.CharField(max_length=16, validators=[calculate_ratio])
@@ -189,7 +184,7 @@ def scale_save(sender, instance, **kwargs):
instance.ratio_int = calculate_ratio(instance.ratio)
class RollingStockType(models.Model):
class RollingStockType(SimpleBaseModel):
type = models.CharField(max_length=64)
order = models.PositiveSmallIntegerField()
category = models.CharField(
@@ -219,12 +214,12 @@ class RollingStockType(models.Model):
return "{0} {1}".format(self.type, self.category)
class Tag(models.Model):
class Tag(SimpleBaseModel):
name = models.CharField(max_length=128, unique=True)
slug = models.CharField(max_length=128, unique=True)
class Meta:
ordering = ["name"]
ordering = ["slug"]
def __str__(self):
return self.name
@@ -239,15 +234,7 @@ class Tag(models.Model):
)
class GenericDocument(Document):
notes = tinymce.HTMLField(blank=True)
tags = models.ManyToManyField(Tag, blank=True)
class Meta:
verbose_name_plural = "Generic Documents"
class Shop(models.Model):
class Shop(SimpleBaseModel):
name = models.CharField(max_length=128, unique=True)
country = CountryField(blank=True)
website = models.URLField(blank=True)

View File

@@ -1,3 +1,371 @@
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.db import IntegrityError
# Create your tests here.
from metadata.models import (
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,6 +20,7 @@ class SiteConfigurationAdmin(SingletonModelAdmin):
"about",
"items_per_page",
"items_ordering",
"featured_items_ordering",
"currency",
"footer",
"footer_extended",
@@ -34,7 +35,8 @@ class SiteConfigurationAdmin(SingletonModelAdmin):
"fields": (
"show_version",
"use_cdn",
"extra_head",
"extra_html",
"extra_js",
"rest_api",
"version",
),

View File

@@ -0,0 +1,43 @@
# 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

@@ -0,0 +1,34 @@
# 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,21 +22,31 @@ class SiteConfiguration(SingletonModel):
default="6",
)
items_ordering = models.CharField(
max_length=10,
max_length=11,
choices=[
("type", "By rolling stock type"),
("company", "By company name"),
("identifier", "By rolling stock class"),
("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",
)
featured_items_ordering = items_ordering.clone()
currency = models.CharField(max_length=3, default="EUR")
footer = tinymce.HTMLField(blank=True)
footer_extended = tinymce.HTMLField(blank=True)
disclaimer = tinymce.HTMLField(blank=True)
show_version = models.BooleanField(default=True)
use_cdn = models.BooleanField(default=True)
extra_head = models.TextField(blank=True)
extra_html = models.TextField(
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:
verbose_name = "Site Configuration"

View File

@@ -1,5 +1,5 @@
/*!
* Bootstrap Icons v1.11.3 (https://icons.getbootstrap.com/)
* Bootstrap Icons v1.13.1 (https://icons.getbootstrap.com/)
* Copyright 2019-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE)
*/
@@ -7,8 +7,8 @@
@font-face {
font-display: block;
font-family: "bootstrap-icons";
src: url("./fonts/bootstrap-icons.woff2?dd67030699838ea613ee6dbda90effa6") format("woff2"),
url("./fonts/bootstrap-icons.woff?dd67030699838ea613ee6dbda90effa6") format("woff");
src: url("./fonts/bootstrap-icons.woff2?e34853135f9e39acf64315236852cd5a") format("woff2"),
url("./fonts/bootstrap-icons.woff?e34853135f9e39acf64315236852cd5a") format("woff");
}
.bi::before,
@@ -2076,3 +2076,31 @@ url("./fonts/bootstrap-icons.woff?dd67030699838ea613ee6dbda90effa6") format("wof
.bi-suitcase2-fill::before { content: "\f901"; }
.bi-suitcase2::before { content: "\f902"; }
.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

1
ram/portal/static/css/main.min.css vendored Normal file
View File

@@ -0,0 +1 @@
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

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -1,9 +1,7 @@
<svg width="26" height="16" enable-background="new 0 0 26 26" version="1" viewBox="0 0 26 16" xmlns="http://www.w3.org/2000/svg">
<path d="m2.8125 0.0010991a1.0001 1.0001 0 0 0-0.8125 1c0 0.55455-0.44545 1-1 1a1.0001 1.0001 0 0 0-1 1v10a1.0001 1.0001 0 0 0 1 1c0.55455 0 1 0.44546 1 1a1.0001 1.0001 0 0 0 1 1h20a1.0001 1.0001 0 0 0 1-1c0-0.55454 0.44546-1 1-1a1.0001 1.0001 0 0 0 1-1v-10a1.0001 1.0001 0 0 0-1-1c-0.55454 0-1-0.44545-1-1a1.0001 1.0001 0 0 0-1-1h-20a1.0001 1.0001 0 0 0-0.09375 0 1.0001 1.0001 0 0 0-0.09375 0zm0.78125 2h14.406v1h2v-1h2.4062c0.30628 0.76906 0.82469 1.2875 1.5938 1.5938v8.8125c-0.76906 0.30628-1.2875 0.82469-1.5938 1.5938h-2.4062v-1h-2v1h-14.406c-0.30628-0.76906-0.82469-1.2875-1.5938-1.5938v-8.8125c0.76906-0.30628 1.2875-0.82469 1.5938-1.5938zm14.406 2v2h2v-2zm0 3v2h2v-2zm0 3v2h2v-2z" enable-background="accumulate" overflow="visible" stroke-width="2" />
<style>
path {
text-indent:0;
text-transform:none;
}
</style>
</svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg width="32" height="16" preserveAspectRatio="xMidYMid" version="1.0" viewBox="0 0 24 12" xmlns="http://www.w3.org/2000/svg">
<metadata>Created by potrace 1.15, written by Peter Selinger 2001-2017</metadata>
<g transform="matrix(.0039261 0 0 -.0039261 -1.4249 18.53)">
<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"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

6
ram/portal/static/js/main.min.js vendored Normal file
View File

@@ -0,0 +1,6 @@
/*!
* 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

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

View File

@@ -0,0 +1,43 @@
// 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

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

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,26 @@
{% 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

@@ -12,14 +12,16 @@
<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>
{% if site_conf.show_version %}<br>Version {{ site_conf.version }}{% endif %}</p>
<p class="text-end fs-5">
{% 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 class="text-reset" title="Back to top" href="#"><i class="bi bi-arrow-up-left-square-fill"></i></a>
<p class="text-end">
{% if site_conf.disclaimer %}
<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>
{% 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>
</div>
<!-- Modal -->
<div class="modal fade" id="disclaimerModal" tabindex="-1" aria-labelledby="disclaimerLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-dialog modal-dialog-scrollable modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="disclaimerLabel">Disclaimer</h1>

View File

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

View File

@@ -0,0 +1,12 @@
<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

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

View File

@@ -0,0 +1,26 @@
{% 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

@@ -0,0 +1,18 @@
{% 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

@@ -0,0 +1,29 @@
{% 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,129 +9,24 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="description" content="{{ site_conf.about}}">
<meta name="description" content="{{ site_conf.about|striptags }}">
<meta name="author" content="{{ site_conf.site_author }}">
<meta name="generator" content="Django Framework">
<title>{% block title %}{{ title }}{% endblock %} - {{ site_conf.site_name }}</title>
<link rel="icon" href="{% static "favicon.png" %}" sizes="any">
<link rel="icon" href="{% static "favicon.svg" %}" type="image/svg+xml">
{% if site_conf.use_cdn %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.css" rel="stylesheet">
{% else %}
<link href="{% static "bootstrap@5.3.3/dist/css/bootstrap.min.css" %}" rel="stylesheet">
<link href="{% static "bootstrap-icons@1.11.3/font/bootstrap-icons.css" %}" rel="stylesheet">
<link href="{% static "bootstrap@5.3.8/dist/css/bootstrap.min.css" %}" rel="stylesheet">
<link href="{% static "bootstrap-icons@1.13.1/font/bootstrap-icons.css" %}" rel="stylesheet">
{% endif %}
<link href="{% static "css/main.css" %}?v={{ site_conf.version }}" rel="stylesheet">
<style>
.bd-placeholder-img {
font-size: 1.125rem;
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>
<link href="{% static "css/main.min.css" %}?v={{ site_conf.version }}" rel="stylesheet">
<script src="{% static "js/main.min.js" %}?v={{ site_conf.version }}"></script>
{% block extra_head %}
{{ site_conf.extra_head | safe }}
{% if site_conf.extra_html %}{{ site_conf.extra_html | safe }}{% endif %}
{% if site_conf.extra_js %}<script src="{% url 'extra_js' %}"></script>{% endif %}
{% endblock %}
</head>
<body>
@@ -140,19 +35,15 @@
<div class="container d-flex">
<div class="me-auto">
<a href="{% url 'index' %}" class="navbar-brand d-flex align-items-center">
<svg class="me-2" width="26" height="16" viewBox="0 0 26 16" xmlns="http://www.w3.org/2000/svg">
<path d="m2.8125 0.0010991a1.0001 1.0001 0 0 0-0.8125 1c0 0.55455-0.44545 1-1 1a1.0001 1.0001 0 0 0-1 1v10a1.0001 1.0001 0 0 0 1 1c0.55455 0 1 0.44546 1 1a1.0001 1.0001 0 0 0 1 1h20a1.0001 1.0001 0 0 0 1-1c0-0.55454 0.44546-1 1-1a1.0001 1.0001 0 0 0 1-1v-10a1.0001 1.0001 0 0 0-1-1c-0.55454 0-1-0.44545-1-1a1.0001 1.0001 0 0 0-1-1h-20a1.0001 1.0001 0 0 0-0.09375 0 1.0001 1.0001 0 0 0-0.09375 0zm0.78125 2h14.406v1h2v-1h2.4062c0.30628 0.76906 0.82469 1.2875 1.5938 1.5938v8.8125c-0.76906 0.30628-1.2875 0.82469-1.5938 1.5938h-2.4062v-1h-2v1h-14.406c-0.30628-0.76906-0.82469-1.2875-1.5938-1.5938v-8.8125c0.76906-0.30628 1.2875-0.82469 1.5938-1.5938zm14.406 2v2h2v-2zm0 3v2h2v-2zm0 3v2h2v-2z" stroke-width="2" />
<style>
path {
text-indent:0;
text-transform:none;
}
</style>
<svg class="me-2" width="32" height="16" version="1.0" viewBox="0 0 24 12" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(.0039261 0 0 -.0039261 -1.4249 18.53)">
<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"/>
</g>
</svg>
<strong>{{ site_conf.site_name }}</strong>
</a>
</div>
{% include 'includes/login.html' %}
{% include '_includes/login.html' %}
</div>
</nav>
</header>
@@ -190,7 +81,7 @@
{% show_bookshelf_menu %}
{% show_flatpages_menu user %}
</ul>
{% include 'includes/search.html' %}
{% include '_includes/search.html' %}
</div>
</div>
</nav>
@@ -215,12 +106,13 @@
<div class="container">{% block pagination %}{% endblock %}</div>
</div>
{% block extra_content %}{% endblock %}
{% include '_includes/symbols.html' %}
</main>
{% include 'includes/footer.html' %}
{% include '_includes/footer.html' %}
{% if site_conf.use_cdn %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
{% else %}
<script src="{% static "bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" %}"></script>
<script src="{% static "bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" %}"></script>
{% endif %}
</body>
</html>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,8 +7,11 @@
{{ t.name }}</a>{# new line is required #}
{% endfor %}
</p>
<small class="text-body-secondary">Updated {{ consist.updated_time | date:"M d, Y H:i" }}</small>
{% 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>
{% endblock %}
{% block carousel %}
{% if consist.image %}
@@ -23,13 +26,42 @@
</div>
{% endif %}
{% 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 %}
{% 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 'consist_pagination' 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' uuid=consist.uuid page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li>
{% else %}
<li class="page-item disabled">
@@ -45,13 +77,13 @@
{% if i == data.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %}
<li class="page-item"><a class="page-link" href="{% url 'consist_pagination' uuid=consist.uuid page=i %}#main-content">{{ i }}</a></li>
<li class="page-item"><a class="page-link" href="{% url 'consist' uuid=consist.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 'consist_pagination' 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' uuid=consist.uuid page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
</li>
{% else %}
<li class="page-item disabled">
@@ -70,10 +102,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>
</nav>
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
<option value="nav-summary" selected>Summary</option>
<option value="#nav-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">
<div class="tab-pane show active table-responsive" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
<table class="table table-striped">
<thead>
<tr>
@@ -83,9 +115,6 @@
{% if consist.company.freelance %}
<span class="badge text-bg-secondary">Freelance</span>
{% endif %}
{% if not consist.published %}
<span class="badge text-bg-warning">Draft</span>
{% endif %}
</div>
</th>
</tr>
@@ -109,7 +138,11 @@
{% endif %}
<tr>
<th scope="row">Length</th>
<td>{{ data | length }}</td>
<td>{{ consist.length }}</td>
</tr>
<tr>
<th scope="row">Composition</th>
<td>{% for t in consist.get_type_count %}{{ t.count }}x {{ t.type }} {{t.category }}{% if not forloop.last %} &raquo; {% endif %}{% endfor %}{% if loads %} | <i class="bi bi-download"></i> {{ loads|length }}x Load{{ loads|pluralize }}{% endif %}</td>
</tr>
</tbody>
</table>

View File

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

View File

@@ -1,6 +1,9 @@
{% extends 'base.html' %}
{% 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>
{% endblock %}
{% block carousel %}
@@ -9,11 +12,6 @@
<section class="py-4 text-start container">
<div class="row">
<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 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 %}

View File

@@ -3,3 +3,18 @@
{% block header %}
<div class="text-body-secondary">{{ site_conf.about | safe }}</div>
{% 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

@@ -1,30 +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>
<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">
{% if data.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'manufacturer_pagination' manufacturer=manufacturer.slug search=search page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
<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>
</li>
{% else %}
<li class="page-item disabled">
@@ -21,13 +21,13 @@
{% if i == data.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %}
<li class="page-item"><a class="page-link" href="{% url 'manufacturer_pagination' manufacturer=manufacturer.slug search=search page=i %}#main-content">{{ i }}</a></li>
<li class="page-item"><a class="page-link" href="{% url 'manufacturer' manufacturer=manufacturer.slug search=search page=i %}#main-content">{{ i }}</a></li>
{% endif %}
{% endif %}
{% endfor %}
{% if data.has_next %}
<li class="page-item">
<a class="page-link" href="{% url 'manufacturer_pagination' manufacturer=manufacturer.slug search=search page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
<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>
</li>
{% else %}
<li class="page-item disabled">

View File

@@ -7,7 +7,7 @@
<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="{% dynamic_pagination type page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
<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>
</li>
{% else %}
<li class="page-item disabled">
@@ -23,13 +23,13 @@
{% if i == data.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %}
<li class="page-item"><a class="page-link" href="{% dynamic_pagination type page=i %}#main-content">{{ i }}</a></li>
<li class="page-item"><a class="page-link" href="{% url request.resolver_match.url_name page=i %}#main-content">{{ i }}</a></li>
{% endif %}
{% endif %}
{% endfor %}
{% if data.has_next %}
<li class="page-item">
<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>
<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>
</li>
{% else %}
<li class="page-item disabled">

View File

@@ -1,12 +1,12 @@
{% extends "cards.html" %}
{% block pagination %}
{% if data.has_other_pages %}
{% with data.0.item.category as c %}
{% with data.0.category as c %}
<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 'manufacturers_pagination' 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' category=c page=data.previous_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-left"></i></a>
</li>
{% else %}
<li class="page-item disabled">
@@ -22,13 +22,13 @@
{% if i == data.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %}
<li class="page-item"><a class="page-link" href="{% url 'manufacturers_pagination' category=c page=i %}#main-content">{{ i }}</a></li>
<li class="page-item"><a class="page-link" href="{% url 'manufacturers' category=c page=i %}#main-content">{{ i }}</a></li>
{% endif %}
{% endif %}
{% endfor %}
{% if data.has_next %}
<li class="page-item">
<a class="page-link" href="{% url 'manufacturers_pagination' category=c page=data.next_page_number %}#main-content" tabindex="-1"><i class="bi bi-chevron-right"></i></a>
<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>
</li>
{% else %}
<li class="page-item disabled">

View File

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

View File

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

View File

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

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