mirror of
https://github.com/daniviga/django-ram.git
synced 2026-02-03 17:40:39 +01:00
Improve performance oprimizing queries (#56)
* Extend test coverage * Implement query optimization * More aggressing code reuse * Add more indexes and optimize usage * Fix tests * Further optimizations, improve counting to rely on backend DB * chore: add Makefile for frontend asset minification - Add comprehensive Makefile with targets for JS and CSS minification - Implements instructions from ram/portal/static/js/src/README.md - Provides targets: install, minify, minify-js, minify-css, clean, watch - Fix main.min.js to only include theme_selector.js and tabs_selector.js - Remove validators.js from minified output per README instructions * Add a Makefile to compile JS and CSS * docs: add blank line whitespace rule to AGENTS.md Specify that blank lines must not contain any whitespace (spaces or tabs) to maintain code cleanliness and PEP 8 compliance * Update for 0.20 release with optimizations * Improve Makefile
This commit is contained in:
@@ -98,6 +98,11 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
search_fields = ("title", "publisher__name", "authors__last_name")
|
||||
list_filter = ("publisher__name", "authors", "published")
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queryset with select_related and prefetch_related."""
|
||||
qs = super().get_queryset(request)
|
||||
return qs.with_related()
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
@@ -189,6 +194,12 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
]
|
||||
|
||||
data = []
|
||||
|
||||
# Prefetch related data to avoid N+1 queries
|
||||
queryset = queryset.select_related(
|
||||
'publisher', 'shop'
|
||||
).prefetch_related('authors', 'tags', 'property__property')
|
||||
|
||||
for obj in queryset:
|
||||
properties = settings.CSV_SEPARATOR_ALT.join(
|
||||
"{}:{}".format(property.property.name, property.value)
|
||||
@@ -266,6 +277,11 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
"scales__scale",
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queryset with select_related and prefetch_related."""
|
||||
qs = super().get_queryset(request)
|
||||
return qs.with_related()
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
@@ -350,6 +366,12 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
]
|
||||
|
||||
data = []
|
||||
|
||||
# Prefetch related data to avoid N+1 queries
|
||||
queryset = queryset.select_related(
|
||||
'manufacturer', 'shop'
|
||||
).prefetch_related('scales', 'tags', 'property__property')
|
||||
|
||||
for obj in queryset:
|
||||
properties = settings.CSV_SEPARATOR_ALT.join(
|
||||
"{}:{}".format(property.property.name, property.value)
|
||||
@@ -490,6 +512,11 @@ class MagazineAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
"publisher__name",
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queryset with select_related and prefetch_related."""
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related('publisher').prefetch_related('tags')
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 6.0.1 on 2026-01-18 13:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("bookshelf", "0031_alter_tocentry_authors_alter_tocentry_subtitle_and_more"),
|
||||
(
|
||||
"metadata",
|
||||
"0027_company_company_slug_idx_company_company_country_idx_and_more",
|
||||
),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="book",
|
||||
index=models.Index(fields=["title"], name="book_title_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="catalog",
|
||||
index=models.Index(fields=["manufacturer"], name="catalog_mfr_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="magazine",
|
||||
index=models.Index(fields=["published"], name="magazine_published_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="magazine",
|
||||
index=models.Index(fields=["name"], name="magazine_name_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="magazineissue",
|
||||
index=models.Index(fields=["magazine"], name="mag_issue_mag_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="magazineissue",
|
||||
index=models.Index(
|
||||
fields=["publication_month"], name="mag_issue_pub_month_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -11,6 +11,7 @@ from django_countries.fields import CountryField
|
||||
|
||||
from ram.utils import DeduplicatedStorage
|
||||
from ram.models import BaseModel, Image, PropertyInstance
|
||||
from ram.managers import BookManager, CatalogManager, MagazineIssueManager
|
||||
from metadata.models import Scale, Manufacturer, Shop, Tag
|
||||
|
||||
|
||||
@@ -105,8 +106,16 @@ class Book(BaseBook):
|
||||
authors = models.ManyToManyField(Author, blank=True)
|
||||
publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE)
|
||||
|
||||
objects = BookManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ["title"]
|
||||
indexes = [
|
||||
# Index for title searches (local field)
|
||||
models.Index(fields=["title"], name="book_title_idx"),
|
||||
# Note: published and publication_year are inherited from BaseBook/BaseModel
|
||||
# and cannot be indexed here due to multi-table inheritance
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
@@ -134,8 +143,18 @@ class Catalog(BaseBook):
|
||||
years = models.CharField(max_length=12)
|
||||
scales = models.ManyToManyField(Scale, related_name="catalogs")
|
||||
|
||||
objects = CatalogManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ["manufacturer", "publication_year"]
|
||||
indexes = [
|
||||
# Index for manufacturer filtering (local field)
|
||||
models.Index(
|
||||
fields=["manufacturer"], name="catalog_mfr_idx"
|
||||
),
|
||||
# Note: published and publication_year are inherited from BaseBook/BaseModel
|
||||
# and cannot be indexed here due to multi-table inheritance
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
# if the object is new, return an empty string to avoid
|
||||
@@ -184,6 +203,12 @@ class Magazine(BaseModel):
|
||||
|
||||
class Meta:
|
||||
ordering = [Lower("name")]
|
||||
indexes = [
|
||||
# Index for published filtering
|
||||
models.Index(fields=["published"], name="magazine_published_idx"),
|
||||
# Index for name searches (case-insensitive via db_collation if needed)
|
||||
models.Index(fields=["name"], name="magazine_name_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -214,6 +239,8 @@ class MagazineIssue(BaseBook):
|
||||
null=True, blank=True, choices=MONTHS.items()
|
||||
)
|
||||
|
||||
objects = MagazineIssueManager()
|
||||
|
||||
class Meta:
|
||||
unique_together = ("magazine", "issue_number")
|
||||
ordering = [
|
||||
@@ -222,6 +249,17 @@ class MagazineIssue(BaseBook):
|
||||
"publication_month",
|
||||
"issue_number",
|
||||
]
|
||||
indexes = [
|
||||
# Index for magazine filtering (local field)
|
||||
models.Index(fields=["magazine"], name="mag_issue_mag_idx"),
|
||||
# Index for publication month (local field)
|
||||
models.Index(
|
||||
fields=["publication_month"],
|
||||
name="mag_issue_pub_month_idx",
|
||||
),
|
||||
# Note: published and publication_year are inherited from BaseBook/BaseModel
|
||||
# and cannot be indexed here due to multi-table inheritance
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.magazine.name} - {self.issue_number}"
|
||||
|
||||
@@ -59,6 +59,11 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
search_fields = ("identifier",) + list_filter
|
||||
save_as = True
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queryset with select_related and prefetch_related."""
|
||||
qs = super().get_queryset(request)
|
||||
return qs.with_related()
|
||||
|
||||
@admin.display(description="Country")
|
||||
def country_flag(self, obj):
|
||||
return format_html(
|
||||
@@ -117,12 +122,27 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
"Item ID",
|
||||
]
|
||||
data = []
|
||||
|
||||
# Prefetch related data to avoid N+1 queries
|
||||
queryset = queryset.select_related(
|
||||
'company', 'scale'
|
||||
).prefetch_related(
|
||||
'tags',
|
||||
'consist_item__rolling_stock__rolling_class__type'
|
||||
)
|
||||
|
||||
for obj in queryset:
|
||||
# Cache the type count to avoid recalculating for each item
|
||||
types = " + ".join(
|
||||
"{}x {}".format(t["count"], t["type"])
|
||||
for t in obj.get_type_count()
|
||||
)
|
||||
# Cache tags to avoid repeated queries
|
||||
tags_str = settings.CSV_SEPARATOR_ALT.join(
|
||||
t.name for t in obj.tags.all()
|
||||
)
|
||||
|
||||
for item in obj.consist_item.all():
|
||||
types = " + ".join(
|
||||
"{}x {}".format(t["count"], t["type"])
|
||||
for t in obj.get_type_count()
|
||||
)
|
||||
data.append(
|
||||
[
|
||||
obj.uuid,
|
||||
@@ -134,9 +154,7 @@ class ConsistAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
obj.scale.scale,
|
||||
obj.era,
|
||||
html.unescape(strip_tags(obj.description)),
|
||||
settings.CSV_SEPARATOR_ALT.join(
|
||||
t.name for t in obj.tags.all()
|
||||
),
|
||||
tags_str,
|
||||
obj.length,
|
||||
types,
|
||||
item.rolling_stock.__str__(),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 6.0.1 on 2026-01-18 13:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("consist", "0019_consistitem_load"),
|
||||
(
|
||||
"metadata",
|
||||
"0027_company_company_slug_idx_company_company_country_idx_and_more",
|
||||
),
|
||||
("roster", "0041_rollingclass_roster_rc_company_idx_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="consist",
|
||||
index=models.Index(fields=["published"], name="consist_published_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="consist",
|
||||
index=models.Index(fields=["scale"], name="consist_scale_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="consist",
|
||||
index=models.Index(fields=["company"], name="consist_company_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="consist",
|
||||
index=models.Index(
|
||||
fields=["published", "scale"], name="consist_pub_scale_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="consistitem",
|
||||
index=models.Index(fields=["load"], name="consist_item_load_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="consistitem",
|
||||
index=models.Index(fields=["order"], name="consist_item_order_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="consistitem",
|
||||
index=models.Index(
|
||||
fields=["consist", "load"], name="consist_item_con_load_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -8,6 +8,7 @@ from django.core.exceptions import ValidationError
|
||||
|
||||
from ram.models import BaseModel
|
||||
from ram.utils import DeduplicatedStorage
|
||||
from ram.managers import ConsistManager
|
||||
from metadata.models import Company, Scale, Tag
|
||||
from roster.models import RollingStock
|
||||
|
||||
@@ -35,6 +36,8 @@ class Consist(BaseModel):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
objects = ConsistManager()
|
||||
|
||||
def __str__(self):
|
||||
return "{0} {1}".format(self.company, self.identifier)
|
||||
|
||||
@@ -45,6 +48,11 @@ class Consist(BaseModel):
|
||||
def length(self):
|
||||
return self.consist_item.filter(load=False).count()
|
||||
|
||||
@property
|
||||
def loads_count(self):
|
||||
"""Count of loads in this consist using database aggregation."""
|
||||
return self.consist_item.filter(load=True).count()
|
||||
|
||||
def get_type_count(self):
|
||||
return self.consist_item.filter(load=False).annotate(
|
||||
type=models.F("rolling_stock__rolling_class__type__type")
|
||||
@@ -71,6 +79,18 @@ class Consist(BaseModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ["company", "-creation_time"]
|
||||
indexes = [
|
||||
# Index for published filtering
|
||||
models.Index(fields=["published"], name="consist_published_idx"),
|
||||
# Index for scale filtering
|
||||
models.Index(fields=["scale"], name="consist_scale_idx"),
|
||||
# Index for company filtering
|
||||
models.Index(fields=["company"], name="consist_company_idx"),
|
||||
# Composite index for published+scale filtering
|
||||
models.Index(
|
||||
fields=["published", "scale"], name="consist_pub_scale_idx"
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class ConsistItem(models.Model):
|
||||
@@ -86,9 +106,19 @@ class ConsistItem(models.Model):
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["consist", "rolling_stock"],
|
||||
name="one_stock_per_consist"
|
||||
name="one_stock_per_consist",
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
# Index for filtering by load status
|
||||
models.Index(fields=["load"], name="consist_item_load_idx"),
|
||||
# Index for ordering
|
||||
models.Index(fields=["order"], name="consist_item_order_idx"),
|
||||
# Composite index for consist+load filtering
|
||||
models.Index(
|
||||
fields=["consist", "load"], name="consist_item_con_load_idx"
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return "{0}".format(self.rolling_stock)
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Generated by Django 6.0.1 on 2026-01-18 13:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("metadata", "0026_alter_manufacturer_name_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="company",
|
||||
index=models.Index(fields=["slug"], name="company_slug_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="company",
|
||||
index=models.Index(fields=["country"], name="company_country_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="company",
|
||||
index=models.Index(fields=["freelance"], name="company_freelance_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="manufacturer",
|
||||
index=models.Index(fields=["category"], name="mfr_category_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="manufacturer",
|
||||
index=models.Index(fields=["slug"], name="mfr_slug_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="manufacturer",
|
||||
index=models.Index(fields=["category", "slug"], name="mfr_cat_slug_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="scale",
|
||||
index=models.Index(fields=["slug"], name="scale_slug_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="scale",
|
||||
index=models.Index(fields=["ratio_int"], name="scale_ratio_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="scale",
|
||||
index=models.Index(
|
||||
fields=["-ratio_int", "-tracks"], name="scale_ratio_tracks_idx"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -48,10 +48,19 @@ class Manufacturer(SimpleBaseModel):
|
||||
ordering = ["category", "slug"]
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["name", "category"],
|
||||
name="unique_name_category"
|
||||
fields=["name", "category"], name="unique_name_category"
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
# Index for category filtering
|
||||
models.Index(fields=["category"], name="mfr_category_idx"),
|
||||
# Index for slug lookups
|
||||
models.Index(fields=["slug"], name="mfr_slug_idx"),
|
||||
# Composite index for category+slug (already in ordering)
|
||||
models.Index(
|
||||
fields=["category", "slug"], name="mfr_cat_slug_idx"
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -91,6 +100,14 @@ class Company(SimpleBaseModel):
|
||||
class Meta:
|
||||
verbose_name_plural = "Companies"
|
||||
ordering = ["slug"]
|
||||
indexes = [
|
||||
# Index for slug lookups (used frequently in URLs)
|
||||
models.Index(fields=["slug"], name="company_slug_idx"),
|
||||
# Index for country filtering
|
||||
models.Index(fields=["country"], name="company_country_idx"),
|
||||
# Index for freelance filtering
|
||||
models.Index(fields=["freelance"], name="company_freelance_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
@@ -165,6 +182,16 @@ class Scale(SimpleBaseModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ["-ratio_int", "-tracks", "scale"]
|
||||
indexes = [
|
||||
# Index for slug lookups
|
||||
models.Index(fields=["slug"], name="scale_slug_idx"),
|
||||
# Index for ratio_int ordering and filtering
|
||||
models.Index(fields=["ratio_int"], name="scale_ratio_idx"),
|
||||
# Composite index for common ordering pattern
|
||||
models.Index(
|
||||
fields=["-ratio_int", "-tracks"], name="scale_ratio_tracks_idx"
|
||||
),
|
||||
]
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(
|
||||
|
||||
3
ram/portal/static/js/main.min.js
vendored
3
ram/portal/static/js/main.min.js
vendored
@@ -3,4 +3,5 @@
|
||||
* Copyright 2011-2023 The Bootstrap Authors
|
||||
* Licensed under the Creative Commons Attribution 3.0 Unported License.
|
||||
*/
|
||||
(()=>{"use strict";const e=()=>localStorage.getItem("theme"),t=()=>{const t=e();return t||(window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light")},a=e=>{"auto"===e&&window.matchMedia("(prefers-color-scheme: dark)").matches?document.documentElement.setAttribute("data-bs-theme","dark"):document.documentElement.setAttribute("data-bs-theme",e)};a(t());const r=(e,t=!1)=>{const a=document.querySelector("#bd-theme");if(!a)return;const r=document.querySelector(".theme-icon-active i"),o=document.querySelector(`[data-bs-theme-value="${e}"]`),s=o.querySelector(".theme-icon i").getAttribute("class");document.querySelectorAll("[data-bs-theme-value]").forEach(e=>{e.classList.remove("active"),e.setAttribute("aria-pressed","false")}),o.classList.add("active"),o.setAttribute("aria-pressed","true"),r.setAttribute("class",s),t&&a.focus()};window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",()=>{const r=e();"light"!==r&&"dark"!==r&&a(t())}),window.addEventListener("DOMContentLoaded",()=>{r(t()),document.querySelectorAll("[data-bs-theme-value]").forEach(e=>{e.addEventListener("click",()=>{const t=e.getAttribute("data-bs-theme-value");(e=>{localStorage.setItem("theme",e)})(t),a(t),r(t,!0)})})})})(),document.addEventListener("DOMContentLoaded",function(){"use strict";const e=document.getElementById("tabSelector"),t=window.location.hash.substring(1);if(t){const a=`#nav-${t}`,r=document.querySelector(`[data-bs-target="${a}"]`);r&&(bootstrap.Tab.getOrCreateInstance(r).show(),e.value=a)}document.querySelectorAll('button[data-bs-toggle="tab"]').forEach(e=>{e.addEventListener("shown.bs.tab",e=>{const t=e.target.getAttribute("data-bs-target").replace("nav-","");history.replaceState(null,null,t)})}),e&&(e.addEventListener("change",function(){const e=this.value,t=document.querySelector(`[data-bs-target="${e}"]`);if(t){bootstrap.Tab.getOrCreateInstance(t).show()}}),document.querySelectorAll('[data-bs-toggle="tab"]').forEach(t=>{t.addEventListener("shown.bs.tab",t=>{const a=t.target.getAttribute("data-bs-target");e.value=a})}))}),document.addEventListener("DOMContentLoaded",function(){"use strict";const e=document.querySelectorAll(".needs-validation");Array.from(e).forEach(e=>{e.addEventListener("submit",t=>{e.checkValidity()||(t.preventDefault(),t.stopPropagation()),e.classList.add("was-validated")},!1)})});
|
||||
(()=>{"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)})});
|
||||
//# sourceMappingURL=main.min.js.map
|
||||
1
ram/portal/static/js/main.min.js.map
Normal file
1
ram/portal/static/js/main.min.js.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"names":["getStoredTheme","localStorage","getItem","getPreferredTheme","storedTheme","window","matchMedia","matches","setTheme","theme","document","documentElement","setAttribute","showActiveTheme","focus","themeSwitcher","querySelector","activeThemeIcon","btnToActive","biOfActiveBtn","getAttribute","querySelectorAll","forEach","element","classList","remove","add","addEventListener","toggle","setItem","setStoredTheme","selectElement","getElementById","hash","location","substring","target","trigger","bootstrap","Tab","getOrCreateInstance","show","value","btn","event","newHash","replace","history","replaceState","this","forms","Array","from","form","checkValidity","preventDefault","stopPropagation"],"sources":["ram/portal/static/js/src/theme_selector.js","ram/portal/static/js/src/tabs_selector.js","ram/portal/static/js/src/validators.js"],"mappings":";;;;;AAMA,MACE,aAEA,MAAMA,EAAiB,IAAMC,aAAaC,QAAQ,SAG5CC,EAAoB,KACxB,MAAMC,EAAcJ,IACpB,OAAII,IAIGC,OAAOC,WAAW,gCAAgCC,QAAU,OAAS,UAGxEC,EAAWC,IACD,SAAVA,GAAoBJ,OAAOC,WAAW,gCAAgCC,QACxEG,SAASC,gBAAgBC,aAAa,gBAAiB,QAEvDF,SAASC,gBAAgBC,aAAa,gBAAiBH,IAI3DD,EAASL,KAET,MAAMU,EAAkB,CAACJ,EAAOK,GAAQ,KACtC,MAAMC,EAAgBL,SAASM,cAAc,aAE7C,IAAKD,EACH,OAGF,MAAME,EAAkBP,SAASM,cAAc,wBACzCE,EAAcR,SAASM,cAAc,yBAAyBP,OAC9DU,EAAgBD,EAAYF,cAAc,iBAAiBI,aAAa,SAE9EV,SAASW,iBAAiB,yBAAyBC,QAAQC,IACzDA,EAAQC,UAAUC,OAAO,UACzBF,EAAQX,aAAa,eAAgB,WAGvCM,EAAYM,UAAUE,IAAI,UAC1BR,EAAYN,aAAa,eAAgB,QACzCK,EAAgBL,aAAa,QAASO,GAElCL,GACFC,EAAcD,SAIlBT,OAAOC,WAAW,gCAAgCqB,iBAAiB,SAAU,KAC3E,MAAMvB,EAAcJ,IACA,UAAhBI,GAA2C,SAAhBA,GAC7BI,EAASL,OAIbE,OAAOsB,iBAAiB,mBAAoB,KAC1Cd,EAAgBV,KAChBO,SAASW,iBAAiB,yBACvBC,QAAQM,IACPA,EAAOD,iBAAiB,QAAS,KAC/B,MAAMlB,EAAQmB,EAAOR,aAAa,uBA1DnBX,KAASR,aAAa4B,QAAQ,QAASpB,IA2DtDqB,CAAerB,GACfD,EAASC,GACTI,EAAgBJ,GAAO,QAI/B,EArEF,GCLAC,SAASiB,iBAAiB,mBAAoB,WAC5C,aAEA,MAAMI,EAAgBrB,SAASsB,eAAe,eAExCC,EAAO5B,OAAO6B,SAASD,KAAKE,UAAU,GAC5C,GAAIF,EAAM,CACR,MAAMG,EAAS,QAAQH,IACjBI,EAAU3B,SAASM,cAAc,oBAAoBoB,OACvDC,IACFC,UAAUC,IAAIC,oBAAoBH,GAASI,OAC3CV,EAAcW,MAAQN,EAE1B,CAGA1B,SAASW,iBAAiB,gCAAgCC,QAAQqB,IAChEA,EAAIhB,iBAAiB,eAAgBiB,IACnC,MAAMC,EAAUD,EAAMR,OAAOhB,aAAa,kBAAkB0B,QAAQ,OAAQ,IAC5EC,QAAQC,aAAa,KAAM,KAAMH,OAKhCd,IACLA,EAAcJ,iBAAiB,SAAU,WACvC,MAAMS,EAASa,KAAKP,MACdL,EAAU3B,SAASM,cAAc,oBAAoBoB,OAC3D,GAAIC,EAAS,CACSC,UAAUC,IAAIC,oBAAoBH,GAC1CI,MACd,CACF,GAGA/B,SAASW,iBAAiB,0BAA0BC,QAAQqB,IAC1DA,EAAIhB,iBAAiB,eAAgBiB,IACnC,MAAMR,EAASQ,EAAMR,OAAOhB,aAAa,kBACzCW,EAAcW,MAAQN,MAG5B,GC1CA1B,SAASiB,iBAAiB,mBAAoB,WAC1C,aAEA,MAAMuB,EAAQxC,SAASW,iBAAiB,qBACxC8B,MAAMC,KAAKF,GAAO5B,QAAQ+B,IACxBA,EAAK1B,iBAAiB,SAAUiB,IACzBS,EAAKC,kBACRV,EAAMW,iBACNX,EAAMY,mBAGRH,EAAK7B,UAAUE,IAAI,mBAClB,IAET","ignoreList":[]}
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
```bash
|
||||
$ npm install terser
|
||||
$ npx terser theme_selector.js tabs_selector.js -c -m -o ../main.min.js
|
||||
$ npx terser theme_selector.js tabs_selector.js validators.js -c -m -o ../main.min.js
|
||||
```
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Composition</th>
|
||||
<td>{% for t in consist.get_type_count %}{{ t.count }}x {{ t.type }} {{t.category }}{% if not forloop.last %} » {% endif %}{% endfor %}{% if loads %} | <i class="bi bi-download"></i> {{ loads|length }}x Load{{ loads|pluralize }}{% endif %}</td>
|
||||
<td>{% for t in consist.get_type_count %}{{ t.count }}x {{ t.type }} {{t.category }}{% if not forloop.last %} » {% endif %}{% endfor %}{% if loads %} | <i class="bi bi-download"></i> {{ loads_count }}x Load{{ loads|pluralize }}{% endif %}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,3 +1,643 @@
|
||||
from django.test import TestCase
|
||||
import base64
|
||||
from decimal import Decimal
|
||||
from django.test import TestCase, Client
|
||||
from django.contrib.auth.models import User
|
||||
from django.urls import reverse
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
# Create your tests here.
|
||||
from portal.models import SiteConfiguration, Flatpage
|
||||
from roster.models import RollingClass, RollingStock
|
||||
from consist.models import Consist, ConsistItem
|
||||
from bookshelf.models import (
|
||||
Book,
|
||||
Catalog,
|
||||
Magazine,
|
||||
MagazineIssue,
|
||||
Author,
|
||||
Publisher,
|
||||
)
|
||||
from metadata.models import (
|
||||
Company,
|
||||
Manufacturer,
|
||||
Scale,
|
||||
RollingStockType,
|
||||
Tag,
|
||||
)
|
||||
|
||||
|
||||
class PortalTestBase(TestCase):
|
||||
"""Base test class with common setup for portal views."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data used across multiple test cases."""
|
||||
# Create test user
|
||||
self.user = User.objects.create_user(
|
||||
username="testuser", password="testpass123"
|
||||
)
|
||||
self.client = Client()
|
||||
|
||||
# Create site configuration
|
||||
self.site_config = SiteConfiguration.get_solo()
|
||||
self.site_config.items_per_page = "6"
|
||||
self.site_config.items_ordering = "type"
|
||||
self.site_config.save()
|
||||
|
||||
# Create metadata
|
||||
self.company = Company.objects.create(
|
||||
name="Rio Grande Southern", country="US"
|
||||
)
|
||||
self.company2 = Company.objects.create(name="D&RGW", 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
|
||||
)
|
||||
|
||||
self.stock_type = RollingStockType.objects.create(
|
||||
type="Steam Locomotive", category="locomotive", order=1
|
||||
)
|
||||
self.stock_type2 = RollingStockType.objects.create(
|
||||
type="Box Car", category="freight", order=2
|
||||
)
|
||||
|
||||
self.real_manufacturer = Manufacturer.objects.create(
|
||||
name="Baldwin Locomotive Works", category="real", country="US"
|
||||
)
|
||||
self.model_manufacturer = Manufacturer.objects.create(
|
||||
name="Bachmann", category="model", country="US"
|
||||
)
|
||||
|
||||
self.tag1 = Tag.objects.create(name="Narrow Gauge")
|
||||
self.tag2 = Tag.objects.create(name="Colorado")
|
||||
|
||||
# Create rolling classes
|
||||
self.rolling_class1 = RollingClass.objects.create(
|
||||
identifier="C-19",
|
||||
type=self.stock_type,
|
||||
company=self.company,
|
||||
description="<p>Narrow gauge steam locomotive</p>",
|
||||
)
|
||||
|
||||
self.rolling_class2 = RollingClass.objects.create(
|
||||
identifier="K-27",
|
||||
type=self.stock_type,
|
||||
company=self.company2,
|
||||
description="<p>Another narrow gauge locomotive</p>",
|
||||
)
|
||||
|
||||
# Create rolling stock
|
||||
self.rolling_stock1 = RollingStock.objects.create(
|
||||
rolling_class=self.rolling_class1,
|
||||
road_number="346",
|
||||
scale=self.scale_ho,
|
||||
manufacturer=self.model_manufacturer,
|
||||
item_number="28698",
|
||||
published=True,
|
||||
featured=True,
|
||||
)
|
||||
self.rolling_stock1.tags.add(self.tag1, self.tag2)
|
||||
|
||||
self.rolling_stock2 = RollingStock.objects.create(
|
||||
rolling_class=self.rolling_class2,
|
||||
road_number="455",
|
||||
scale=self.scale_ho,
|
||||
manufacturer=self.model_manufacturer,
|
||||
item_number="28699",
|
||||
published=True,
|
||||
featured=False,
|
||||
)
|
||||
|
||||
self.rolling_stock3 = RollingStock.objects.create(
|
||||
rolling_class=self.rolling_class1,
|
||||
road_number="340",
|
||||
scale=self.scale_n,
|
||||
manufacturer=self.model_manufacturer,
|
||||
item_number="28700",
|
||||
published=False, # Unpublished
|
||||
)
|
||||
|
||||
# Create consist
|
||||
self.consist = Consist.objects.create(
|
||||
identifier="Freight Train 1",
|
||||
company=self.company,
|
||||
scale=self.scale_ho,
|
||||
era="1950s",
|
||||
published=True,
|
||||
)
|
||||
ConsistItem.objects.create(
|
||||
consist=self.consist,
|
||||
rolling_stock=self.rolling_stock1,
|
||||
order=1,
|
||||
load=False,
|
||||
)
|
||||
|
||||
# Create bookshelf data
|
||||
self.publisher = Publisher.objects.create(
|
||||
name="Kalmbach Publishing", country="US"
|
||||
)
|
||||
self.author = Author.objects.create(
|
||||
first_name="John", last_name="Doe"
|
||||
)
|
||||
|
||||
self.book = Book.objects.create(
|
||||
title="Model Railroading Basics",
|
||||
publisher=self.publisher,
|
||||
ISBN="978-0-89024-123-4",
|
||||
language="en",
|
||||
number_of_pages=200,
|
||||
publication_year=2020,
|
||||
published=True,
|
||||
)
|
||||
self.book.authors.add(self.author)
|
||||
|
||||
self.catalog = Catalog.objects.create(
|
||||
manufacturer=self.model_manufacturer,
|
||||
years="2020-2021",
|
||||
publication_year=2020,
|
||||
published=True,
|
||||
)
|
||||
self.catalog.scales.add(self.scale_ho)
|
||||
|
||||
self.magazine = Magazine.objects.create(
|
||||
name="Model Railroader", publisher=self.publisher, published=True
|
||||
)
|
||||
|
||||
self.magazine_issue = MagazineIssue.objects.create(
|
||||
magazine=self.magazine,
|
||||
issue_number="Jan 2020",
|
||||
publication_year=2020,
|
||||
publication_month=1,
|
||||
published=True,
|
||||
)
|
||||
|
||||
# Create flatpage
|
||||
self.flatpage = Flatpage.objects.create(
|
||||
name="About Us",
|
||||
path="about-us",
|
||||
content="<p>About our site</p>",
|
||||
published=True,
|
||||
)
|
||||
|
||||
|
||||
class GetHomeViewTest(PortalTestBase):
|
||||
"""Test cases for GetHome view (homepage)."""
|
||||
|
||||
def test_home_view_loads(self):
|
||||
"""Test that the home page loads successfully."""
|
||||
response = self.client.get(reverse("index"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "home.html")
|
||||
|
||||
def test_home_view_shows_featured_items(self):
|
||||
"""Test that featured items appear on homepage."""
|
||||
response = self.client.get(reverse("index"))
|
||||
self.assertContains(response, "346") # Featured rolling stock
|
||||
self.assertIn(self.rolling_stock1, response.context["data"])
|
||||
|
||||
def test_home_view_hides_unpublished_for_anonymous(self):
|
||||
"""Test that unpublished items are hidden from anonymous users."""
|
||||
response = self.client.get(reverse("index"))
|
||||
# rolling_stock3 is unpublished, should not appear
|
||||
self.assertNotIn(self.rolling_stock3, response.context["data"])
|
||||
|
||||
def test_home_view_shows_unpublished_for_authenticated(self):
|
||||
"""Test that authenticated users see unpublished items."""
|
||||
self.client.login(username="testuser", password="testpass123")
|
||||
response = self.client.get(reverse("index"))
|
||||
# Authenticated users should see all items
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class GetRosterViewTest(PortalTestBase):
|
||||
"""Test cases for GetRoster view."""
|
||||
|
||||
def test_roster_view_loads(self):
|
||||
"""Test that the roster page loads successfully."""
|
||||
response = self.client.get(reverse("roster"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "pagination.html")
|
||||
|
||||
def test_roster_view_shows_published_items(self):
|
||||
"""Test that roster shows published rolling stock."""
|
||||
response = self.client.get(reverse("roster"))
|
||||
self.assertIn(self.rolling_stock1, response.context["data"])
|
||||
self.assertIn(self.rolling_stock2, response.context["data"])
|
||||
|
||||
def test_roster_pagination(self):
|
||||
"""Test roster pagination."""
|
||||
# Create more items to test pagination
|
||||
for i in range(10):
|
||||
RollingStock.objects.create(
|
||||
rolling_class=self.rolling_class1,
|
||||
road_number=f"35{i}",
|
||||
scale=self.scale_ho,
|
||||
manufacturer=self.model_manufacturer,
|
||||
published=True,
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("roster"))
|
||||
self.assertIn("page_range", response.context)
|
||||
# Should paginate with items_per_page=6
|
||||
self.assertLessEqual(len(response.context["data"]), 6)
|
||||
|
||||
|
||||
class GetRollingStockViewTest(PortalTestBase):
|
||||
"""Test cases for GetRollingStock detail view."""
|
||||
|
||||
def test_rolling_stock_detail_view(self):
|
||||
"""Test rolling stock detail view loads correctly."""
|
||||
url = reverse("rolling_stock", kwargs={"uuid": self.rolling_stock1.uuid})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "rollingstock.html")
|
||||
self.assertEqual(
|
||||
response.context["rolling_stock"], self.rolling_stock1
|
||||
)
|
||||
|
||||
def test_rolling_stock_detail_with_properties(self):
|
||||
"""Test detail view includes properties and documents."""
|
||||
url = reverse("rolling_stock", kwargs={"uuid": self.rolling_stock1.uuid})
|
||||
response = self.client.get(url)
|
||||
self.assertIn("properties", response.context)
|
||||
self.assertIn("documents", response.context)
|
||||
self.assertIn("class_properties", response.context)
|
||||
|
||||
def test_rolling_stock_detail_shows_consists(self):
|
||||
"""Test detail view shows consists this rolling stock is in."""
|
||||
url = reverse("rolling_stock", kwargs={"uuid": self.rolling_stock1.uuid})
|
||||
response = self.client.get(url)
|
||||
self.assertIn("consists", response.context)
|
||||
self.assertIn(self.consist, response.context["consists"])
|
||||
|
||||
def test_rolling_stock_detail_not_found(self):
|
||||
"""Test 404 for non-existent rolling stock."""
|
||||
from uuid import uuid4
|
||||
|
||||
url = reverse("rolling_stock", kwargs={"uuid": uuid4()})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class SearchObjectsViewTest(PortalTestBase):
|
||||
"""Test cases for SearchObjects view."""
|
||||
|
||||
def test_search_view_post(self):
|
||||
"""Test search via POST request."""
|
||||
response = self.client.post(reverse("search"), {"search": "346"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "search.html")
|
||||
|
||||
def test_search_finds_rolling_stock(self):
|
||||
"""Test search finds rolling stock by road number."""
|
||||
search_term = base64.b64encode(b"346").decode()
|
||||
url = reverse("search", kwargs={"search": search_term, "page": 1})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# Should find rolling_stock1 with road number 346
|
||||
|
||||
def test_search_with_filter_type(self):
|
||||
"""Test search with type filter."""
|
||||
search_term = base64.b64encode(b"type:Steam").decode()
|
||||
url = reverse("search", kwargs={"search": search_term, "page": 1})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_search_with_filter_company(self):
|
||||
"""Test search with company filter."""
|
||||
search_term = base64.b64encode(b"company:Rio Grande").decode()
|
||||
url = reverse("search", kwargs={"search": search_term, "page": 1})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_search_finds_books(self):
|
||||
"""Test search finds books."""
|
||||
search_term = base64.b64encode(b"Railroading").decode()
|
||||
url = reverse("search", kwargs={"search": search_term, "page": 1})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_search_empty_returns_bad_request(self):
|
||||
"""Test search with empty string returns error."""
|
||||
response = self.client.post(reverse("search"), {"search": ""})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
|
||||
class GetObjectsFilteredViewTest(PortalTestBase):
|
||||
"""Test cases for GetObjectsFiltered view."""
|
||||
|
||||
def test_filter_by_type(self):
|
||||
"""Test filtering by rolling stock type."""
|
||||
url = reverse(
|
||||
"filtered",
|
||||
kwargs={"_filter": "type", "search": self.stock_type.slug},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "filter.html")
|
||||
|
||||
def test_filter_by_company(self):
|
||||
"""Test filtering by company."""
|
||||
url = reverse(
|
||||
"filtered",
|
||||
kwargs={"_filter": "company", "search": self.company.slug},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_filter_by_scale(self):
|
||||
"""Test filtering by scale."""
|
||||
url = reverse(
|
||||
"filtered",
|
||||
kwargs={"_filter": "scale", "search": self.scale_ho.slug},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_filter_by_tag(self):
|
||||
"""Test filtering by tag."""
|
||||
url = reverse(
|
||||
"filtered", kwargs={"_filter": "tag", "search": self.tag1.slug}
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# Should find rolling_stock1 which has tag1
|
||||
|
||||
def test_filter_invalid_raises_404(self):
|
||||
"""Test invalid filter type raises 404."""
|
||||
url = reverse(
|
||||
"filtered", kwargs={"_filter": "invalid", "search": "test"}
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class GetManufacturerItemViewTest(PortalTestBase):
|
||||
"""Test cases for GetManufacturerItem view."""
|
||||
|
||||
def test_manufacturer_view_all_items(self):
|
||||
"""Test manufacturer view showing all items."""
|
||||
url = reverse(
|
||||
"manufacturer",
|
||||
kwargs={
|
||||
"manufacturer": self.model_manufacturer.slug,
|
||||
"search": "all",
|
||||
},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "manufacturer.html")
|
||||
|
||||
def test_manufacturer_view_specific_item(self):
|
||||
"""Test manufacturer view filtered by item number."""
|
||||
url = reverse(
|
||||
"manufacturer",
|
||||
kwargs={
|
||||
"manufacturer": self.model_manufacturer.slug,
|
||||
"search": self.rolling_stock1.item_number_slug,
|
||||
},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
# Should return rolling stock with that item number
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_manufacturer_not_found(self):
|
||||
"""Test 404 for non-existent manufacturer."""
|
||||
url = reverse(
|
||||
"manufacturer",
|
||||
kwargs={"manufacturer": "nonexistent", "search": "all"},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class ConsistsViewTest(PortalTestBase):
|
||||
"""Test cases for Consists list view."""
|
||||
|
||||
def test_consists_list_view(self):
|
||||
"""Test consists list view loads."""
|
||||
response = self.client.get(reverse("consists"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(self.consist, response.context["data"])
|
||||
|
||||
def test_consists_pagination(self):
|
||||
"""Test consists list pagination."""
|
||||
# Create more consists for pagination
|
||||
for i in range(10):
|
||||
Consist.objects.create(
|
||||
identifier=f"Train {i}",
|
||||
company=self.company,
|
||||
scale=self.scale_ho,
|
||||
published=True,
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("consists"))
|
||||
self.assertIn("page_range", response.context)
|
||||
|
||||
|
||||
class GetConsistViewTest(PortalTestBase):
|
||||
"""Test cases for GetConsist detail view."""
|
||||
|
||||
def test_consist_detail_view(self):
|
||||
"""Test consist detail view loads correctly."""
|
||||
url = reverse("consist", kwargs={"uuid": self.consist.uuid})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "consist.html")
|
||||
self.assertEqual(response.context["consist"], self.consist)
|
||||
|
||||
def test_consist_shows_rolling_stock(self):
|
||||
"""Test consist detail shows constituent rolling stock."""
|
||||
url = reverse("consist", kwargs={"uuid": self.consist.uuid})
|
||||
response = self.client.get(url)
|
||||
self.assertIn("data", response.context)
|
||||
# Should show rolling_stock1 which is in the consist
|
||||
|
||||
def test_consist_not_found(self):
|
||||
"""Test 404 for non-existent consist."""
|
||||
from uuid import uuid4
|
||||
|
||||
url = reverse("consist", kwargs={"uuid": uuid4()})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class MetadataListViewsTest(PortalTestBase):
|
||||
"""Test cases for metadata list views (Companies, Scales, Types)."""
|
||||
|
||||
def test_companies_view(self):
|
||||
"""Test companies list view."""
|
||||
response = self.client.get(reverse("companies"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(self.company, response.context["data"])
|
||||
|
||||
def test_manufacturers_view_real(self):
|
||||
"""Test manufacturers view for real manufacturers."""
|
||||
url = reverse("manufacturers", kwargs={"category": "real"})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(self.real_manufacturer, response.context["data"])
|
||||
|
||||
def test_manufacturers_view_model(self):
|
||||
"""Test manufacturers view for model manufacturers."""
|
||||
url = reverse("manufacturers", kwargs={"category": "model"})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(self.model_manufacturer, response.context["data"])
|
||||
|
||||
def test_manufacturers_invalid_category(self):
|
||||
"""Test manufacturers view with invalid category."""
|
||||
url = reverse("manufacturers", kwargs={"category": "invalid"})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_scales_view(self):
|
||||
"""Test scales list view."""
|
||||
response = self.client.get(reverse("scales"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(self.scale_ho, response.context["data"])
|
||||
|
||||
def test_types_view(self):
|
||||
"""Test rolling stock types list view."""
|
||||
response = self.client.get(reverse("rolling_stock_types"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(self.stock_type, response.context["data"])
|
||||
|
||||
|
||||
class BookshelfViewsTest(PortalTestBase):
|
||||
"""Test cases for bookshelf views."""
|
||||
|
||||
def test_books_list_view(self):
|
||||
"""Test books list view."""
|
||||
response = self.client.get(reverse("books"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(self.book, response.context["data"])
|
||||
|
||||
def test_catalogs_list_view(self):
|
||||
"""Test catalogs list view."""
|
||||
response = self.client.get(reverse("catalogs"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(self.catalog, response.context["data"])
|
||||
|
||||
def test_magazines_list_view(self):
|
||||
"""Test magazines list view."""
|
||||
response = self.client.get(reverse("magazines"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(self.magazine, response.context["data"])
|
||||
|
||||
def test_book_detail_view(self):
|
||||
"""Test book detail view."""
|
||||
url = reverse(
|
||||
"bookshelf_item",
|
||||
kwargs={"selector": "book", "uuid": self.book.uuid},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "bookshelf/book.html")
|
||||
self.assertEqual(response.context["data"], self.book)
|
||||
|
||||
def test_catalog_detail_view(self):
|
||||
"""Test catalog detail view."""
|
||||
url = reverse(
|
||||
"bookshelf_item",
|
||||
kwargs={"selector": "catalog", "uuid": self.catalog.uuid},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.context["data"], self.catalog)
|
||||
|
||||
def test_bookshelf_item_invalid_selector(self):
|
||||
"""Test bookshelf item with invalid selector."""
|
||||
url = reverse(
|
||||
"bookshelf_item",
|
||||
kwargs={"selector": "invalid", "uuid": self.book.uuid},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_magazine_detail_view(self):
|
||||
"""Test magazine detail view."""
|
||||
url = reverse("magazine", kwargs={"uuid": self.magazine.uuid})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "bookshelf/magazine.html")
|
||||
|
||||
def test_magazine_issue_detail_view(self):
|
||||
"""Test magazine issue detail view."""
|
||||
url = reverse(
|
||||
"issue",
|
||||
kwargs={
|
||||
"magazine": self.magazine.uuid,
|
||||
"uuid": self.magazine_issue.uuid,
|
||||
},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.context["data"], self.magazine_issue)
|
||||
|
||||
|
||||
class FlatpageViewTest(PortalTestBase):
|
||||
"""Test cases for Flatpage view."""
|
||||
|
||||
def test_flatpage_view_loads(self):
|
||||
"""Test flatpage loads correctly."""
|
||||
url = reverse("flatpage", kwargs={"flatpage": self.flatpage.path})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "flatpages/flatpage.html")
|
||||
self.assertEqual(response.context["flatpage"], self.flatpage)
|
||||
|
||||
def test_flatpage_not_found(self):
|
||||
"""Test 404 for non-existent flatpage."""
|
||||
url = reverse("flatpage", kwargs={"flatpage": "nonexistent"})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_unpublished_flatpage_hidden_from_anonymous(self):
|
||||
"""Test unpublished flatpage is hidden from anonymous users."""
|
||||
self.flatpage.published = False
|
||||
self.flatpage.save()
|
||||
|
||||
url = reverse("flatpage", kwargs={"flatpage": self.flatpage.path})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class RenderExtraJSViewTest(PortalTestBase):
|
||||
"""Test cases for RenderExtraJS view."""
|
||||
|
||||
def test_extra_js_view_loads(self):
|
||||
"""Test extra JS endpoint loads."""
|
||||
response = self.client.get(reverse("extra_js"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response["Content-Type"], "application/javascript")
|
||||
|
||||
def test_extra_js_returns_configured_content(self):
|
||||
"""Test extra JS returns configured JavaScript."""
|
||||
self.site_config.extra_js = "console.log('test');"
|
||||
self.site_config.save()
|
||||
|
||||
response = self.client.get(reverse("extra_js"))
|
||||
self.assertContains(response, "console.log('test');")
|
||||
|
||||
|
||||
class QueryOptimizationTest(PortalTestBase):
|
||||
"""Test cases to verify query optimization is working."""
|
||||
|
||||
def test_rolling_stock_list_uses_select_related(self):
|
||||
"""Test that rolling stock list view uses query optimization."""
|
||||
# This test verifies the optimization exists in the code
|
||||
# In a real scenario, you'd use django-debug-toolbar or
|
||||
# assertNumQueries to verify actual query counts
|
||||
response = self.client.get(reverse("roster"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# If optimization is working, this should use far fewer queries
|
||||
# than the number of rolling stock items
|
||||
|
||||
def test_consist_detail_uses_prefetch_related(self):
|
||||
"""Test that consist detail view uses query optimization."""
|
||||
url = reverse("consist", kwargs={"uuid": self.consist.uuid})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# Should prefetch rolling stock items to avoid N+1 queries
|
||||
|
||||
@@ -96,6 +96,7 @@ class GetData(View):
|
||||
def get_data(self, request):
|
||||
return (
|
||||
RollingStock.objects.get_published(request.user)
|
||||
.with_related()
|
||||
.order_by(*get_items_ordering())
|
||||
.filter(self.filter)
|
||||
)
|
||||
@@ -132,6 +133,7 @@ class GetHome(GetData):
|
||||
max_items = min(settings.FEATURED_ITEMS_MAX, get_items_per_page())
|
||||
return (
|
||||
RollingStock.objects.get_published(request.user)
|
||||
.with_related()
|
||||
.filter(featured=True)
|
||||
.order_by(*get_items_ordering(config="featured_items_ordering"))[
|
||||
:max_items
|
||||
@@ -200,6 +202,7 @@ class SearchObjects(View):
|
||||
# and manufacturer as well
|
||||
roster = (
|
||||
RollingStock.objects.get_published(request.user)
|
||||
.with_related()
|
||||
.filter(query)
|
||||
.distinct()
|
||||
.order_by(*get_items_ordering())
|
||||
@@ -209,6 +212,7 @@ class SearchObjects(View):
|
||||
if _filter is None:
|
||||
consists = (
|
||||
Consist.objects.get_published(request.user)
|
||||
.with_related()
|
||||
.filter(
|
||||
Q(
|
||||
Q(identifier__icontains=search)
|
||||
@@ -220,6 +224,7 @@ class SearchObjects(View):
|
||||
data = list(chain(data, consists))
|
||||
books = (
|
||||
Book.objects.get_published(request.user)
|
||||
.with_related()
|
||||
.filter(
|
||||
Q(
|
||||
Q(title__icontains=search)
|
||||
@@ -231,6 +236,7 @@ class SearchObjects(View):
|
||||
)
|
||||
catalogs = (
|
||||
Catalog.objects.get_published(request.user)
|
||||
.with_related()
|
||||
.filter(
|
||||
Q(
|
||||
Q(manufacturer__name__icontains=search)
|
||||
@@ -242,6 +248,7 @@ class SearchObjects(View):
|
||||
data = list(chain(data, books, catalogs))
|
||||
magazine_issues = (
|
||||
MagazineIssue.objects.get_published(request.user)
|
||||
.with_related()
|
||||
.filter(
|
||||
Q(
|
||||
Q(magazine__name__icontains=search)
|
||||
@@ -331,9 +338,16 @@ class GetManufacturerItem(View):
|
||||
)
|
||||
if search != "all":
|
||||
roster = get_list_or_404(
|
||||
RollingStock.objects.get_published(request.user).order_by(
|
||||
*get_items_ordering()
|
||||
),
|
||||
RollingStock.objects.get_published(request.user)
|
||||
.select_related(
|
||||
'rolling_class',
|
||||
'rolling_class__company',
|
||||
'rolling_class__type',
|
||||
'manufacturer',
|
||||
'scale',
|
||||
)
|
||||
.prefetch_related('image')
|
||||
.order_by(*get_items_ordering()),
|
||||
Q(
|
||||
Q(manufacturer=manufacturer)
|
||||
& Q(item_number_slug__exact=search)
|
||||
@@ -349,6 +363,7 @@ class GetManufacturerItem(View):
|
||||
else:
|
||||
roster = (
|
||||
RollingStock.objects.get_published(request.user)
|
||||
.with_related()
|
||||
.filter(
|
||||
Q(manufacturer=manufacturer)
|
||||
| Q(rolling_class__manufacturer=manufacturer)
|
||||
@@ -356,8 +371,10 @@ class GetManufacturerItem(View):
|
||||
.distinct()
|
||||
.order_by(*get_items_ordering())
|
||||
)
|
||||
catalogs = Catalog.objects.get_published(request.user).filter(
|
||||
manufacturer=manufacturer
|
||||
catalogs = (
|
||||
Catalog.objects.get_published(request.user)
|
||||
.with_related()
|
||||
.filter(manufacturer=manufacturer)
|
||||
)
|
||||
title = "Manufacturer: {0}".format(manufacturer)
|
||||
|
||||
@@ -405,6 +422,7 @@ class GetObjectsFiltered(View):
|
||||
|
||||
roster = (
|
||||
RollingStock.objects.get_published(request.user)
|
||||
.with_related()
|
||||
.filter(query)
|
||||
.distinct()
|
||||
.order_by(*get_items_ordering())
|
||||
@@ -415,6 +433,7 @@ class GetObjectsFiltered(View):
|
||||
if _filter == "scale":
|
||||
catalogs = (
|
||||
Catalog.objects.get_published(request.user)
|
||||
.with_related()
|
||||
.filter(scales__slug=search)
|
||||
.distinct()
|
||||
)
|
||||
@@ -423,6 +442,7 @@ class GetObjectsFiltered(View):
|
||||
try: # Execute only if query_2nd is defined
|
||||
consists = (
|
||||
Consist.objects.get_published(request.user)
|
||||
.with_related()
|
||||
.filter(query_2nd)
|
||||
.distinct()
|
||||
)
|
||||
@@ -430,16 +450,19 @@ class GetObjectsFiltered(View):
|
||||
if _filter == "tag": # Books can be filtered only by tag
|
||||
books = (
|
||||
Book.objects.get_published(request.user)
|
||||
.with_related()
|
||||
.filter(query_2nd)
|
||||
.distinct()
|
||||
)
|
||||
catalogs = (
|
||||
Catalog.objects.get_published(request.user)
|
||||
.with_related()
|
||||
.filter(query_2nd)
|
||||
.distinct()
|
||||
)
|
||||
magazine_issues = (
|
||||
MagazineIssue.objects.get_published(request.user)
|
||||
.with_related()
|
||||
.filter(query_2nd)
|
||||
.distinct()
|
||||
)
|
||||
@@ -477,9 +500,11 @@ class GetObjectsFiltered(View):
|
||||
class GetRollingStock(View):
|
||||
def get(self, request, uuid):
|
||||
try:
|
||||
rolling_stock = RollingStock.objects.get_published(
|
||||
request.user
|
||||
).get(uuid=uuid)
|
||||
rolling_stock = (
|
||||
RollingStock.objects.get_published(request.user)
|
||||
.with_details()
|
||||
.get(uuid=uuid)
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
raise Http404
|
||||
|
||||
@@ -498,13 +523,14 @@ class GetRollingStock(View):
|
||||
)
|
||||
|
||||
consists = list(
|
||||
Consist.objects.get_published(request.user).filter(
|
||||
consist_item__rolling_stock=rolling_stock
|
||||
)
|
||||
Consist.objects.get_published(request.user)
|
||||
.with_related()
|
||||
.filter(consist_item__rolling_stock=rolling_stock)
|
||||
)
|
||||
|
||||
trainset = list(
|
||||
RollingStock.objects.get_published(request.user)
|
||||
.with_related()
|
||||
.filter(
|
||||
Q(
|
||||
Q(item_number__exact=rolling_stock.item_number)
|
||||
@@ -535,30 +561,52 @@ class Consists(GetData):
|
||||
title = "Consists"
|
||||
|
||||
def get_data(self, request):
|
||||
return Consist.objects.get_published(request.user).all()
|
||||
return (
|
||||
Consist.objects.get_published(request.user)
|
||||
.with_related()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
class GetConsist(View):
|
||||
def get(self, request, uuid, page=1):
|
||||
try:
|
||||
consist = Consist.objects.get_published(request.user).get(
|
||||
uuid=uuid
|
||||
consist = (
|
||||
Consist.objects.get_published(request.user)
|
||||
.with_rolling_stock()
|
||||
.get(uuid=uuid)
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
raise Http404
|
||||
|
||||
data = list(
|
||||
RollingStock.objects.get_published(request.user).get(
|
||||
uuid=r.rolling_stock_id
|
||||
)
|
||||
for r in consist.consist_item.filter(load=False)
|
||||
)
|
||||
loads = list(
|
||||
RollingStock.objects.get_published(request.user).get(
|
||||
uuid=r.rolling_stock_id
|
||||
)
|
||||
for r in consist.consist_item.filter(load=True)
|
||||
# Get all published rolling stock IDs for efficient filtering
|
||||
published_ids = set(
|
||||
RollingStock.objects.get_published(request.user)
|
||||
.values_list('uuid', flat=True)
|
||||
)
|
||||
|
||||
# Fetch consist items with related rolling stock in one query
|
||||
consist_items = consist.consist_item.select_related(
|
||||
'rolling_stock',
|
||||
'rolling_stock__rolling_class',
|
||||
'rolling_stock__rolling_class__company',
|
||||
'rolling_stock__rolling_class__type',
|
||||
'rolling_stock__manufacturer',
|
||||
'rolling_stock__scale',
|
||||
).prefetch_related('rolling_stock__image')
|
||||
|
||||
# Filter items and loads efficiently
|
||||
data = [
|
||||
item.rolling_stock
|
||||
for item in consist_items.filter(load=False)
|
||||
if item.rolling_stock.uuid in published_ids
|
||||
]
|
||||
loads = [
|
||||
item.rolling_stock
|
||||
for item in consist_items.filter(load=True)
|
||||
if item.rolling_stock.uuid in published_ids
|
||||
]
|
||||
|
||||
paginator = Paginator(data, get_items_per_page())
|
||||
data = paginator.get_page(page)
|
||||
page_range = paginator.get_elided_page_range(
|
||||
@@ -573,6 +621,7 @@ class GetConsist(View):
|
||||
"consist": consist,
|
||||
"data": data,
|
||||
"loads": loads,
|
||||
"loads_count": len(loads),
|
||||
"page_range": page_range,
|
||||
},
|
||||
)
|
||||
@@ -739,14 +788,22 @@ class Books(GetData):
|
||||
title = "Books"
|
||||
|
||||
def get_data(self, request):
|
||||
return Book.objects.get_published(request.user).all()
|
||||
return (
|
||||
Book.objects.get_published(request.user)
|
||||
.with_related()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
class Catalogs(GetData):
|
||||
title = "Catalogs"
|
||||
|
||||
def get_data(self, request):
|
||||
return Catalog.objects.get_published(request.user).all()
|
||||
return (
|
||||
Catalog.objects.get_published(request.user)
|
||||
.with_related()
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
class Magazines(GetData):
|
||||
@@ -755,6 +812,8 @@ class Magazines(GetData):
|
||||
def get_data(self, request):
|
||||
return (
|
||||
Magazine.objects.get_published(request.user)
|
||||
.select_related('publisher')
|
||||
.prefetch_related('tags')
|
||||
.order_by(Lower("name"))
|
||||
.annotate(
|
||||
issues=Count(
|
||||
@@ -772,12 +831,19 @@ class Magazines(GetData):
|
||||
class GetMagazine(View):
|
||||
def get(self, request, uuid, page=1):
|
||||
try:
|
||||
magazine = Magazine.objects.get_published(request.user).get(
|
||||
uuid=uuid
|
||||
magazine = (
|
||||
Magazine.objects.get_published(request.user)
|
||||
.select_related('publisher')
|
||||
.prefetch_related('tags')
|
||||
.get(uuid=uuid)
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
raise Http404
|
||||
data = list(magazine.issue.get_published(request.user).all())
|
||||
data = list(
|
||||
magazine.issue.get_published(request.user)
|
||||
.with_related()
|
||||
.all()
|
||||
)
|
||||
paginator = Paginator(data, get_items_per_page())
|
||||
data = paginator.get_page(page)
|
||||
page_range = paginator.get_elided_page_range(
|
||||
@@ -800,9 +866,10 @@ class GetMagazine(View):
|
||||
class GetMagazineIssue(View):
|
||||
def get(self, request, uuid, magazine, page=1):
|
||||
try:
|
||||
issue = MagazineIssue.objects.get_published(request.user).get(
|
||||
uuid=uuid,
|
||||
magazine__uuid=magazine,
|
||||
issue = (
|
||||
MagazineIssue.objects.get_published(request.user)
|
||||
.with_details()
|
||||
.get(uuid=uuid, magazine__uuid=magazine)
|
||||
)
|
||||
except ObjectDoesNotExist:
|
||||
raise Http404
|
||||
@@ -823,9 +890,17 @@ class GetMagazineIssue(View):
|
||||
class GetBookCatalog(View):
|
||||
def get_object(self, request, uuid, selector):
|
||||
if selector == "book":
|
||||
return Book.objects.get_published(request.user).get(uuid=uuid)
|
||||
return (
|
||||
Book.objects.get_published(request.user)
|
||||
.with_details()
|
||||
.get(uuid=uuid)
|
||||
)
|
||||
elif selector == "catalog":
|
||||
return Catalog.objects.get_published(request.user).get(uuid=uuid)
|
||||
return (
|
||||
Catalog.objects.get_published(request.user)
|
||||
.with_details()
|
||||
.get(uuid=uuid)
|
||||
)
|
||||
else:
|
||||
raise Http404
|
||||
|
||||
|
||||
@@ -9,5 +9,5 @@ if DJANGO_VERSION < (6, 0):
|
||||
)
|
||||
)
|
||||
|
||||
__version__ = "0.19.10"
|
||||
__version__ = "0.20.1"
|
||||
__version__ += git_suffix(__file__)
|
||||
|
||||
@@ -2,18 +2,227 @@ from django.db import models
|
||||
from django.core.exceptions import FieldError
|
||||
|
||||
|
||||
class PublicManager(models.Manager):
|
||||
class PublicQuerySet(models.QuerySet):
|
||||
"""Base QuerySet with published/public filtering."""
|
||||
|
||||
def get_published(self, user):
|
||||
"""
|
||||
Get published items based on user authentication status.
|
||||
Returns all items for authenticated users, only published for anonymous.
|
||||
"""
|
||||
if user.is_authenticated:
|
||||
return self.get_queryset()
|
||||
return self
|
||||
else:
|
||||
return self.get_queryset().filter(published=True)
|
||||
return self.filter(published=True)
|
||||
|
||||
def get_public(self, user):
|
||||
"""
|
||||
Get public items based on user authentication status.
|
||||
Returns all items for authenticated users, only non-private for anonymous.
|
||||
"""
|
||||
if user.is_authenticated:
|
||||
return self.get_queryset()
|
||||
return self
|
||||
else:
|
||||
try:
|
||||
return self.get_queryset().filter(private=False)
|
||||
return self.filter(private=False)
|
||||
except FieldError:
|
||||
return self.get_queryset().filter(property__private=False)
|
||||
return self.filter(property__private=False)
|
||||
|
||||
|
||||
class PublicManager(models.Manager):
|
||||
"""Manager using PublicQuerySet."""
|
||||
|
||||
def get_queryset(self):
|
||||
return PublicQuerySet(self.model, using=self._db)
|
||||
|
||||
def get_published(self, user):
|
||||
return self.get_queryset().get_published(user)
|
||||
|
||||
def get_public(self, user):
|
||||
return self.get_queryset().get_public(user)
|
||||
|
||||
|
||||
class RollingStockQuerySet(PublicQuerySet):
|
||||
"""QuerySet with optimization methods for RollingStock."""
|
||||
|
||||
def with_related(self):
|
||||
"""
|
||||
Optimize queryset by prefetching commonly accessed related objects.
|
||||
Use this for list views to avoid N+1 queries.
|
||||
"""
|
||||
return self.select_related(
|
||||
'rolling_class',
|
||||
'rolling_class__company',
|
||||
'rolling_class__type',
|
||||
'manufacturer',
|
||||
'scale',
|
||||
'decoder',
|
||||
'shop',
|
||||
).prefetch_related('tags', 'image')
|
||||
|
||||
def with_details(self):
|
||||
"""
|
||||
Optimize queryset for detail views with all related objects.
|
||||
Includes properties, documents, and journal entries.
|
||||
"""
|
||||
return self.with_related().prefetch_related(
|
||||
'property',
|
||||
'document',
|
||||
'journal',
|
||||
'rolling_class__property',
|
||||
'rolling_class__manufacturer',
|
||||
'decoder__document',
|
||||
)
|
||||
|
||||
|
||||
class RollingStockManager(PublicManager):
|
||||
"""Optimized manager for RollingStock with prefetch methods."""
|
||||
|
||||
def get_queryset(self):
|
||||
return RollingStockQuerySet(self.model, using=self._db)
|
||||
|
||||
def with_related(self):
|
||||
return self.get_queryset().with_related()
|
||||
|
||||
def with_details(self):
|
||||
return self.get_queryset().with_details()
|
||||
|
||||
def get_published_with_related(self, user):
|
||||
"""
|
||||
Convenience method combining get_published with related objects.
|
||||
"""
|
||||
return self.get_published(user).with_related()
|
||||
|
||||
|
||||
class ConsistQuerySet(PublicQuerySet):
|
||||
"""QuerySet with optimization methods for Consist."""
|
||||
|
||||
def with_related(self):
|
||||
"""
|
||||
Optimize queryset by prefetching commonly accessed related objects.
|
||||
Note: Consist.image is a direct ImageField, not a relation.
|
||||
"""
|
||||
return self.select_related('company', 'scale').prefetch_related(
|
||||
'tags', 'consist_item'
|
||||
)
|
||||
|
||||
def with_rolling_stock(self):
|
||||
"""
|
||||
Optimize queryset including consist items and their rolling stock.
|
||||
Use for detail views showing consist composition.
|
||||
"""
|
||||
return self.with_related().prefetch_related(
|
||||
'consist_item__rolling_stock',
|
||||
'consist_item__rolling_stock__rolling_class',
|
||||
'consist_item__rolling_stock__rolling_class__company',
|
||||
'consist_item__rolling_stock__rolling_class__type',
|
||||
'consist_item__rolling_stock__manufacturer',
|
||||
'consist_item__rolling_stock__scale',
|
||||
'consist_item__rolling_stock__image',
|
||||
)
|
||||
|
||||
|
||||
class ConsistManager(PublicManager):
|
||||
"""Optimized manager for Consist with prefetch methods."""
|
||||
|
||||
def get_queryset(self):
|
||||
return ConsistQuerySet(self.model, using=self._db)
|
||||
|
||||
def with_related(self):
|
||||
return self.get_queryset().with_related()
|
||||
|
||||
def with_rolling_stock(self):
|
||||
return self.get_queryset().with_rolling_stock()
|
||||
|
||||
|
||||
class BookQuerySet(PublicQuerySet):
|
||||
"""QuerySet with optimization methods for Book."""
|
||||
|
||||
def with_related(self):
|
||||
"""
|
||||
Optimize queryset by prefetching commonly accessed related objects.
|
||||
"""
|
||||
return self.select_related('publisher', 'shop').prefetch_related(
|
||||
'authors', 'tags', 'image', 'toc'
|
||||
)
|
||||
|
||||
def with_details(self):
|
||||
"""
|
||||
Optimize queryset for detail views with properties and documents.
|
||||
"""
|
||||
return self.with_related().prefetch_related('property', 'document')
|
||||
|
||||
|
||||
class BookManager(PublicManager):
|
||||
"""Optimized manager for Book/Catalog with prefetch methods."""
|
||||
|
||||
def get_queryset(self):
|
||||
return BookQuerySet(self.model, using=self._db)
|
||||
|
||||
def with_related(self):
|
||||
return self.get_queryset().with_related()
|
||||
|
||||
def with_details(self):
|
||||
return self.get_queryset().with_details()
|
||||
|
||||
|
||||
class CatalogQuerySet(PublicQuerySet):
|
||||
"""QuerySet with optimization methods for Catalog."""
|
||||
|
||||
def with_related(self):
|
||||
"""
|
||||
Optimize queryset by prefetching commonly accessed related objects.
|
||||
"""
|
||||
return self.select_related('manufacturer', 'shop').prefetch_related(
|
||||
'scales', 'tags', 'image'
|
||||
)
|
||||
|
||||
def with_details(self):
|
||||
"""
|
||||
Optimize queryset for detail views with properties and documents.
|
||||
"""
|
||||
return self.with_related().prefetch_related('property', 'document')
|
||||
|
||||
|
||||
class CatalogManager(PublicManager):
|
||||
"""Optimized manager for Catalog with prefetch methods."""
|
||||
|
||||
def get_queryset(self):
|
||||
return CatalogQuerySet(self.model, using=self._db)
|
||||
|
||||
def with_related(self):
|
||||
return self.get_queryset().with_related()
|
||||
|
||||
def with_details(self):
|
||||
return self.get_queryset().with_details()
|
||||
|
||||
|
||||
class MagazineIssueQuerySet(PublicQuerySet):
|
||||
"""QuerySet with optimization methods for MagazineIssue."""
|
||||
|
||||
def with_related(self):
|
||||
"""
|
||||
Optimize queryset by prefetching commonly accessed related objects.
|
||||
"""
|
||||
return self.select_related('magazine').prefetch_related(
|
||||
'tags', 'image', 'toc'
|
||||
)
|
||||
|
||||
def with_details(self):
|
||||
"""
|
||||
Optimize queryset for detail views with properties and documents.
|
||||
"""
|
||||
return self.with_related().prefetch_related('property', 'document')
|
||||
|
||||
|
||||
class MagazineIssueManager(PublicManager):
|
||||
"""Optimized manager for MagazineIssue with prefetch methods."""
|
||||
|
||||
def get_queryset(self):
|
||||
return MagazineIssueQuerySet(self.model, using=self._db)
|
||||
|
||||
def with_related(self):
|
||||
return self.get_queryset().with_related()
|
||||
|
||||
def with_details(self):
|
||||
return self.get_queryset().with_details()
|
||||
|
||||
@@ -158,6 +158,11 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
)
|
||||
save_as = True
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queryset with select_related and prefetch_related."""
|
||||
qs = super().get_queryset(request)
|
||||
return qs.with_related()
|
||||
|
||||
@admin.display(description="Country")
|
||||
def country_flag(self, obj):
|
||||
return format_html(
|
||||
@@ -268,6 +273,18 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
|
||||
"Properties",
|
||||
]
|
||||
data = []
|
||||
|
||||
# Prefetch related data to avoid N+1 queries
|
||||
queryset = queryset.select_related(
|
||||
'rolling_class',
|
||||
'rolling_class__type',
|
||||
'rolling_class__company',
|
||||
'manufacturer',
|
||||
'scale',
|
||||
'decoder',
|
||||
'shop'
|
||||
).prefetch_related('tags', 'property__property')
|
||||
|
||||
for obj in queryset:
|
||||
properties = settings.CSV_SEPARATOR_ALT.join(
|
||||
"{}:{}".format(property.property.name, property.value)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Generated by Django 6.0.1 on 2026-01-18 13:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
(
|
||||
"metadata",
|
||||
"0027_company_company_slug_idx_company_company_country_idx_and_more",
|
||||
),
|
||||
("roster", "0040_alter_rollingstock_decoder_interface_order"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddIndex(
|
||||
model_name="rollingclass",
|
||||
index=models.Index(fields=["company"], name="roster_rc_company_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="rollingclass",
|
||||
index=models.Index(fields=["type"], name="roster_rc_type_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="rollingclass",
|
||||
index=models.Index(
|
||||
fields=["company", "identifier"], name="roster_rc_co_ident_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="rollingstock",
|
||||
index=models.Index(fields=["published"], name="roster_published_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="rollingstock",
|
||||
index=models.Index(fields=["featured"], name="roster_featured_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="rollingstock",
|
||||
index=models.Index(
|
||||
fields=["item_number_slug"], name="roster_item_slug_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="rollingstock",
|
||||
index=models.Index(fields=["road_number_int"], name="roster_road_num_idx"),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="rollingstock",
|
||||
index=models.Index(
|
||||
fields=["published", "featured"], name="roster_pub_feat_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="rollingstock",
|
||||
index=models.Index(
|
||||
fields=["manufacturer", "item_number_slug"], name="roster_mfr_item_idx"
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="rollingstock",
|
||||
index=models.Index(fields=["scale"], name="roster_scale_idx"),
|
||||
),
|
||||
]
|
||||
@@ -11,7 +11,7 @@ from tinymce import models as tinymce
|
||||
|
||||
from ram.models import BaseModel, Image, PropertyInstance
|
||||
from ram.utils import DeduplicatedStorage, slugify
|
||||
from ram.managers import PublicManager
|
||||
from ram.managers import RollingStockManager
|
||||
from metadata.models import (
|
||||
Scale,
|
||||
Manufacturer,
|
||||
@@ -38,6 +38,14 @@ class RollingClass(models.Model):
|
||||
ordering = ["company", "identifier"]
|
||||
verbose_name = "Class"
|
||||
verbose_name_plural = "Classes"
|
||||
indexes = [
|
||||
models.Index(fields=["company"], name="roster_rc_company_idx"),
|
||||
models.Index(fields=["type"], name="roster_rc_type_idx"),
|
||||
models.Index(
|
||||
fields=["company", "identifier"],
|
||||
name="roster_rc_co_ident_idx", # Shortened to fit 30 char limit
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return "{0} {1}".format(self.company, self.identifier)
|
||||
@@ -120,9 +128,35 @@ class RollingStock(BaseModel):
|
||||
Tag, related_name="rolling_stock", blank=True
|
||||
)
|
||||
|
||||
objects = RollingStockManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ["rolling_class", "road_number_int"]
|
||||
verbose_name_plural = "Rolling stock"
|
||||
indexes = [
|
||||
# Index for published/featured filtering
|
||||
models.Index(fields=["published"], name="roster_published_idx"),
|
||||
models.Index(fields=["featured"], name="roster_featured_idx"),
|
||||
# Index for item number searches
|
||||
models.Index(
|
||||
fields=["item_number_slug"], name="roster_item_slug_idx"
|
||||
),
|
||||
# Index for road number searches and ordering
|
||||
models.Index(
|
||||
fields=["road_number_int"], name="roster_road_num_idx"
|
||||
),
|
||||
# Composite index for common filtering patterns
|
||||
models.Index(
|
||||
fields=["published", "featured"], name="roster_pub_feat_idx"
|
||||
),
|
||||
# Composite index for manufacturer+item_number lookups
|
||||
models.Index(
|
||||
fields=["manufacturer", "item_number_slug"],
|
||||
name="roster_mfr_item_idx",
|
||||
),
|
||||
# Index for scale filtering
|
||||
models.Index(fields=["scale"], name="roster_scale_idx"),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return "{0} {1}".format(self.rolling_class, self.road_number)
|
||||
@@ -248,7 +282,7 @@ class RollingStockJournal(models.Model):
|
||||
class Meta:
|
||||
ordering = ["date", "rolling_stock"]
|
||||
|
||||
objects = PublicManager()
|
||||
objects = RollingStockManager()
|
||||
|
||||
|
||||
# @receiver(models.signals.post_delete, sender=Cab)
|
||||
|
||||
Reference in New Issue
Block a user