mirror of
https://github.com/daniviga/django-ram.git
synced 2026-02-03 17:40:39 +01:00
Compare commits
17 Commits
v0.19.9
...
b9e55936e1
| Author | SHA1 | Date | |
|---|---|---|---|
|
b9e55936e1
|
|||
|
268fe8f9a7
|
|||
|
289ace4a49
|
|||
|
8c216c7e56
|
|||
|
d1e741ebfd
|
|||
|
650a93676e
|
|||
|
265aed56fe
|
|||
|
167a0593de
|
|||
|
a254786ddc
|
|||
|
8d899e4d9f
|
|||
|
40df9eb376
|
|||
|
226f0b32ba
|
|||
|
3c121a60a4
|
|||
|
ab606859d1
|
|||
|
a16801eb4b
|
|||
|
b8d10a68ca
|
|||
|
e690ded04f
|
6
.github/workflows/django.yml
vendored
6
.github/workflows/django.yml
vendored
@@ -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
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -129,7 +129,6 @@ dmypy.json
|
||||
|
||||
# node.js / npm stuff
|
||||
node_modules
|
||||
package.json
|
||||
package-lock.json
|
||||
|
||||
# our own stuff
|
||||
|
||||
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
|
||||
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,7 +2,12 @@ import html
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html, format_html_join, 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
|
||||
@@ -149,7 +154,7 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
def invoices(self, obj):
|
||||
if obj.invoice.exists():
|
||||
html = format_html_join(
|
||||
"<br>",
|
||||
mark_safe("<br>"),
|
||||
'<a href="{}" target="_blank">{}</a>',
|
||||
((i.file.url, i) for i in obj.invoice.all()),
|
||||
)
|
||||
@@ -317,7 +322,7 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
def invoices(self, obj):
|
||||
if obj.invoice.exists():
|
||||
html = format_html_join(
|
||||
"<br>",
|
||||
mark_safe("<br>"),
|
||||
'<a href="{}" target="_blank">{}</a>',
|
||||
((i.file.url, i) for i in obj.invoice.all()),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -30,7 +30,7 @@ class Property(SimpleBaseModel):
|
||||
|
||||
|
||||
class Manufacturer(SimpleBaseModel):
|
||||
name = models.CharField(max_length=128, unique=True)
|
||||
name = models.CharField(max_length=128)
|
||||
slug = models.CharField(max_length=128, unique=True, editable=False)
|
||||
category = models.CharField(
|
||||
max_length=64, choices=settings.MANUFACTURER_TYPES
|
||||
@@ -46,6 +46,12 @@ class Manufacturer(SimpleBaseModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ["category", "slug"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["name", "category"],
|
||||
name="unique_name_category"
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -35,7 +35,8 @@ class SiteConfigurationAdmin(SingletonModelAdmin):
|
||||
"fields": (
|
||||
"show_version",
|
||||
"use_cdn",
|
||||
"extra_head",
|
||||
"extra_html",
|
||||
"extra_js",
|
||||
"rest_api",
|
||||
"version",
|
||||
),
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -39,7 +39,14 @@ class SiteConfiguration(SingletonModel):
|
||||
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"
|
||||
|
||||
2
ram/portal/static/js/main.min.js
vendored
2
ram/portal/static/js/main.min.js
vendored
@@ -3,4 +3,4 @@
|
||||
* 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(){const e=window.location.hash.substring(1);if(e){const t=document.querySelector(`[data-bs-target="#nav-${e}"]`);t&&bootstrap.Tab.getOrCreateInstance(t).show()}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)})});const t=document.getElementById("tabSelector");t&&(t.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(e=>{e.addEventListener("shown.bs.tab",e=>{const a=e.target.getAttribute("data-bs-target");t.value=a.substring(1)})}))});
|
||||
(()=>{"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)})});
|
||||
@@ -1,13 +1,19 @@
|
||||
// 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 trigger = document.querySelector(`[data-bs-target="#nav-${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 => {
|
||||
@@ -17,14 +23,12 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
});
|
||||
|
||||
// allow tab selection via a dropdown on small screens
|
||||
const selectElement = document.getElementById('tabSelector');
|
||||
if (!selectElement) return;
|
||||
selectElement.addEventListener('change', function () {
|
||||
const targetSelector = this.value;
|
||||
const triggerEl = document.querySelector(`[data-bs-target="#${targetSelector}"]`);
|
||||
if (triggerEl) {
|
||||
// Use Bootstrap 5.3's API — ensures transitions + ARIA updates
|
||||
const tabInstance = bootstrap.Tab.getOrCreateInstance(triggerEl);
|
||||
const target = this.value;
|
||||
const trigger = document.querySelector(`[data-bs-target="${target}"]`);
|
||||
if (trigger) {
|
||||
const tabInstance = bootstrap.Tab.getOrCreateInstance(trigger);
|
||||
tabInstance.show();
|
||||
}
|
||||
});
|
||||
@@ -33,7 +37,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
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.substring(1); // remove the '#' character
|
||||
selectElement.value = target;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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)
|
||||
})
|
||||
});
|
||||
@@ -10,21 +10,3 @@
|
||||
<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>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<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>
|
||||
@@ -23,9 +23,10 @@
|
||||
<link href="{% static "bootstrap-icons@1.13.1/font/bootstrap-icons.css" %}" rel="stylesheet">
|
||||
{% endif %}
|
||||
<link href="{% static "css/main.min.css" %}?v={{ site_conf.version }}" rel="stylesheet">
|
||||
<script src="{% static "js/main.min.js" %}"></script>
|
||||
<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>
|
||||
|
||||
@@ -53,9 +53,9 @@
|
||||
{% 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 data.toc.all %}<option value="nav-toc">Table of contents</option>{% endif %}
|
||||
{% 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">
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<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">
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
<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 table-responsive" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
|
||||
|
||||
@@ -59,15 +59,15 @@
|
||||
{% 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 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 %}
|
||||
<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">
|
||||
|
||||
@@ -17,15 +17,13 @@ def dcc(object):
|
||||
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(
|
||||
f'<abbr title="{object.decoder}">'
|
||||
decoder += mark_safe(
|
||||
'<i class="bi bi-volume-up-fill"></i></abbr>'
|
||||
)
|
||||
else:
|
||||
decoder = mark_safe(
|
||||
f'<abbr title="{object.decoder}'
|
||||
f'({object.get_decoder_interface()})">'
|
||||
decoder += mark_safe(
|
||||
'<i class="bi bi-cpu-fill"></i></abbr>'
|
||||
)
|
||||
if decoder:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from portal.views import (
|
||||
RenderExtraJS,
|
||||
GetHome,
|
||||
GetRoster,
|
||||
GetObjectsFiltered,
|
||||
@@ -24,6 +25,7 @@ from portal.views import (
|
||||
|
||||
urlpatterns = [
|
||||
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="roster"),
|
||||
path(
|
||||
|
||||
@@ -7,7 +7,7 @@ from urllib.parse import unquote
|
||||
from django.conf import settings
|
||||
from django.views import View
|
||||
from django.urls import Resolver404
|
||||
from django.http import Http404, HttpResponseBadRequest
|
||||
from django.http import Http404, HttpResponse, HttpResponseBadRequest
|
||||
from django.db.utils import OperationalError, ProgrammingError
|
||||
from django.db.models import F, Q, Count
|
||||
from django.db.models.functions import Lower
|
||||
@@ -78,6 +78,16 @@ class Render404(View):
|
||||
)
|
||||
|
||||
|
||||
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 = None
|
||||
template = "pagination.html"
|
||||
@@ -267,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()
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
from django import VERSION as DJANGO_VERSION
|
||||
from django.utils.termcolors import colorize
|
||||
from ram.utils import git_suffix
|
||||
|
||||
__version__ = "0.19.9"
|
||||
if DJANGO_VERSION < (6, 0):
|
||||
exit(
|
||||
colorize(
|
||||
"ERROR: This project requires Django 6.0 or higher.", fg="red"
|
||||
)
|
||||
)
|
||||
|
||||
__version__ = "0.19.10"
|
||||
__version__ += git_suffix(__file__)
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
"""
|
||||
Django settings for ram project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 4.0.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.0/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/4.0/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from django.utils.csp import CSP
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
@@ -22,18 +13,13 @@ STORAGE_DIR = BASE_DIR / "storage"
|
||||
# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = (
|
||||
"django-insecure-1fgtf05rwp0qp05@ef@a7%x#o+t6vk6063py=vhdmut0j!8s4u"
|
||||
)
|
||||
SECRET_KEY = "django-ram-insecure-Chang3m3-1n-Pr0duct10n!"
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
@@ -61,6 +47,7 @@ MIDDLEWARE = [
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.middleware.csp.ContentSecurityPolicyMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
@@ -87,10 +74,6 @@ TEMPLATES = [
|
||||
|
||||
WSGI_APPLICATION = "ram.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
@@ -98,54 +81,59 @@ DATABASES = {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", # noqa: E501
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", # noqa: E501
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", # noqa: E501
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", # noqa: E501
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/4.0/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = "UTC"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/4.0/howto/static-files/
|
||||
|
||||
STATIC_URL = "static/"
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
MEDIA_URL = "media/"
|
||||
MEDIA_ROOT = STORAGE_DIR / "media"
|
||||
|
||||
# Enforce a CSP policy:
|
||||
CDN_WHITELIST_CSP = ["https://cdn.jsdelivr.net/"]
|
||||
SECURE_CSP = {
|
||||
"default-src": [CSP.SELF] + CDN_WHITELIST_CSP,
|
||||
"img-src": ["data:", "*"],
|
||||
"script-src": [
|
||||
CSP.SELF,
|
||||
"https://www.googletagmanager.com/",
|
||||
]
|
||||
+ CDN_WHITELIST_CSP,
|
||||
"style-src": [CSP.SELF, CSP.UNSAFE_INLINE] + CDN_WHITELIST_CSP,
|
||||
}
|
||||
|
||||
# Cookies hardening
|
||||
SESSION_COOKIE_NAME = "__Secure-sessionid"
|
||||
SESSION_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
CSRF_COOKIE_NAME = "__Secure-csrftoken"
|
||||
CSRF_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_HTTPONLY = True
|
||||
|
||||
# django-ram REST API settings
|
||||
REST_ENABLED = False # Set to True to enable the REST API
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
|
||||
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", # noqa: E501
|
||||
"PAGE_SIZE": 5,
|
||||
}
|
||||
|
||||
@@ -171,7 +159,7 @@ COUNTRIES_OVERRIDE = {
|
||||
"XX": "None",
|
||||
}
|
||||
|
||||
SITE_NAME = "Railroad Assets Manger"
|
||||
SITE_NAME = "Railroad Assets Manager"
|
||||
|
||||
# Image used on cards without a custom image uploaded.
|
||||
# The file must be placed in the root of the 'static' folder
|
||||
@@ -184,16 +172,17 @@ DECODER_INTERFACES = [
|
||||
(0, "Built-in"),
|
||||
(1, "NEM651"),
|
||||
(2, "NEM652"),
|
||||
(3, "PluX"),
|
||||
(4, "21MTC"),
|
||||
(5, "Next18/Next18S"),
|
||||
(3, "NEM658 (Plux16)"),
|
||||
(6, "NEM658 (Plux22)"),
|
||||
(4, "NEM660 (21MTC)"),
|
||||
(5, "NEM662 (Next18/Next18S)"),
|
||||
]
|
||||
|
||||
MANUFACTURER_TYPES = [
|
||||
("model", "Model"),
|
||||
("real", "Real"),
|
||||
("accessory", "Accessory"),
|
||||
("other", "Other")
|
||||
("other", "Other"),
|
||||
]
|
||||
|
||||
ROLLING_STOCK_TYPES = [
|
||||
|
||||
139
ram/ram/tests.py
Normal file
139
ram/ram/tests.py
Normal file
@@ -0,0 +1,139 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from ram.utils import slugify
|
||||
|
||||
|
||||
class SimpleBaseModelTestCase(TestCase):
|
||||
"""Test cases for SimpleBaseModel."""
|
||||
|
||||
def test_obj_type_property(self):
|
||||
"""Test obj_type returns model name."""
|
||||
# We can't instantiate abstract models directly,
|
||||
# so we test via a concrete model that inherits from it
|
||||
from metadata.models import Company
|
||||
|
||||
company = Company.objects.create(name="Test", country="US")
|
||||
self.assertEqual(company.obj_type, "company")
|
||||
|
||||
def test_obj_label_property(self):
|
||||
"""Test obj_label returns object name."""
|
||||
from metadata.models import Company
|
||||
|
||||
company = Company.objects.create(name="Test", country="US")
|
||||
self.assertEqual(company.obj_label, "Company")
|
||||
|
||||
|
||||
class BaseModelTestCase(TestCase):
|
||||
"""Test cases for BaseModel."""
|
||||
|
||||
def test_base_model_fields(self):
|
||||
"""Test that BaseModel includes expected fields."""
|
||||
# Test via a concrete model
|
||||
from roster.models import RollingStock, RollingClass
|
||||
from metadata.models import Company, Scale, RollingStockType
|
||||
|
||||
company = Company.objects.create(name="Test", country="US")
|
||||
scale = Scale.objects.create(scale="HO", ratio="1:87", tracks=16.5)
|
||||
stock_type = RollingStockType.objects.create(
|
||||
type="Test", category="locomotive", order=1
|
||||
)
|
||||
rolling_class = RollingClass.objects.create(
|
||||
identifier="Test",
|
||||
type=stock_type,
|
||||
company=company,
|
||||
)
|
||||
|
||||
stock = RollingStock.objects.create(
|
||||
rolling_class=rolling_class,
|
||||
road_number="123",
|
||||
scale=scale,
|
||||
description="<p>Test description</p>",
|
||||
notes="Test notes",
|
||||
)
|
||||
|
||||
# Check BaseModel fields exist
|
||||
self.assertIsNotNone(stock.uuid)
|
||||
self.assertTrue(stock.published)
|
||||
self.assertIsNotNone(stock.creation_time)
|
||||
self.assertIsNotNone(stock.updated_time)
|
||||
self.assertEqual(stock.description, "<p>Test description</p>")
|
||||
|
||||
def test_base_model_obj_properties(self):
|
||||
"""Test obj_type and obj_label properties."""
|
||||
from roster.models import RollingStock, RollingClass
|
||||
from metadata.models import Company, Scale, RollingStockType
|
||||
|
||||
company = Company.objects.create(name="Test", country="US")
|
||||
scale = Scale.objects.create(scale="HO", ratio="1:87", tracks=16.5)
|
||||
stock_type = RollingStockType.objects.create(
|
||||
type="Test", category="locomotive", order=1
|
||||
)
|
||||
rolling_class = RollingClass.objects.create(
|
||||
identifier="Test",
|
||||
type=stock_type,
|
||||
company=company,
|
||||
)
|
||||
|
||||
stock = RollingStock.objects.create(
|
||||
rolling_class=rolling_class,
|
||||
road_number="123",
|
||||
scale=scale,
|
||||
)
|
||||
|
||||
self.assertEqual(stock.obj_type, "rollingstock")
|
||||
self.assertEqual(stock.obj_label, "RollingStock")
|
||||
|
||||
def test_base_model_published_default(self):
|
||||
"""Test that published defaults to True."""
|
||||
from roster.models import RollingStock, RollingClass
|
||||
from metadata.models import Company, Scale, RollingStockType
|
||||
|
||||
company = Company.objects.create(name="Test", country="US")
|
||||
scale = Scale.objects.create(scale="HO", ratio="1:87", tracks=16.5)
|
||||
stock_type = RollingStockType.objects.create(
|
||||
type="Test", category="locomotive", order=1
|
||||
)
|
||||
rolling_class = RollingClass.objects.create(
|
||||
identifier="Test",
|
||||
type=stock_type,
|
||||
company=company,
|
||||
)
|
||||
|
||||
stock = RollingStock.objects.create(
|
||||
rolling_class=rolling_class,
|
||||
road_number="123",
|
||||
scale=scale,
|
||||
)
|
||||
|
||||
self.assertTrue(stock.published)
|
||||
|
||||
|
||||
class SlugifyTestCase(TestCase):
|
||||
"""Test cases for slugify utility function."""
|
||||
|
||||
def test_slugify_basic(self):
|
||||
"""Test basic slugification."""
|
||||
self.assertEqual(slugify("Hello World"), "hello-world")
|
||||
|
||||
def test_slugify_special_characters(self):
|
||||
"""Test slugification with special characters."""
|
||||
self.assertEqual(slugify("Hello & World!"), "hello-world")
|
||||
|
||||
def test_slugify_multiple_spaces(self):
|
||||
"""Test slugification with multiple spaces."""
|
||||
self.assertEqual(slugify("Hello World"), "hello-world")
|
||||
|
||||
def test_slugify_numbers(self):
|
||||
"""Test slugification with numbers."""
|
||||
self.assertEqual(slugify("Test 123 ABC"), "test-123-abc")
|
||||
|
||||
def test_slugify_underscores(self):
|
||||
"""Test slugification preserves underscores."""
|
||||
result = slugify("test_value")
|
||||
# Depending on implementation, may keep or convert underscores
|
||||
self.assertIn(result, ["test-value", "test_value"])
|
||||
|
||||
def test_slugify_empty_string(self):
|
||||
"""Test slugification of empty string."""
|
||||
result = slugify("")
|
||||
self.assertEqual(result, "")
|
||||
@@ -85,12 +85,20 @@ class DownloadFile(View):
|
||||
# Find all models inheriting from PublishableFile
|
||||
for model in apps.get_models():
|
||||
if issubclass(model, PrivateDocument) and not model._meta.abstract:
|
||||
try:
|
||||
doc = model.objects.get(file__endswith=filename)
|
||||
if doc.private and not request.user.is_staff:
|
||||
# Due to deduplication, multiple documents may have
|
||||
# the same file name; if any is private, use a failsafe
|
||||
# approach enforce access control
|
||||
docs = model.objects.filter(file__endswith=filename)
|
||||
if not docs.exists():
|
||||
continue
|
||||
|
||||
if (
|
||||
any(doc.private for doc in docs)
|
||||
and not request.user.is_staff
|
||||
):
|
||||
break
|
||||
|
||||
file = doc.file
|
||||
file = docs.first().file
|
||||
if not os.path.exists(file.path):
|
||||
break
|
||||
|
||||
@@ -110,14 +118,9 @@ class DownloadFile(View):
|
||||
open(file.path, "rb"), as_attachment=True
|
||||
)
|
||||
|
||||
response["Content-Disposition"] = (
|
||||
'{}; filename="{}"'.format(
|
||||
disposition,
|
||||
smart_str(os.path.basename(file.path))
|
||||
)
|
||||
response["Content-Disposition"] = '{}; filename="{}"'.format(
|
||||
disposition, smart_str(os.path.basename(file.path))
|
||||
)
|
||||
return response
|
||||
except model.DoesNotExist:
|
||||
continue
|
||||
|
||||
raise Http404("File not found")
|
||||
|
||||
@@ -2,8 +2,12 @@ import html
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html, format_html_join, strip_tags
|
||||
|
||||
from django.utils.html import (
|
||||
format_html,
|
||||
format_html_join,
|
||||
strip_tags,
|
||||
mark_safe,
|
||||
)
|
||||
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
|
||||
|
||||
from ram.utils import generate_csv
|
||||
@@ -31,7 +35,7 @@ class RollingClassPropertyInline(admin.TabularInline):
|
||||
class RollingClass(admin.ModelAdmin):
|
||||
inlines = (RollingClassPropertyInline,)
|
||||
autocomplete_fields = ("manufacturer",)
|
||||
list_display = ("__str__", "type", "company", "country_flag")
|
||||
list_display = ("__str__", "identifier", "type", "company", "country_flag")
|
||||
list_filter = ("company", "type__category", "type")
|
||||
search_fields = (
|
||||
"identifier",
|
||||
@@ -229,7 +233,7 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
def invoices(self, obj):
|
||||
if obj.invoice.exists():
|
||||
html = format_html_join(
|
||||
"<br>",
|
||||
mark_safe("<br>"),
|
||||
'<a href="{}" target="_blank">{}</a>',
|
||||
((i.file.url, i) for i in obj.invoice.all()),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 6.0 on 2026-01-08 12:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("roster", "0039_rollingstock_featured"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="rollingstock",
|
||||
name="decoder_interface",
|
||||
field=models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
choices=[
|
||||
(0, "Built-in"),
|
||||
(1, "NEM651"),
|
||||
(2, "NEM652"),
|
||||
(3, "NEM658 (Plux16)"),
|
||||
(6, "NEM658 (Plux22)"),
|
||||
(4, "NEM660 (21MTC)"),
|
||||
(5, "NEM662 (Next18/Next18S)"),
|
||||
],
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,3 +1,340 @@
|
||||
from decimal import Decimal
|
||||
from django.test import TestCase
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.conf import settings
|
||||
|
||||
# Create your tests here.
|
||||
from roster.models import RollingClass, RollingStock, RollingStockImage
|
||||
from metadata.models import (
|
||||
Company,
|
||||
Manufacturer,
|
||||
Scale,
|
||||
RollingStockType,
|
||||
Decoder,
|
||||
)
|
||||
|
||||
|
||||
class RollingClassTestCase(TestCase):
|
||||
"""Test cases for RollingClass model."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
# Create a company
|
||||
self.company = Company.objects.create(
|
||||
name="Rio Grande Southern",
|
||||
country="US",
|
||||
)
|
||||
|
||||
# Create a rolling stock type
|
||||
self.stock_type = RollingStockType.objects.create(
|
||||
type="Steam Locomotive",
|
||||
category="locomotive",
|
||||
order=1,
|
||||
)
|
||||
|
||||
# Create a real manufacturer
|
||||
self.real_manufacturer = Manufacturer.objects.create(
|
||||
name="Baldwin Locomotive Works",
|
||||
category="real",
|
||||
country="US",
|
||||
)
|
||||
|
||||
def test_rolling_class_creation(self):
|
||||
"""Test creating a rolling class."""
|
||||
rolling_class = RollingClass.objects.create(
|
||||
identifier="C-19",
|
||||
type=self.stock_type,
|
||||
company=self.company,
|
||||
description="<p>Narrow gauge steam locomotive</p>",
|
||||
)
|
||||
|
||||
self.assertEqual(str(rolling_class), "Rio Grande Southern C-19")
|
||||
self.assertEqual(rolling_class.identifier, "C-19")
|
||||
self.assertEqual(rolling_class.type, self.stock_type)
|
||||
self.assertEqual(rolling_class.company, self.company)
|
||||
|
||||
def test_rolling_class_country_property(self):
|
||||
"""Test that rolling class inherits country from company."""
|
||||
rolling_class = RollingClass.objects.create(
|
||||
identifier="C-19",
|
||||
type=self.stock_type,
|
||||
company=self.company,
|
||||
)
|
||||
|
||||
self.assertEqual(rolling_class.country, self.company.country)
|
||||
|
||||
def test_rolling_class_ordering(self):
|
||||
"""Test rolling class ordering by company and identifier."""
|
||||
company2 = Company.objects.create(name="D&RGW", country="US")
|
||||
|
||||
rc1 = RollingClass.objects.create(
|
||||
identifier="K-27", type=self.stock_type, company=company2
|
||||
)
|
||||
rc2 = RollingClass.objects.create(
|
||||
identifier="C-19", type=self.stock_type, company=self.company
|
||||
)
|
||||
rc3 = RollingClass.objects.create(
|
||||
identifier="K-28", type=self.stock_type, company=company2
|
||||
)
|
||||
|
||||
classes = list(RollingClass.objects.all())
|
||||
self.assertEqual(classes[0], rc1) # D&RGW K-27
|
||||
self.assertEqual(classes[1], rc3) # D&RGW K-28
|
||||
self.assertEqual(classes[2], rc2) # Rio Grande Southern comes last
|
||||
|
||||
def test_rolling_class_manufacturer_relationship(self):
|
||||
"""Test many-to-many relationship with manufacturers."""
|
||||
rolling_class = RollingClass.objects.create(
|
||||
identifier="C-19",
|
||||
type=self.stock_type,
|
||||
company=self.company,
|
||||
)
|
||||
|
||||
rolling_class.manufacturer.add(self.real_manufacturer)
|
||||
|
||||
self.assertEqual(rolling_class.manufacturer.count(), 1)
|
||||
self.assertIn(self.real_manufacturer, rolling_class.manufacturer.all())
|
||||
|
||||
|
||||
class RollingStockTestCase(TestCase):
|
||||
"""Test cases for RollingStock model."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
# Create necessary related objects
|
||||
self.company = Company.objects.create(name="RGS", country="US")
|
||||
|
||||
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.scale = Scale.objects.create(
|
||||
scale="HOn3",
|
||||
ratio="1:87",
|
||||
tracks=10.5,
|
||||
gauge="3 ft",
|
||||
)
|
||||
|
||||
self.model_manufacturer = Manufacturer.objects.create(
|
||||
name="Blackstone Models",
|
||||
category="model",
|
||||
country="US",
|
||||
)
|
||||
|
||||
def test_rolling_stock_creation(self):
|
||||
"""Test creating rolling stock."""
|
||||
stock = RollingStock.objects.create(
|
||||
rolling_class=self.rolling_class,
|
||||
road_number="340",
|
||||
scale=self.scale,
|
||||
manufacturer=self.model_manufacturer,
|
||||
)
|
||||
|
||||
self.assertEqual(str(stock), "RGS C-19 340")
|
||||
self.assertEqual(stock.road_number, "340")
|
||||
self.assertEqual(stock.road_number_int, 340)
|
||||
self.assertTrue(stock.published)
|
||||
|
||||
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_road_number_no_integer(self):
|
||||
"""Test road number with no integer defaults to 0."""
|
||||
stock = RollingStock.objects.create(
|
||||
rolling_class=self.rolling_class,
|
||||
road_number="N/A",
|
||||
scale=self.scale,
|
||||
)
|
||||
|
||||
self.assertEqual(stock.road_number_int, 0)
|
||||
|
||||
def test_item_number_slug_generation(self):
|
||||
"""Test automatic slug generation from item number."""
|
||||
stock = RollingStock.objects.create(
|
||||
rolling_class=self.rolling_class,
|
||||
road_number="340",
|
||||
scale=self.scale,
|
||||
item_number="BLI-123 ABC",
|
||||
)
|
||||
|
||||
self.assertEqual(stock.item_number_slug, "bli-123-abc")
|
||||
|
||||
def test_featured_limit_validation(self):
|
||||
"""Test that featured items are limited by FEATURED_ITEMS_MAX."""
|
||||
# Create FEATURED_ITEMS_MAX featured items
|
||||
for i in range(settings.FEATURED_ITEMS_MAX):
|
||||
RollingStock.objects.create(
|
||||
rolling_class=self.rolling_class,
|
||||
road_number=str(i),
|
||||
scale=self.scale,
|
||||
featured=True,
|
||||
)
|
||||
|
||||
# Try to create one more featured item
|
||||
extra_stock = RollingStock(
|
||||
rolling_class=self.rolling_class,
|
||||
road_number="999",
|
||||
scale=self.scale,
|
||||
featured=True,
|
||||
)
|
||||
|
||||
with self.assertRaises(ValidationError) as cm:
|
||||
extra_stock.clean()
|
||||
|
||||
self.assertIn("featured items", str(cm.exception))
|
||||
|
||||
def test_price_decimal_field(self):
|
||||
"""Test price field accepts decimal values."""
|
||||
stock = RollingStock.objects.create(
|
||||
rolling_class=self.rolling_class,
|
||||
road_number="340",
|
||||
scale=self.scale,
|
||||
price=Decimal("249.99"),
|
||||
)
|
||||
|
||||
self.assertEqual(stock.price, Decimal("249.99"))
|
||||
|
||||
def test_decoder_interface_display(self):
|
||||
"""Test decoder interface display method."""
|
||||
stock = RollingStock.objects.create(
|
||||
rolling_class=self.rolling_class,
|
||||
road_number="340",
|
||||
scale=self.scale,
|
||||
decoder_interface=1, # 21-pin interface
|
||||
)
|
||||
|
||||
interface = stock.get_decoder_interface()
|
||||
self.assertIsNotNone(interface)
|
||||
self.assertNotEqual(interface, "No interface")
|
||||
|
||||
def test_decoder_interface_none(self):
|
||||
"""Test decoder interface when not set."""
|
||||
stock = RollingStock.objects.create(
|
||||
rolling_class=self.rolling_class,
|
||||
road_number="340",
|
||||
scale=self.scale,
|
||||
)
|
||||
|
||||
interface = stock.get_decoder_interface()
|
||||
self.assertEqual(interface, "No interface")
|
||||
|
||||
def test_country_and_company_properties(self):
|
||||
"""Test that country and company properties work."""
|
||||
stock = RollingStock.objects.create(
|
||||
rolling_class=self.rolling_class,
|
||||
road_number="340",
|
||||
scale=self.scale,
|
||||
)
|
||||
|
||||
self.assertEqual(stock.country, self.company.country)
|
||||
self.assertEqual(stock.company, self.company)
|
||||
|
||||
def test_get_absolute_url(self):
|
||||
"""Test get_absolute_url returns correct URL."""
|
||||
stock = RollingStock.objects.create(
|
||||
rolling_class=self.rolling_class,
|
||||
road_number="340",
|
||||
scale=self.scale,
|
||||
)
|
||||
|
||||
url = stock.get_absolute_url()
|
||||
self.assertIn(str(stock.uuid), url)
|
||||
|
||||
def test_published_filtering(self):
|
||||
"""Test PublicManager filters unpublished items."""
|
||||
published_stock = RollingStock.objects.create(
|
||||
rolling_class=self.rolling_class,
|
||||
road_number="340",
|
||||
scale=self.scale,
|
||||
published=True,
|
||||
)
|
||||
|
||||
unpublished_stock = RollingStock.objects.create(
|
||||
rolling_class=self.rolling_class,
|
||||
road_number="341",
|
||||
scale=self.scale,
|
||||
published=False,
|
||||
)
|
||||
|
||||
# PublicManager should only return published items
|
||||
all_stock = RollingStock.objects.all()
|
||||
|
||||
# Note: This test assumes PublicManager is properly configured
|
||||
# to filter by published=True
|
||||
self.assertIn(published_stock, all_stock)
|
||||
|
||||
def test_ordering(self):
|
||||
"""Test rolling stock ordering."""
|
||||
stock1 = RollingStock.objects.create(
|
||||
rolling_class=self.rolling_class,
|
||||
road_number="340",
|
||||
scale=self.scale,
|
||||
)
|
||||
|
||||
stock2 = RollingStock.objects.create(
|
||||
rolling_class=self.rolling_class,
|
||||
road_number="342",
|
||||
scale=self.scale,
|
||||
)
|
||||
|
||||
stock3 = RollingStock.objects.create(
|
||||
rolling_class=self.rolling_class,
|
||||
road_number="341",
|
||||
scale=self.scale,
|
||||
)
|
||||
|
||||
stocks = list(RollingStock.objects.all())
|
||||
self.assertEqual(stocks[0], stock1) # 340
|
||||
self.assertEqual(stocks[1], stock3) # 341
|
||||
self.assertEqual(stocks[2], stock2) # 342
|
||||
|
||||
|
||||
class RollingStockImageTestCase(TestCase):
|
||||
"""Test cases for RollingStockImage model."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
self.company = Company.objects.create(name="RGS", country="US")
|
||||
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.scale = Scale.objects.create(
|
||||
scale="HOn3",
|
||||
ratio="1:87",
|
||||
tracks=10.5,
|
||||
)
|
||||
self.stock = RollingStock.objects.create(
|
||||
rolling_class=self.rolling_class,
|
||||
road_number="340",
|
||||
scale=self.scale,
|
||||
)
|
||||
|
||||
def test_image_ordering(self):
|
||||
"""Test that images are ordered by the order field."""
|
||||
# Note: Actual image upload testing would require test files
|
||||
# This test validates the relationship exists
|
||||
self.assertEqual(self.stock.image.count(), 0)
|
||||
|
||||
# The image model should have an order field
|
||||
self.assertTrue(hasattr(RollingStockImage, "order"))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
pytz
|
||||
pillow
|
||||
markdown
|
||||
Django
|
||||
Django>=6.0
|
||||
djangorestframework
|
||||
django-solo
|
||||
django-countries
|
||||
|
||||
Reference in New Issue
Block a user