mirror of
https://github.com/daniviga/django-ram.git
synced 2026-02-03 17:40:39 +01:00
Compare commits
85 Commits
v0.17.5
...
b9e55936e1
| Author | SHA1 | Date | |
|---|---|---|---|
|
b9e55936e1
|
|||
|
268fe8f9a7
|
|||
|
289ace4a49
|
|||
|
8c216c7e56
|
|||
|
d1e741ebfd
|
|||
|
650a93676e
|
|||
|
265aed56fe
|
|||
|
167a0593de
|
|||
|
a254786ddc
|
|||
|
8d899e4d9f
|
|||
|
40df9eb376
|
|||
|
226f0b32ba
|
|||
|
3c121a60a4
|
|||
|
ab606859d1
|
|||
|
a16801eb4b
|
|||
|
b8d10a68ca
|
|||
|
e690ded04f
|
|||
|
15a7ffaf4f
|
|||
|
a11f97bcad
|
|||
|
3c854bda1b
|
|||
|
564416b3d5
|
|||
|
967ea5d495
|
|||
|
7656aa8b68
|
|||
| 1be102b9d4 | |||
| 4ec7b8fc18 | |||
| 9a469378df | |||
| ede8741473 | |||
|
49c8d804d6
|
|||
|
2ab2d00585
|
|||
|
c95064ddec
|
|||
|
16bd82de39
|
|||
|
2ae7f2685d
|
|||
|
29f9a213b4
|
|||
|
884661d4e1
|
|||
|
c7cace96f7
|
|||
|
d3c099c05b
|
|||
|
903633b5a7
|
|||
|
ee775d737e
|
|||
|
8087ab5997
|
|||
|
1899747909
|
|||
|
0880bd0817
|
|||
|
74d7df2c8b
|
|||
|
c81508bbd5
|
|||
|
b4f69d8a34
|
|||
| 676418cb67 | |||
|
98d2e7beab
|
|||
|
fb17dc2a7c
|
|||
|
5a71dc36fa
|
|||
|
c539255bf9
|
|||
|
fc527d5cd1
|
|||
|
f45d754c91
|
|||
|
e9c9ede357
|
|||
| 39b0a9378b | |||
| 6b10051bc4 | |||
|
3804c3379b
|
|||
|
1b769da553
|
|||
|
f655900411
|
|||
|
3e69b9ae6e
|
|||
|
66c3c3f51c
|
|||
|
935c439084
|
|||
|
d757388ca8
|
|||
| 536101d2ff | |||
|
955397acd5
|
|||
|
672cadd7e1
|
|||
|
464fe57536
|
|||
|
bd16c7eee7
|
|||
|
cc2e374558
|
|||
|
1c25ac9b14
|
|||
|
de126a735d
|
|||
|
18b5ab8053
|
|||
|
3acc80e2ad
|
|||
|
552ba39970
|
|||
|
222e2075ec
|
|||
|
b5c57dcd94
|
|||
|
b81c63898f
|
|||
|
76b266b1f9
|
|||
|
86657a3b9f
|
|||
|
d0d25424fb
|
|||
|
292b95b8ed
|
|||
|
dea7a594bc
|
|||
|
60195bc99f
|
|||
|
7673f0514a
|
|||
|
40f42a9ee9
|
|||
|
2e06e94fde
|
|||
|
ece8d1ad94
|
8
.github/workflows/django.yml
vendored
8
.github/workflows/django.yml
vendored
@@ -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
5
.gitignore
vendored
@@ -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
340
AGENTS.md
Normal 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
|
||||
Submodule arduino/CommandStation-EX updated: 911bbd63be...313d2cd3e0
Submodule arduino/WebThrottle-EX updated: eb43d7906f...eeec7d4af6
Submodule arduino/arduino-cli updated: fa6eafcbbe...08ff7e2b76
Submodule arduino/dcc-ex.github.io updated: a0f886b69f...190d3adfa1
43
docs/nginx/nginx.conf
Normal file
43
docs/nginx/nginx.conf
Normal 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
6
package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"clean-css-cli": "^5.6.3",
|
||||
"terser": "^5.44.1"
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,22 @@ 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
|
||||
from repository.models import (
|
||||
BookDocument,
|
||||
CatalogDocument,
|
||||
MagazineIssueDocument,
|
||||
)
|
||||
from bookshelf.models import (
|
||||
BaseBookProperty,
|
||||
BaseBookImage,
|
||||
@@ -16,13 +25,16 @@ from bookshelf.models import (
|
||||
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"
|
||||
@@ -40,7 +52,7 @@ class BookPropertyInline(admin.TabularInline):
|
||||
class BookDocInline(admin.TabularInline):
|
||||
model = BookDocument
|
||||
min_num = 0
|
||||
extra = 0
|
||||
extra = 1
|
||||
classes = ["collapse"]
|
||||
|
||||
|
||||
@@ -48,9 +60,27 @@ 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,
|
||||
@@ -66,7 +96,7 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
autocomplete_fields = ("authors", "publisher", "shop")
|
||||
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 = (
|
||||
(
|
||||
@@ -123,13 +153,14 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
@admin.display(description="Invoices")
|
||||
def invoices(self, obj):
|
||||
if obj.invoice.exists():
|
||||
html = "<br>".join(
|
||||
"<a href=\"{}\" target=\"_blank\">{}</a>".format(
|
||||
i.file.url, i
|
||||
) for i in obj.invoice.all())
|
||||
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 format_html(html)
|
||||
return html
|
||||
|
||||
@admin.display(description="Publisher")
|
||||
def get_publisher(self, obj):
|
||||
@@ -201,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
|
||||
)
|
||||
|
||||
|
||||
@@ -228,7 +259,12 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
autocomplete_fields = ("manufacturer",)
|
||||
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 = (
|
||||
(
|
||||
@@ -285,13 +321,14 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
@admin.display(description="Invoices")
|
||||
def invoices(self, obj):
|
||||
if obj.invoice.exists():
|
||||
html = "<br>".join(
|
||||
"<a href=\"{}\" target=\"_blank\">{}</a>".format(
|
||||
i.file.url, i
|
||||
) for i in obj.invoice.all())
|
||||
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 format_html(html)
|
||||
return html
|
||||
|
||||
def download_csv(modeladmin, request, queryset):
|
||||
header = [
|
||||
@@ -344,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]
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
|
||||
123
ram/bookshelf/migrations/0024_alter_basebook_language.py
Normal file
123
ram/bookshelf/migrations/0024_alter_basebook_language.py
Normal 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,
|
||||
),
|
||||
),
|
||||
]
|
||||
224
ram/bookshelf/migrations/0025_magazine_magazineissue.py
Normal file
224
ram/bookshelf/migrations/0025_magazine_magazineissue.py
Normal 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",),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
18
ram/bookshelf/migrations/0027_magazine_website.py
Normal file
18
ram/bookshelf/migrations/0027_magazine_website.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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"),
|
||||
),
|
||||
]
|
||||
53
ram/bookshelf/migrations/0030_tocentry.py
Normal file
53
ram/bookshelf/migrations/0030_tocentry.py
Normal 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"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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(),
|
||||
),
|
||||
]
|
||||
@@ -1,8 +1,12 @@
|
||||
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
|
||||
@@ -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):
|
||||
@@ -120,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}
|
||||
)
|
||||
|
||||
|
||||
@@ -129,9 +129,10 @@ 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"]
|
||||
@@ -146,10 +147,142 @@ class Catalog(BaseBook):
|
||||
|
||||
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
|
||||
|
||||
@@ -49,3 +49,5 @@ class CatalogSerializer(serializers.ModelSerializer):
|
||||
"price",
|
||||
)
|
||||
read_only_fields = ("creation_time", "updated_time")
|
||||
|
||||
# FIXME: add Magazine and MagazineIssue serializers
|
||||
|
||||
16
ram/bookshelf/static/admin/js/magazine_issue_defaults.js
Normal file
16
ram/bookshelf/static/admin/js/magazine_issue_defaults.js
Normal 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;
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__name", "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]
|
||||
|
||||
42
ram/consist/migrations/0017_consist_scale.py
Normal file
42
ram/consist/migrations/0017_consist_scale.py
Normal 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
|
||||
),
|
||||
]
|
||||
25
ram/consist/migrations/0018_alter_consist_scale.py
Normal file
25
ram/consist/migrations/0018_alter_consist_scale.py
Normal 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"
|
||||
),
|
||||
),
|
||||
]
|
||||
18
ram/consist/migrations/0019_consistitem_load.py
Normal file
18
ram/consist/migrations/0019_consistitem_load.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
@@ -41,10 +43,10 @@ class Consist(BaseModel):
|
||||
|
||||
@property
|
||||
def length(self):
|
||||
return self.consist_item.count()
|
||||
return self.consist_item.filter(load=False).count()
|
||||
|
||||
def get_type_count(self):
|
||||
return self.consist_item.annotate(
|
||||
return self.consist_item.filter(load=False).annotate(
|
||||
type=models.F("rolling_stock__rolling_class__type__type")
|
||||
).values(
|
||||
"type"
|
||||
@@ -54,6 +56,15 @@ class Consist(BaseModel):
|
||||
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
|
||||
@@ -67,6 +78,7 @@ class ConsistItem(models.Model):
|
||||
Consist, on_delete=models.CASCADE, related_name="consist_item"
|
||||
)
|
||||
rolling_stock = models.ForeignKey(RollingStock, on_delete=models.CASCADE)
|
||||
load = models.BooleanField(default=False)
|
||||
order = models.PositiveIntegerField(blank=False, null=False)
|
||||
|
||||
class Meta:
|
||||
@@ -82,7 +94,24 @@ class ConsistItem(models.Model):
|
||||
return "{0}".format(self.rolling_stock)
|
||||
|
||||
def clean(self):
|
||||
if self.consist.published and not self.rolling_stock.published:
|
||||
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."
|
||||
)
|
||||
@@ -94,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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -24,7 +24,7 @@ class PropertyAdmin(admin.ModelAdmin):
|
||||
class DecoderDocInline(admin.TabularInline):
|
||||
model = DecoderDocument
|
||||
min_num = 0
|
||||
extra = 0
|
||||
extra = 1
|
||||
classes = ["collapse"]
|
||||
|
||||
|
||||
@@ -47,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,6 +88,12 @@ class RollingStockTypeAdmin(SortableAdminMixin, admin.ModelAdmin):
|
||||
|
||||
@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
|
||||
)
|
||||
|
||||
@@ -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"]},
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,11 +7,12 @@ from django.dispatch.dispatcher import receiver
|
||||
from django.core.exceptions import ValidationError
|
||||
from django_countries.fields import CountryField
|
||||
|
||||
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,
|
||||
@@ -27,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
|
||||
@@ -43,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
|
||||
@@ -57,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)
|
||||
@@ -78,7 +90,7 @@ class Company(models.Model):
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = "Companies"
|
||||
ordering = ["name"]
|
||||
ordering = ["slug"]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -101,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,
|
||||
@@ -137,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])
|
||||
@@ -172,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(
|
||||
@@ -202,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
|
||||
@@ -222,7 +234,7 @@ class Tag(models.Model):
|
||||
)
|
||||
|
||||
|
||||
class Shop(models.Model):
|
||||
class Shop(SimpleBaseModel):
|
||||
name = models.CharField(max_length=128, unique=True)
|
||||
country = CountryField(blank=True)
|
||||
website = models.URLField(blank=True)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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."
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
Binary file not shown.
@@ -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"; }
|
||||
Binary file not shown.
Binary file not shown.
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
6
ram/portal/static/bootstrap@5.3.8/dist/css/bootstrap.min.css
vendored
Normal file
6
ram/portal/static/bootstrap@5.3.8/dist/css/bootstrap.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
ram/portal/static/bootstrap@5.3.8/dist/css/bootstrap.min.css.map
vendored
Normal file
1
ram/portal/static/bootstrap@5.3.8/dist/css/bootstrap.min.css.map
vendored
Normal file
File diff suppressed because one or more lines are too long
7
ram/portal/static/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js
vendored
Normal file
7
ram/portal/static/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
ram/portal/static/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js.map
vendored
Normal file
1
ram/portal/static/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
ram/portal/static/css/main.min.css
vendored
Normal file
1
ram/portal/static/css/main.min.css
vendored
Normal 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}
|
||||
7
ram/portal/static/css/src/README.md
Normal file
7
ram/portal/static/css/src/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Compile main.min.css
|
||||
|
||||
```bash
|
||||
$ npm install clean-css-cli
|
||||
$ npx cleancss -o ../main.min.css main.css
|
||||
```
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 5.2 KiB |
@@ -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
6
ram/portal/static/js/main.min.js
vendored
Normal 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)})});
|
||||
7
ram/portal/static/js/src/README.md
Normal file
7
ram/portal/static/js/src/README.md
Normal 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
|
||||
```
|
||||
|
||||
43
ram/portal/static/js/src/tabs_selector.js
Normal file
43
ram/portal/static/js/src/tabs_selector.js
Normal 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;
|
||||
});
|
||||
});
|
||||
});
|
||||
76
ram/portal/static/js/src/theme_selector.js
Normal file
76
ram/portal/static/js/src/theme_selector.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})()
|
||||
15
ram/portal/static/js/src/validators.js
Normal file
15
ram/portal/static/js/src/validators.js
Normal 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)
|
||||
})
|
||||
});
|
||||
26
ram/portal/templates/_includes/documents.html
Normal file
26
ram/portal/templates/_includes/documents.html
Normal 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 %}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</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>
|
||||
@@ -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>
|
||||
12
ram/portal/templates/_includes/search.html
Normal file
12
ram/portal/templates/_includes/search.html
Normal 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>
|
||||
39
ram/portal/templates/_includes/symbols.html
Normal file
39
ram/portal/templates/_includes/symbols.html
Normal 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>
|
||||
|
||||
26
ram/portal/templates/_modules/documents.html
Normal file
26
ram/portal/templates/_modules/documents.html
Normal 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 %}
|
||||
|
||||
18
ram/portal/templates/_modules/properties.html
Normal file
18
ram/portal/templates/_modules/properties.html
Normal 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 %}
|
||||
|
||||
29
ram/portal/templates/_modules/purchase_data.html
Normal file
29
ram/portal/templates/_modules/purchase_data.html
Normal 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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</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>
|
||||
<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 %}
|
||||
</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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
128
ram/portal/templates/bookshelf/magazine.html
Normal file
128
ram/portal/templates/bookshelf/magazine.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,69 +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>
|
||||
<img src="{{ d.item.company.country.flag }}" alt="{{ d.item.company.country }}">
|
||||
<abbr title="{{ d.item.company.extended_name }}">{{ d.item.company }}</abbr>
|
||||
<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.length }}</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>
|
||||
|
||||
100
ram/portal/templates/cards/magazine.html
Normal file
100
ram/portal/templates/cards/magazine.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
<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.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>
|
||||
<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,71 +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>
|
||||
<img src="{{ d.item.company.country.flag }}" alt="{{ d.item.company.country }}">
|
||||
<a href="{% url 'filtered' _filter="company" search=d.item.company.slug %}"><abbr title="{{ d.item.company.extended_name }}">{{ d.item.company }}</abbr></a>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> 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>{{ consist.length }}: {% for t in consist.get_type_count %}{{ t.count }}x {{ t.type }} {{t.category }}{% if not forloop.last %} + {% endif %}{% endfor %}</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 %} » {% endif %}{% endfor %}{% if loads %} | <i class="bi bi-download"></i> {{ loads|length }}x Load{{ loads|pluralize }}{% endif %}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
36
ram/portal/templatetags/dcc.py
Normal file
36
ram/portal/templatetags/dcc.py
Normal 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
|
||||
@@ -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])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django import template
|
||||
from portal.models import Flatpage
|
||||
from bookshelf.models import Book, Catalog
|
||||
from bookshelf.models import Book, Catalog, Magazine
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@@ -8,10 +8,14 @@ register = template.Library()
|
||||
@register.inclusion_tag('bookshelf/bookshelf_menu.html')
|
||||
def show_bookshelf_menu():
|
||||
# FIXME: Filter out unpublished books and catalogs?
|
||||
books = Book.objects.exists()
|
||||
catalogs = Catalog.objects.exists()
|
||||
magazines = Magazine.objects.exists()
|
||||
return {
|
||||
"bookshelf_menu": (Book.objects.exists() or Catalog.objects.exists()),
|
||||
"books_menu": Book.objects.exists(),
|
||||
"catalogs_menu": Catalog.objects.exists(),
|
||||
"bookshelf_menu": (books or catalogs or magazines),
|
||||
"books_menu": books,
|
||||
"catalogs_menu": catalogs,
|
||||
"magazines_menu": magazines,
|
||||
}
|
||||
|
||||
|
||||
|
||||
11
ram/portal/templatetags/shuffle.py
Normal file
11
ram/portal/templatetags/shuffle.py
Normal file
@@ -0,0 +1,11 @@
|
||||
import random
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def shuffle(items):
|
||||
shuffled_items = list(items)
|
||||
random.shuffle(shuffled_items)
|
||||
return shuffled_items
|
||||
@@ -1,7 +1,8 @@
|
||||
from django.urls import path
|
||||
|
||||
from portal.views import (
|
||||
GetData,
|
||||
RenderExtraJS,
|
||||
GetHome,
|
||||
GetRoster,
|
||||
GetObjectsFiltered,
|
||||
GetManufacturerItem,
|
||||
@@ -15,103 +16,84 @@ from portal.views import (
|
||||
Types,
|
||||
Books,
|
||||
Catalogs,
|
||||
Magazines,
|
||||
GetMagazine,
|
||||
GetMagazineIssue,
|
||||
GetBookCatalog,
|
||||
SearchObjects,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("", GetData.as_view(template="home.html"), name="index"),
|
||||
path("", GetHome.as_view(), name="index"),
|
||||
path("extra.js", RenderExtraJS.as_view(), name="extra_js"),
|
||||
path("roster", GetRoster.as_view(), name="roster"),
|
||||
path(
|
||||
"roster/page/<int:page>",
|
||||
GetRoster.as_view(),
|
||||
name="rosters_pagination"
|
||||
),
|
||||
path("roster/page/<int:page>", GetRoster.as_view(), name="roster"),
|
||||
path(
|
||||
"page/<str:flatpage>",
|
||||
GetFlatpage.as_view(),
|
||||
name="flatpage",
|
||||
),
|
||||
path(
|
||||
"consists",
|
||||
Consists.as_view(),
|
||||
name="consists"
|
||||
),
|
||||
path(
|
||||
"consists/page/<int:page>",
|
||||
Consists.as_view(),
|
||||
name="consists_pagination"
|
||||
),
|
||||
path("consists", Consists.as_view(), name="consists"),
|
||||
path("consists/page/<int:page>", Consists.as_view(), name="consists"),
|
||||
path("consist/<uuid:uuid>", GetConsist.as_view(), name="consist"),
|
||||
path(
|
||||
"consist/<uuid:uuid>/page/<int:page>",
|
||||
GetConsist.as_view(),
|
||||
name="consist_pagination",
|
||||
),
|
||||
path(
|
||||
"companies",
|
||||
Companies.as_view(),
|
||||
name="companies"
|
||||
name="consist",
|
||||
),
|
||||
path("companies", Companies.as_view(), name="companies"),
|
||||
path(
|
||||
"companies/page/<int:page>",
|
||||
Companies.as_view(),
|
||||
name="companies_pagination",
|
||||
name="companies",
|
||||
),
|
||||
path(
|
||||
"manufacturers/<str:category>",
|
||||
Manufacturers.as_view(template="pagination_manufacturers.html"),
|
||||
name="manufacturers"
|
||||
name="manufacturers",
|
||||
),
|
||||
path(
|
||||
"manufacturers/<str:category>/page/<int:page>",
|
||||
Manufacturers.as_view(template="pagination_manufacturers.html"),
|
||||
name="manufacturers_pagination",
|
||||
name="manufacturers",
|
||||
),
|
||||
path("scales", Scales.as_view(), name="scales"),
|
||||
path("scales/page/<int:page>", Scales.as_view(), name="scales"),
|
||||
path("types", Types.as_view(), name="rolling_stock_types"),
|
||||
path("types/page/<int:page>", Types.as_view(), name="rolling_stock_types"),
|
||||
path("bookshelf/books", Books.as_view(), name="books"),
|
||||
path("bookshelf/books/page/<int:page>", Books.as_view(), name="books"),
|
||||
path(
|
||||
"bookshelf/magazine/<uuid:uuid>",
|
||||
GetMagazine.as_view(),
|
||||
name="magazine",
|
||||
),
|
||||
path(
|
||||
"scales",
|
||||
Scales.as_view(),
|
||||
name="scales"
|
||||
"bookshelf/magazine/<uuid:uuid>/page/<int:page>",
|
||||
GetMagazine.as_view(),
|
||||
name="magazine",
|
||||
),
|
||||
path(
|
||||
"scales/page/<int:page>",
|
||||
Scales.as_view(),
|
||||
name="scales_pagination"
|
||||
"bookshelf/magazine/<uuid:magazine>/issue/<uuid:uuid>",
|
||||
GetMagazineIssue.as_view(),
|
||||
name="issue",
|
||||
),
|
||||
path("bookshelf/magazines", Magazines.as_view(), name="magazines"),
|
||||
path(
|
||||
"types",
|
||||
Types.as_view(),
|
||||
name="rolling_stock_types"
|
||||
),
|
||||
path(
|
||||
"types/page/<int:page>",
|
||||
Types.as_view(),
|
||||
name="rolling_stock_types_pagination"
|
||||
),
|
||||
path(
|
||||
"bookshelf/books",
|
||||
Books.as_view(),
|
||||
name="books"
|
||||
),
|
||||
path(
|
||||
"bookshelf/books/page/<int:page>",
|
||||
Books.as_view(),
|
||||
name="books_pagination"
|
||||
"bookshelf/magazines/page/<int:page>",
|
||||
Magazines.as_view(),
|
||||
name="magazines",
|
||||
),
|
||||
path(
|
||||
"bookshelf/<str:selector>/<uuid:uuid>",
|
||||
GetBookCatalog.as_view(),
|
||||
name="bookshelf_item"
|
||||
),
|
||||
path(
|
||||
"bookshelf/catalogs",
|
||||
Catalogs.as_view(),
|
||||
name="catalogs"
|
||||
name="bookshelf_item",
|
||||
),
|
||||
path("bookshelf/catalogs", Catalogs.as_view(), name="catalogs"),
|
||||
path(
|
||||
"bookshelf/catalogs/page/<int:page>",
|
||||
Catalogs.as_view(),
|
||||
name="catalogs_pagination"
|
||||
name="catalogs",
|
||||
),
|
||||
path(
|
||||
"search",
|
||||
@@ -121,7 +103,7 @@ urlpatterns = [
|
||||
path(
|
||||
"search/<str:search>/page/<int:page>",
|
||||
SearchObjects.as_view(),
|
||||
name="search_pagination",
|
||||
name="search",
|
||||
),
|
||||
path(
|
||||
"manufacturer/<str:manufacturer>",
|
||||
@@ -131,7 +113,7 @@ urlpatterns = [
|
||||
path(
|
||||
"manufacturer/<str:manufacturer>/page/<int:page>",
|
||||
GetManufacturerItem.as_view(),
|
||||
name="manufacturer_pagination",
|
||||
name="manufacturer",
|
||||
),
|
||||
path(
|
||||
"manufacturer/<str:manufacturer>/<str:search>",
|
||||
@@ -141,7 +123,7 @@ urlpatterns = [
|
||||
path(
|
||||
"manufacturer/<str:manufacturer>/<str:search>/page/<int:page>",
|
||||
GetManufacturerItem.as_view(),
|
||||
name="manufacturer_pagination",
|
||||
name="manufacturer",
|
||||
),
|
||||
path(
|
||||
"<str:_filter>/<str:search>",
|
||||
@@ -151,7 +133,7 @@ urlpatterns = [
|
||||
path(
|
||||
"<str:_filter>/<str:search>/page/<int:page>",
|
||||
GetObjectsFiltered.as_view(),
|
||||
name="filtered_pagination",
|
||||
name="filtered",
|
||||
),
|
||||
path("<uuid:uuid>", GetRollingStock.as_view(), name="rolling_stock"),
|
||||
]
|
||||
|
||||
@@ -4,10 +4,13 @@ from itertools import chain
|
||||
from functools import reduce
|
||||
from urllib.parse import unquote
|
||||
|
||||
from django.conf import settings
|
||||
from django.views import View
|
||||
from django.http import Http404, HttpResponseBadRequest
|
||||
from django.urls import Resolver404
|
||||
from django.http import Http404, HttpResponse, HttpResponseBadRequest
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
from django.db.models import Q, Count
|
||||
from django.db.models import F, Q, Count
|
||||
from django.db.models.functions import Lower
|
||||
from django.shortcuts import render, get_object_or_404, get_list_or_404
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.paginator import Paginator
|
||||
@@ -16,7 +19,7 @@ from portal.utils import get_site_conf
|
||||
from portal.models import Flatpage
|
||||
from roster.models import RollingStock
|
||||
from consist.models import Consist
|
||||
from bookshelf.models import Book, Catalog
|
||||
from bookshelf.models import Book, Catalog, Magazine, MagazineIssue
|
||||
from metadata.models import (
|
||||
Company,
|
||||
Manufacturer,
|
||||
@@ -31,52 +34,77 @@ def get_items_per_page():
|
||||
items_per_page = get_site_conf().items_per_page
|
||||
except (OperationalError, ProgrammingError):
|
||||
items_per_page = 6
|
||||
return items_per_page
|
||||
return int(items_per_page)
|
||||
|
||||
|
||||
def get_order_by_field():
|
||||
def get_items_ordering(config="items_ordering"):
|
||||
try:
|
||||
order_by = get_site_conf().items_ordering
|
||||
order_by = getattr(get_site_conf(), config)
|
||||
except (OperationalError, ProgrammingError):
|
||||
order_by = "type"
|
||||
|
||||
fields = [
|
||||
"rolling_class__type",
|
||||
"rolling_class__company",
|
||||
"rolling_class__identifier",
|
||||
"road_number_int",
|
||||
"rolling_class__type", # 0
|
||||
"rolling_class__company", # 1
|
||||
"rolling_class__company__country", # 2
|
||||
"rolling_class__identifier", # 3
|
||||
"road_number_int", # 4
|
||||
]
|
||||
|
||||
if order_by == "type":
|
||||
return (fields[0], fields[1], fields[2], fields[3])
|
||||
elif order_by == "company":
|
||||
return (fields[1], fields[0], fields[2], fields[3])
|
||||
elif order_by == "identifier":
|
||||
return (fields[2], fields[0], fields[1], fields[3])
|
||||
order_map = {
|
||||
"type": (0, 1, 3, 4),
|
||||
"company": (1, 0, 3, 4),
|
||||
"country": (2, 0, 1, 3, 4),
|
||||
"cou+com": (2, 1, 0, 3, 4),
|
||||
"class": (0, 3, 1, 4),
|
||||
}
|
||||
|
||||
return tuple(fields[i] for i in order_map.get(order_by, "type"))
|
||||
|
||||
|
||||
class Render404(View):
|
||||
def get(self, request, exception):
|
||||
return render(request, "base.html", {"title": "404 page not found"})
|
||||
generic_message = "Page not found"
|
||||
if isinstance(exception, Resolver404):
|
||||
message = generic_message
|
||||
else:
|
||||
message = str(exception) if exception else generic_message
|
||||
|
||||
return render(
|
||||
request,
|
||||
"base.html",
|
||||
{"title": message},
|
||||
status=404,
|
||||
)
|
||||
|
||||
|
||||
class RenderExtraJS(View):
|
||||
def get(self, request):
|
||||
try:
|
||||
extra_js = get_site_conf().extra_js
|
||||
except (OperationalError, ProgrammingError):
|
||||
extra_js = ""
|
||||
|
||||
return HttpResponse(extra_js, content_type="application/javascript")
|
||||
|
||||
|
||||
class GetData(View):
|
||||
title = "Home"
|
||||
title = None
|
||||
template = "pagination.html"
|
||||
item_type = "roster"
|
||||
filter = Q() # empty filter by default
|
||||
|
||||
def get_data(self, request):
|
||||
return (
|
||||
RollingStock.objects.get_published(request.user)
|
||||
.order_by(*get_order_by_field())
|
||||
.order_by(*get_items_ordering())
|
||||
.filter(self.filter)
|
||||
)
|
||||
|
||||
def get(self, request, page=1):
|
||||
data = []
|
||||
for item in self.get_data(request):
|
||||
data.append({"type": self.item_type, "item": item})
|
||||
if self.title is None or self.template is None:
|
||||
raise Exception("title and template must be defined")
|
||||
|
||||
data = list(self.get_data(request))
|
||||
|
||||
paginator = Paginator(data, get_items_per_page())
|
||||
data = paginator.get_page(page)
|
||||
@@ -89,7 +117,6 @@ class GetData(View):
|
||||
self.template,
|
||||
{
|
||||
"title": self.title,
|
||||
"type": self.item_type,
|
||||
"data": data,
|
||||
"matches": paginator.count,
|
||||
"page_range": page_range,
|
||||
@@ -97,18 +124,38 @@ class GetData(View):
|
||||
)
|
||||
|
||||
|
||||
class GetRoster(GetData):
|
||||
title = "The Roster"
|
||||
item_type = "roster"
|
||||
class GetHome(GetData):
|
||||
title = "Home"
|
||||
template = "home.html"
|
||||
|
||||
def get_data(self, request):
|
||||
return RollingStock.objects.get_published(request.user).order_by(
|
||||
*get_order_by_field()
|
||||
)
|
||||
max_items = min(settings.FEATURED_ITEMS_MAX, get_items_per_page())
|
||||
return (
|
||||
RollingStock.objects.get_published(request.user)
|
||||
.filter(featured=True)
|
||||
.order_by(*get_items_ordering(config="featured_items_ordering"))[
|
||||
:max_items
|
||||
]
|
||||
) or super().get_data(request)
|
||||
|
||||
|
||||
class GetRoster(GetData):
|
||||
title = "The Roster"
|
||||
|
||||
|
||||
class SearchObjects(View):
|
||||
def run_search(self, request, search, _filter, page=1):
|
||||
"""
|
||||
Run the search query on the database and return the results.
|
||||
param request: HTTP request
|
||||
param search: search string
|
||||
param _filter: filter to apply (type, company, manufacturer, scale)
|
||||
param page: page number for pagination
|
||||
return: tuple (data, matches, page_range)
|
||||
1. data: list of dicts with keys "type" and "item"
|
||||
2. matches: total number of matches
|
||||
3. page_range: elided page range for pagination
|
||||
"""
|
||||
if _filter is None:
|
||||
query = reduce(
|
||||
operator.or_,
|
||||
@@ -151,15 +198,13 @@ class SearchObjects(View):
|
||||
# FIXME duplicated code!
|
||||
# FIXME see if it makes sense to filter calatogs and books by scale
|
||||
# and manufacturer as well
|
||||
data = []
|
||||
roster = (
|
||||
RollingStock.objects.get_published(request.user)
|
||||
.filter(query)
|
||||
.distinct()
|
||||
.order_by(*get_order_by_field())
|
||||
.order_by(*get_items_ordering())
|
||||
)
|
||||
for item in roster:
|
||||
data.append({"type": "roster", "item": item})
|
||||
data = list(roster)
|
||||
|
||||
if _filter is None:
|
||||
consists = (
|
||||
@@ -172,20 +217,41 @@ class SearchObjects(View):
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
for item in consists:
|
||||
data.append({"type": "consist", "item": item})
|
||||
data = list(chain(data, consists))
|
||||
books = (
|
||||
Book.objects.get_published(request.user)
|
||||
.filter(title__icontains=search)
|
||||
.filter(
|
||||
Q(
|
||||
Q(title__icontains=search)
|
||||
| Q(description__icontains=search)
|
||||
| Q(toc__title__icontains=search)
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
catalogs = (
|
||||
Catalog.objects.get_published(request.user)
|
||||
.filter(manufacturer__name__icontains=search)
|
||||
.filter(
|
||||
Q(
|
||||
Q(manufacturer__name__icontains=search)
|
||||
| Q(description__icontains=search)
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
for item in list(chain(books, catalogs)):
|
||||
data.append({"type": "book", "item": item})
|
||||
data = list(chain(data, books, catalogs))
|
||||
magazine_issues = (
|
||||
MagazineIssue.objects.get_published(request.user)
|
||||
.filter(
|
||||
Q(
|
||||
Q(magazine__name__icontains=search)
|
||||
| Q(description__icontains=search)
|
||||
| Q(toc__title__icontains=search)
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
data = list(chain(data, magazine_issues))
|
||||
|
||||
paginator = Paginator(data, get_items_per_page())
|
||||
data = paginator.get_page(page)
|
||||
@@ -211,6 +277,9 @@ class SearchObjects(View):
|
||||
return _filter, search
|
||||
|
||||
def get(self, request, search, page=1):
|
||||
if not search:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
try:
|
||||
encoded_search = search
|
||||
search = base64.b64decode(search.encode()).decode()
|
||||
@@ -241,20 +310,36 @@ class SearchObjects(View):
|
||||
|
||||
class GetManufacturerItem(View):
|
||||
def get(self, request, manufacturer, search="all", page=1):
|
||||
"""
|
||||
Get all items from a specific manufacturer. If `search` is not "all",
|
||||
filter by item number as well, for example to get all itmes from the
|
||||
same set.
|
||||
The view returns both rolling stock and catalogs.
|
||||
param request: HTTP request
|
||||
param manufacturer: Manufacturer slug
|
||||
param search: item number slug or "all"
|
||||
param page: page number for pagination
|
||||
return: rendered template
|
||||
1. manufacturer: Manufacturer object
|
||||
2. search: item number slug or "all"
|
||||
3. data: list of dicts with keys "type" and "item"
|
||||
4. matches: total number of matches
|
||||
5. page_range: elided page range for pagination
|
||||
"""
|
||||
manufacturer = get_object_or_404(
|
||||
Manufacturer, slug__iexact=manufacturer
|
||||
)
|
||||
|
||||
if search != "all":
|
||||
roster = get_list_or_404(
|
||||
RollingStock.objects.get_published(request.user).order_by(
|
||||
*get_order_by_field()
|
||||
*get_items_ordering()
|
||||
),
|
||||
Q(
|
||||
Q(manufacturer=manufacturer)
|
||||
& Q(item_number_slug__exact=search)
|
||||
),
|
||||
)
|
||||
catalogs = [] # no catalogs when searching for a specific item
|
||||
title = "{0}: {1}".format(
|
||||
manufacturer,
|
||||
# all returned records must have the same `item_number``;
|
||||
@@ -269,14 +354,14 @@ class GetManufacturerItem(View):
|
||||
| Q(rolling_class__manufacturer=manufacturer)
|
||||
)
|
||||
.distinct()
|
||||
.order_by(*get_order_by_field())
|
||||
.order_by(*get_items_ordering())
|
||||
)
|
||||
catalogs = Catalog.objects.get_published(request.user).filter(
|
||||
manufacturer=manufacturer
|
||||
)
|
||||
title = "Manufacturer: {0}".format(manufacturer)
|
||||
|
||||
data = []
|
||||
for item in roster:
|
||||
data.append({"type": "roster", "item": item})
|
||||
|
||||
data = list(chain(roster, catalogs))
|
||||
paginator = Paginator(data, get_items_per_page())
|
||||
data = paginator.get_page(page)
|
||||
page_range = paginator.get_elided_page_range(
|
||||
@@ -322,12 +407,18 @@ class GetObjectsFiltered(View):
|
||||
RollingStock.objects.get_published(request.user)
|
||||
.filter(query)
|
||||
.distinct()
|
||||
.order_by(*get_order_by_field())
|
||||
.order_by(*get_items_ordering())
|
||||
)
|
||||
|
||||
data = []
|
||||
for item in roster:
|
||||
data.append({"type": "roster", "item": item})
|
||||
data = list(roster)
|
||||
|
||||
if _filter == "scale":
|
||||
catalogs = (
|
||||
Catalog.objects.get_published(request.user)
|
||||
.filter(scales__slug=search)
|
||||
.distinct()
|
||||
)
|
||||
data = list(chain(data, catalogs))
|
||||
|
||||
try: # Execute only if query_2nd is defined
|
||||
consists = (
|
||||
@@ -335,23 +426,24 @@ class GetObjectsFiltered(View):
|
||||
.filter(query_2nd)
|
||||
.distinct()
|
||||
)
|
||||
for item in consists:
|
||||
data.append({"type": "consist", "item": item})
|
||||
data = list(chain(data, consists))
|
||||
if _filter == "tag": # Books can be filtered only by tag
|
||||
books = (
|
||||
Book.objects.get_published(request.user)
|
||||
.filter(query_2nd)
|
||||
.distinct()
|
||||
)
|
||||
for item in books:
|
||||
data.append({"type": "book", "item": item})
|
||||
catalogs = (
|
||||
Catalog.objects.get_published(request.user)
|
||||
.filter(query_2nd)
|
||||
.distinct()
|
||||
)
|
||||
for item in catalogs:
|
||||
data.append({"type": "catalog", "item": item})
|
||||
magazine_issues = (
|
||||
MagazineIssue.objects.get_published(request.user)
|
||||
.filter(query_2nd)
|
||||
.distinct()
|
||||
)
|
||||
data = list(chain(data, books, catalogs, magazine_issues))
|
||||
except NameError:
|
||||
pass
|
||||
|
||||
@@ -405,24 +497,22 @@ class GetRollingStock(View):
|
||||
request.user
|
||||
)
|
||||
|
||||
consists = [
|
||||
{"type": "consist", "item": c}
|
||||
for c in Consist.objects.get_published(request.user).filter(
|
||||
consists = list(
|
||||
Consist.objects.get_published(request.user).filter(
|
||||
consist_item__rolling_stock=rolling_stock
|
||||
)
|
||||
] # A dict with "item" is required by the consists card
|
||||
)
|
||||
|
||||
set = [
|
||||
{"type": "set", "item": s}
|
||||
for s in RollingStock.objects.get_published(request.user)
|
||||
trainset = list(
|
||||
RollingStock.objects.get_published(request.user)
|
||||
.filter(
|
||||
Q(
|
||||
Q(item_number__exact=rolling_stock.item_number)
|
||||
& Q(set=True)
|
||||
)
|
||||
)
|
||||
.order_by(*get_order_by_field())
|
||||
]
|
||||
.order_by(*get_items_ordering())
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -435,7 +525,7 @@ class GetRollingStock(View):
|
||||
"decoder_documents": decoder_documents,
|
||||
"documents": documents,
|
||||
"journal": journal,
|
||||
"set": set,
|
||||
"set": trainset,
|
||||
"consists": consists,
|
||||
},
|
||||
)
|
||||
@@ -443,7 +533,6 @@ class GetRollingStock(View):
|
||||
|
||||
class Consists(GetData):
|
||||
title = "Consists"
|
||||
item_type = "consist"
|
||||
|
||||
def get_data(self, request):
|
||||
return Consist.objects.get_published(request.user).all()
|
||||
@@ -457,16 +546,19 @@ class GetConsist(View):
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
raise Http404
|
||||
data = [
|
||||
{
|
||||
"type": "roster",
|
||||
"item": RollingStock.objects.get_published(request.user).get(
|
||||
uuid=r.rolling_stock_id
|
||||
),
|
||||
}
|
||||
for r in consist.consist_item.all()
|
||||
]
|
||||
|
||||
data = list(
|
||||
RollingStock.objects.get_published(request.user).get(
|
||||
uuid=r.rolling_stock_id
|
||||
)
|
||||
for r in consist.consist_item.filter(load=False)
|
||||
)
|
||||
loads = list(
|
||||
RollingStock.objects.get_published(request.user).get(
|
||||
uuid=r.rolling_stock_id
|
||||
)
|
||||
for r in consist.consist_item.filter(load=True)
|
||||
)
|
||||
paginator = Paginator(data, get_items_per_page())
|
||||
data = paginator.get_page(page)
|
||||
page_range = paginator.get_elided_page_range(
|
||||
@@ -480,6 +572,7 @@ class GetConsist(View):
|
||||
"title": consist,
|
||||
"consist": consist,
|
||||
"data": data,
|
||||
"loads": loads,
|
||||
"page_range": page_range,
|
||||
},
|
||||
)
|
||||
@@ -487,10 +580,62 @@ class GetConsist(View):
|
||||
|
||||
class Manufacturers(GetData):
|
||||
title = "Manufacturers"
|
||||
item_type = "manufacturer"
|
||||
|
||||
def get_data(self, request):
|
||||
return Manufacturer.objects.filter(self.filter)
|
||||
return (
|
||||
Manufacturer.objects.filter(self.filter)
|
||||
.annotate(
|
||||
num_rollingstock=(
|
||||
Count(
|
||||
"rollingstock",
|
||||
filter=Q(
|
||||
rollingstock__in=(
|
||||
RollingStock.objects.get_published(
|
||||
request.user
|
||||
)
|
||||
)
|
||||
),
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
num_rollingclass=(
|
||||
Count(
|
||||
"rollingclass__rolling_stock",
|
||||
filter=Q(
|
||||
rollingclass__rolling_stock__in=(
|
||||
RollingStock.objects.get_published(
|
||||
request.user
|
||||
)
|
||||
),
|
||||
),
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
num_catalogs=(
|
||||
Count(
|
||||
"catalogs",
|
||||
filter=Q(
|
||||
catalogs__in=(
|
||||
Catalog.objects.get_published(request.user)
|
||||
),
|
||||
),
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
num_items=(
|
||||
F("num_rollingstock")
|
||||
+ F("num_rollingclass")
|
||||
+ F("num_catalogs")
|
||||
)
|
||||
)
|
||||
.order_by("name")
|
||||
)
|
||||
|
||||
# overload get method to filter by category
|
||||
def get(self, request, category, page=1):
|
||||
@@ -503,34 +648,95 @@ class Manufacturers(GetData):
|
||||
|
||||
class Companies(GetData):
|
||||
title = "Companies"
|
||||
item_type = "company"
|
||||
|
||||
def get_data(self, request):
|
||||
return Company.objects.all()
|
||||
return (
|
||||
Company.objects.annotate(
|
||||
num_rollingstock=(
|
||||
Count(
|
||||
"rollingclass__rolling_stock",
|
||||
filter=Q(
|
||||
rollingclass__rolling_stock__in=(
|
||||
RollingStock.objects.get_published(
|
||||
request.user
|
||||
)
|
||||
)
|
||||
),
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
num_consists=(
|
||||
Count(
|
||||
"consist",
|
||||
filter=Q(
|
||||
consist__in=(
|
||||
Consist.objects.get_published(request.user)
|
||||
),
|
||||
),
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(num_items=F("num_rollingstock") + F("num_consists"))
|
||||
.order_by("name")
|
||||
)
|
||||
|
||||
|
||||
class Scales(GetData):
|
||||
title = "Scales"
|
||||
item_type = "scale"
|
||||
queryset = Scale.objects.all()
|
||||
|
||||
def get_data(self, request):
|
||||
return Scale.objects.annotate(
|
||||
num_items=Count("rollingstock")
|
||||
) # .filter(num_items__gt=0) to filter data with no items
|
||||
return (
|
||||
Scale.objects.annotate(
|
||||
num_rollingstock=Count(
|
||||
"rollingstock",
|
||||
filter=Q(
|
||||
rollingstock__in=RollingStock.objects.get_published(
|
||||
request.user
|
||||
)
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
num_consists=Count(
|
||||
"consist",
|
||||
filter=Q(
|
||||
consist__in=Consist.objects.get_published(request.user)
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
num_catalogs=Count("catalogs", distinct=True),
|
||||
)
|
||||
.annotate(
|
||||
num_items=(
|
||||
F("num_rollingstock")
|
||||
+ F("num_consists")
|
||||
+ F("num_catalogs")
|
||||
)
|
||||
)
|
||||
.order_by("-ratio_int", "-tracks", "scale")
|
||||
)
|
||||
|
||||
|
||||
class Types(GetData):
|
||||
title = "Types"
|
||||
item_type = "rolling_stock_type"
|
||||
|
||||
def get_data(self, request):
|
||||
return RollingStockType.objects.all()
|
||||
return RollingStockType.objects.annotate(
|
||||
num_items=Count(
|
||||
"rollingclass__rolling_stock",
|
||||
filter=Q(
|
||||
rollingclass__rolling_stock__in=(
|
||||
RollingStock.objects.get_published(request.user)
|
||||
)
|
||||
),
|
||||
)
|
||||
).order_by("order")
|
||||
|
||||
|
||||
class Books(GetData):
|
||||
title = "Books"
|
||||
item_type = "book"
|
||||
|
||||
def get_data(self, request):
|
||||
return Book.objects.get_published(request.user).all()
|
||||
@@ -538,12 +744,82 @@ class Books(GetData):
|
||||
|
||||
class Catalogs(GetData):
|
||||
title = "Catalogs"
|
||||
item_type = "catalog"
|
||||
|
||||
def get_data(self, request):
|
||||
return Catalog.objects.get_published(request.user).all()
|
||||
|
||||
|
||||
class Magazines(GetData):
|
||||
title = "Magazines"
|
||||
|
||||
def get_data(self, request):
|
||||
return (
|
||||
Magazine.objects.get_published(request.user)
|
||||
.order_by(Lower("name"))
|
||||
.annotate(
|
||||
issues=Count(
|
||||
"issue",
|
||||
filter=Q(
|
||||
issue__in=(
|
||||
MagazineIssue.objects.get_published(request.user)
|
||||
)
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class GetMagazine(View):
|
||||
def get(self, request, uuid, page=1):
|
||||
try:
|
||||
magazine = Magazine.objects.get_published(request.user).get(
|
||||
uuid=uuid
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
raise Http404
|
||||
data = list(magazine.issue.get_published(request.user).all())
|
||||
paginator = Paginator(data, get_items_per_page())
|
||||
data = paginator.get_page(page)
|
||||
page_range = paginator.get_elided_page_range(
|
||||
data.number, on_each_side=1, on_ends=1
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"bookshelf/magazine.html",
|
||||
{
|
||||
"title": magazine,
|
||||
"magazine": magazine,
|
||||
"data": data,
|
||||
"matches": paginator.count,
|
||||
"page_range": page_range,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class GetMagazineIssue(View):
|
||||
def get(self, request, uuid, magazine, page=1):
|
||||
try:
|
||||
issue = MagazineIssue.objects.get_published(request.user).get(
|
||||
uuid=uuid,
|
||||
magazine__uuid=magazine,
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
raise Http404
|
||||
properties = issue.property.get_public(request.user)
|
||||
documents = issue.document.get_public(request.user)
|
||||
return render(
|
||||
request,
|
||||
"bookshelf/book.html",
|
||||
{
|
||||
"title": issue,
|
||||
"data": issue,
|
||||
"documents": documents,
|
||||
"properties": properties,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class GetBookCatalog(View):
|
||||
def get_object(self, request, uuid, selector):
|
||||
if selector == "book":
|
||||
@@ -566,10 +842,9 @@ class GetBookCatalog(View):
|
||||
"bookshelf/book.html",
|
||||
{
|
||||
"title": book,
|
||||
"book": book,
|
||||
"data": book,
|
||||
"documents": documents,
|
||||
"properties": properties,
|
||||
"type": selector
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user