Compare commits

...

23 Commits

Author SHA1 Message Date
564416b3d5 Bump to v0.19.8 2026-01-05 15:46:35 +01:00
967ea5d495 Hide accordion in consists if no load 2026-01-05 15:45:52 +01:00
7656aa8b68 Simplify consist cards cover generator 2026-01-05 15:38:51 +01:00
1be102b9d4 Better 404 handling 2026-01-05 14:54:38 +01:00
4ec7b8fc18 Fix support for X-Accel-Redirect 2026-01-05 14:39:45 +01:00
9a469378df Add support for X-Accel-Redirect 2026-01-05 00:04:44 +01:00
ede8741473 Enforce file access permissions 2026-01-04 23:48:52 +01:00
49c8d804d6 Implement support for rolling stock load in consists 2026-01-03 14:18:46 +01:00
2ab2d00585 Improve ordering 2026-01-03 00:54:21 +01:00
c95064ddec More templates modularization 2026-01-02 23:19:18 +01:00
16bd82de39 Improve tables behavior on small screen (mobile) 2026-01-02 22:19:22 +01:00
2ae7f2685d Make documents UI modular 2026-01-02 19:12:00 +01:00
29f9a213b4 Make the TOC table responsive 2025-12-31 18:16:08 +01:00
884661d4e1 Enforce page number in TOC 2025-12-31 14:57:15 +01:00
c7cace96f7 Extend lenght of TOC items 2025-12-31 14:49:37 +01:00
d3c099c05b Extend search to toc titles 2025-12-30 22:21:43 +01:00
903633b5a7 Extend TOC to books 2025-12-30 22:15:29 +01:00
ee775d737e Make sure that cache is always cleaned while performing an update 2025-12-30 21:53:04 +01:00
8087ab5997 Implement TOC in UI 2025-12-30 00:44:06 +01:00
1899747909 Minor fix 2025-12-29 12:16:58 +01:00
0880bd0817 Initial implemntation of TOC for books et al. 2025-12-29 12:05:37 +01:00
74d7df2c8b Add an icon for fetured items 2025-12-29 11:44:54 +01:00
c81508bbd5 Fix a bug in featured count limit 2025-12-25 19:46:18 +01:00
35 changed files with 634 additions and 245 deletions

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

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

View File

@@ -22,6 +22,7 @@ from bookshelf.models import (
Catalog, Catalog,
Magazine, Magazine,
MagazineIssue, MagazineIssue,
TocEntry,
) )
@@ -58,20 +59,34 @@ class MagazineIssueDocInline(BookDocInline):
model = MagazineIssueDocument model = MagazineIssueDocument
class BookTocInline(admin.TabularInline):
model = TocEntry
min_num = 0
extra = 0
fields = (
"title",
"subtitle",
"authors",
"page",
"featured",
)
@admin.register(Book) @admin.register(Book)
class BookAdmin(SortableAdminBase, admin.ModelAdmin): class BookAdmin(SortableAdminBase, admin.ModelAdmin):
inlines = ( inlines = (
BookTocInline,
BookPropertyInline, BookPropertyInline,
BookImageInline, BookImageInline,
BookDocInline, BookDocInline,
) )
list_display = ( list_display = (
"published",
"title", "title",
"get_authors", "get_authors",
"get_publisher", "get_publisher",
"publication_year", "publication_year",
"number_of_pages", "number_of_pages",
"published",
) )
autocomplete_fields = ("authors", "publisher", "shop") autocomplete_fields = ("authors", "publisher", "shop")
readonly_fields = ("invoices", "creation_time", "updated_time") readonly_fields = ("invoices", "creation_time", "updated_time")
@@ -366,6 +381,7 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
@admin.register(MagazineIssue) @admin.register(MagazineIssue)
class MagazineIssueAdmin(SortableAdminBase, admin.ModelAdmin): class MagazineIssueAdmin(SortableAdminBase, admin.ModelAdmin):
inlines = ( inlines = (
BookTocInline,
BookPropertyInline, BookPropertyInline,
BookImageInline, BookImageInline,
MagazineIssueDocInline, MagazineIssueDocInline,

View File

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

View File

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

View File

@@ -191,6 +191,15 @@ class Magazine(BaseModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse("magazine", kwargs={"uuid": self.uuid}) return reverse("magazine", kwargs={"uuid": self.uuid})
def get_cover(self):
if self.image:
return self.image
else:
cover_issue = self.issue.filter(published=True).first()
if cover_issue and cover_issue.image.exists():
return cover_issue.image.first().image
return None
def website_short(self): def website_short(self):
if self.website: if self.website:
return urlparse(self.website).netloc.replace("www.", "") return urlparse(self.website).netloc.replace("www.", "")
@@ -239,3 +248,41 @@ class MagazineIssue(BaseBook):
return reverse( return reverse(
"issue", kwargs={"uuid": self.uuid, "magazine": self.magazine.uuid} "issue", kwargs={"uuid": self.uuid, "magazine": self.magazine.uuid}
) )
class TocEntry(BaseModel):
book = models.ForeignKey(
BaseBook, on_delete=models.CASCADE, related_name="toc"
)
title = models.CharField()
subtitle = models.CharField(blank=True)
authors = models.CharField(blank=True)
page = models.SmallIntegerField()
featured = models.BooleanField(
default=False,
)
class Meta:
ordering = ["page"]
verbose_name = "Table of Contents Entry"
verbose_name_plural = "Table of Contents Entries"
def __str__(self):
if self.subtitle:
title = f"{self.title}: {self.subtitle}"
else:
title = self.title
return f"{title} (p. {self.page})"
def clean(self):
if self.page is None:
raise ValidationError("Page number is required.")
if self.page < 1:
raise ValidationError("Page number is invalid.")
try:
if self.page > self.book.number_of_pages:
raise ValidationError(
"Page number exceeds the publication's number of pages."
)
except TypeError:
pass # number_of_pages is None

View File

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

View File

@@ -43,10 +43,10 @@ class Consist(BaseModel):
@property @property
def length(self): def length(self):
return self.consist_item.count() return self.consist_item.filter(load=False).count()
def get_type_count(self): def get_type_count(self):
return self.consist_item.annotate( return self.consist_item.filter(load=False).annotate(
type=models.F("rolling_stock__rolling_class__type__type") type=models.F("rolling_stock__rolling_class__type__type")
).values( ).values(
"type" "type"
@@ -56,6 +56,15 @@ class Consist(BaseModel):
order=models.Max("order"), order=models.Max("order"),
).order_by("order") ).order_by("order")
def get_cover(self):
if self.image:
return self.image
else:
consist_item = self.consist_item.first()
if consist_item and consist_item.rolling_stock.image.exists():
return consist_item.rolling_stock.image.first().image
return None
@property @property
def country(self): def country(self):
return self.company.country return self.company.country
@@ -69,6 +78,7 @@ class ConsistItem(models.Model):
Consist, on_delete=models.CASCADE, related_name="consist_item" Consist, on_delete=models.CASCADE, related_name="consist_item"
) )
rolling_stock = models.ForeignKey(RollingStock, on_delete=models.CASCADE) rolling_stock = models.ForeignKey(RollingStock, on_delete=models.CASCADE)
load = models.BooleanField(default=False)
order = models.PositiveIntegerField(blank=False, null=False) order = models.PositiveIntegerField(blank=False, null=False)
class Meta: class Meta:
@@ -92,10 +102,15 @@ class ConsistItem(models.Model):
# because the consist is not saved yet and it must be moved # because the consist is not saved yet and it must be moved
# to the admin form validation via InlineFormSet.clean() # to the admin form validation via InlineFormSet.clean()
consist = self.consist consist = self.consist
if rolling_stock.scale != consist.scale: # Scale must match, but allow loads of any scale
if rolling_stock.scale != consist.scale and not self.load:
raise ValidationError( raise ValidationError(
"The rolling stock and consist must be of the same scale." "The rolling stock and consist must be of the same scale."
) )
if self.load and rolling_stock.scale.ratio != consist.scale.ratio:
raise ValidationError(
"The load and consist must be of the same scale ratio."
)
if self.consist.published and not rolling_stock.published: if self.consist.published and not rolling_stock.published:
raise ValidationError( raise ValidationError(
"You must unpublish the the consist before using this item." "You must unpublish the the consist before using this item."

View File

@@ -20,6 +20,7 @@ class SiteConfigurationAdmin(SingletonModelAdmin):
"about", "about",
"items_per_page", "items_per_page",
"items_ordering", "items_ordering",
"featured_items_ordering",
"currency", "currency",
"footer", "footer",
"footer_extended", "footer_extended",

View File

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

View File

@@ -22,14 +22,17 @@ class SiteConfiguration(SingletonModel):
default="6", default="6",
) )
items_ordering = models.CharField( items_ordering = models.CharField(
max_length=10, max_length=11,
choices=[ choices=[
("type", "By rolling stock type"), ("type", "By rolling stock type and company"),
("company", "By company name"), ("class", "By rolling stock type and class"),
("identifier", "By rolling stock class"), ("company", "By company and type"),
("country", "By country and type"),
("cou+com", "By country and company"),
], ],
default="type", default="type",
) )
featured_items_ordering = items_ordering.clone()
currency = models.CharField(max_length=3, default="EUR") currency = models.CharField(max_length=3, default="EUR")
footer = tinymce.HTMLField(blank=True) footer = tinymce.HTMLField(blank=True)
footer_extended = tinymce.HTMLField(blank=True) footer_extended = tinymce.HTMLField(blank=True)

View File

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

View File

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

View File

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

View File

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

View File

@@ -148,7 +148,7 @@
<strong>{{ site_conf.site_name }}</strong> <strong>{{ site_conf.site_name }}</strong>
</a> </a>
</div> </div>
{% include 'includes/login.html' %} {% include '_includes/login.html' %}
</div> </div>
</nav> </nav>
</header> </header>
@@ -186,7 +186,7 @@
{% show_bookshelf_menu %} {% show_bookshelf_menu %}
{% show_flatpages_menu user %} {% show_flatpages_menu user %}
</ul> </ul>
{% include 'includes/search.html' %} {% include '_includes/search.html' %}
</div> </div>
</div> </div>
</nav> </nav>
@@ -211,9 +211,9 @@
<div class="container">{% block pagination %}{% endblock %}</div> <div class="container">{% block pagination %}{% endblock %}</div>
</div> </div>
{% block extra_content %}{% endblock %} {% block extra_content %}{% endblock %}
{% include 'includes/symbols.html' %} {% include '_includes/symbols.html' %}
</main> </main>
{% include 'includes/footer.html' %} {% include '_includes/footer.html' %}
{% if site_conf.use_cdn %} {% if site_conf.use_cdn %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"></script>
{% else %} {% else %}

View File

@@ -49,10 +49,12 @@
<div class="mx-auto"> <div class="mx-auto">
<nav class="nav nav-tabs d-none d-lg-flex flex-row mb-2" id="nav-tab" role="tablist"> <nav class="nav nav-tabs d-none d-lg-flex flex-row mb-2" id="nav-tab" role="tablist">
<button class="nav-link active" id="nav-summary-tab" data-bs-toggle="tab" data-bs-target="#nav-summary" type="button" role="tab" aria-controls="nav-summary" aria-selected="true">Summary</button> <button class="nav-link active" id="nav-summary-tab" data-bs-toggle="tab" data-bs-target="#nav-summary" type="button" role="tab" aria-controls="nav-summary" aria-selected="true">Summary</button>
{% if data.toc.all %}<button class="nav-link" id="nav-toc-tab" data-bs-toggle="tab" data-bs-target="#nav-toc" type="button" role="tab" aria-controls="nav-toc" aria-selected="true">Table of contents</button>{% endif %}
{% if documents %}<button class="nav-link" id="nav-documents-tab" data-bs-toggle="tab" data-bs-target="#nav-documents" type="button" role="tab" aria-controls="nav-documents" aria-selected="false">Documents</button>{% endif %} {% if documents %}<button class="nav-link" id="nav-documents-tab" data-bs-toggle="tab" data-bs-target="#nav-documents" type="button" role="tab" aria-controls="nav-documents" aria-selected="false">Documents</button>{% endif %}
</nav> </nav>
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector"> <select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
<option value="nav-summary" selected>Summary</option> <option value="nav-summary" selected>Summary</option>
{% if data.toc.all %}<option value="nav-toc">Table of contents</option>{% endif %}
{% if documents %}<option value="nav-documents">Documents</option>{% endif %} {% if documents %}<option value="nav-documents">Documents</option>{% endif %}
</select> </select>
<div class="tab-content" id="nav-tabContent"> <div class="tab-content" id="nav-tabContent">
@@ -145,67 +147,35 @@
{% endif %} {% endif %}
</tbody> </tbody>
</table> </table>
{% if request.user.is_staff %} {% include "_modules/purchase_data.html" %}
{% include "_modules/properties.html" %}
</div>
<div class="tab-pane table-responsive" id="nav-toc" role="tabpanel" aria-labelledby="nav-toc-tab">
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th colspan="2" scope="row">Purchase</th> <th scope="row">Title</th>
<th scope="row">Subtitle</th>
<th scope="row">Authors</th>
<th scope="row">Page</th>
<th scope="row"><abbr title="Featured article"><i class="bi bi-star-fill"></i></abbr></th>
</tr> </tr>
</thead> </thead>
<tbody class="table-group-divider"> <tbody class="table-group-divider">
{% for toc in data.toc.all %}
<tr> <tr>
<th class="w-33" scope="row">Shop</th> <td class="w-33">{{ toc.title }}</td>
<td> <td class="w-33">{{ toc.subtitle }}</td>
{{ data.shop|default:"-" }} <td>{{ toc.authors }}</td>
{% if data.shop.website %} <a href="{{ data.shop.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %} <td>{{ toc.page }}</td>
</td> <td>{% if toc.featured %}<abbr title="Featured article"><i class="bi bi-star-fill text-warning"></i></abbr>{% endif %}</td>
</tr>
<tr>
<th class="w-33" scope="row">Purchase date</th>
<td>{{ data.purchase_date|default:"-" }}</td>
</tr>
<tr>
<th scope="row">Price ({{ site_conf.currency }})</th>
<td>{{ data.price|default:"-" }}</td>
</tr> </tr>
{% endfor %}
</tbody> </tbody>
</table> </table>
{% endif %}
{% if properties %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Properties</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for p in properties %}
<tr>
<th class="w-33" scope="row">{{ p.property }}</th>
<td>{{ p.value }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div> </div>
<div class="tab-pane" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab"> <div class="tab-pane" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
<table class="table table-striped"> {% include "_modules/documents.html" %}
<thead>
<tr>
<th colspan="3" scope="row">Documents</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for d in documents.all %}
<tr>
<td class="w-33">{{ d.description }}</td>
<td><a href="{{ d.file.url }}" target="_blank">{{ d.filename }}</a></td>
<td class="text-end">{{ d.file.size | filesizeformat }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
</div> </div>
<div class="d-grid gap-2 d-md-flex justify-content-md-end"> <div class="d-grid gap-2 d-md-flex justify-content-md-end">

View File

@@ -1,12 +1,13 @@
{% load static %}
<div class="col"> <div class="col">
<div class="card shadow-sm"> <div class="card shadow-sm">
<a href="{{ d.get_absolute_url }}"> <a href="{{ d.get_absolute_url }}">
{% if d.image %} {% if d.get_cover %}
<img class="card-img-top" src="{{ d.image.url }}" alt="{{ d }}"> <img class="card-img-top" src="{{ d.get_cover.url }}" alt="{{ d }}">
{% else %} {% else %}
{% with d.consist_item.first.rolling_stock as r %} <!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
<img class="card-img-top" src="{{ r.image.first.image.url }}" alt="{{ d }}"> <a href="{{d.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d }}"></a>
{% endwith %}
{% endif %} {% endif %}
</a> </a>
<div class="card-body"> <div class="card-body">

View File

@@ -4,12 +4,8 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
{% if d.obj_type == "magazine" %} {% if d.obj_type == "magazine" %}
<a href="{{ d.get_absolute_url }}"> <a href="{{ d.get_absolute_url }}">
{% if d.image and d.obj_type == "magazine" %} {% if d.get_cover %}
<img class="card-img-top" src="{{ d.image.url }}" alt="{{ d }}"> <img class="card-img-top" src="{{ d.get_cover.url }}" alt="{{ d }}">
{% elif d.issue.first.image.exists %}
{% with d.issue.first as i %}
<img class="card-img-top" src="{{ i.image.first.image.url }}" alt="{{ d }}">
{% endwith %}
{% else %} {% else %}
<!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) --> <!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
<img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d }}"> <img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d }}">

View File

@@ -3,12 +3,19 @@
<div class="col"> <div class="col">
<div class="card shadow-sm"> <div class="card shadow-sm">
{% if d.image.exists %} <div id="card-img-container" class="position-relative">
<a href="{{d.get_absolute_url}}"><img class="card-img-top" src="{{ d.image.first.image.url }}" alt="{{ d }}"></a> {% if d.featured %}
{% else %} <span class="position-absolute translate-middle top-0 start-0 m-3 text-danger">
<!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) --> <abbr title="Featured item"><i class="bi bi-heart-fill"></i></abbr>
<a href="{{d.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d }}"></a> </span>
{% endif %} {% endif %}
{% if d.image.exists %}
<a href="{{d.get_absolute_url}}"><img class="card-img-top" src="{{ d.image.first.image.url }}" alt="{{ d }}"></a>
{% else %}
<!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
<a href="{{d.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d }}"></a>
{% endif %}
</div>
<div class="card-body"> <div class="card-body">
<p class="card-text" style="position: relative;"> <p class="card-text" style="position: relative;">
<strong>{{ d }}</strong> <strong>{{ d }}</strong>

View File

@@ -7,11 +7,11 @@
{{ t.name }}</a>{# new line is required #} {{ t.name }}</a>{# new line is required #}
{% endfor %} {% endfor %}
</p> </p>
{% endif %}
{% if not consist.published %} {% if not consist.published %}
<span class="badge text-bg-warning">Unpublished</span> | <span class="badge text-bg-warning">Unpublished</span> |
{% endif %} {% endif %}
<small class="text-body-secondary">Updated {{ consist.updated_time | date:"M d, Y H:i" }}</small> <small class="text-body-secondary">Updated {{ consist.updated_time | date:"M d, Y H:i" }}</small>
{% endif %}
{% endblock %} {% endblock %}
{% block carousel %} {% block carousel %}
{% if consist.image %} {% if consist.image %}
@@ -26,6 +26,35 @@
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block cards_layout %}
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
{% block cards %}
{% for d in data %}
{% include "cards/roster.html" %}
{% endfor %}
{% endblock %}
</div>
{% if loads %}
<div class="accordion shadow-sm mt-4" id="accordionLoads">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseLoads" aria-expanded="false" aria-controls="collapseLoads">
<i class="bi bi-download"></i>&nbsp;Rolling Stock loaded on freight cars
</button>
</h2>
<div id="collapseLoads" class="accordion-collapse collapse" data-bs-parent="#accordionLoads">
<div class="accordion-body">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
{% for l in loads %}
{% include "cards/roster.html" with d=l %}
{% endfor %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block pagination %} {% block pagination %}
{% if data.has_other_pages %} {% if data.has_other_pages %}
<nav aria-label="Page navigation"> <nav aria-label="Page navigation">
@@ -76,7 +105,7 @@
<option value="nav-summary" selected>Summary</option> <option value="nav-summary" selected>Summary</option>
</select> </select>
<div class="tab-content" id="nav-tabContent"> <div class="tab-content" id="nav-tabContent">
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab"> <div class="tab-pane show active table-responsive" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
@@ -113,7 +142,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row">Composition</th> <th scope="row">Composition</th>
<td>{% for t in consist.get_type_count %}{{ t.count }}x {{ t.type }} {{t.category }}{% if not forloop.last %} &raquo; {% endif %}{% endfor %}</td> <td>{% for t in consist.get_type_count %}{{ t.count }}x {{ t.type }} {{t.category }}{% if not forloop.last %} &raquo; {% endif %}{% endfor %}{% if loads %} | <i class="bi bi-download"></i> {{ loads|length }}x Load{{ loads|pluralize }}{% endif %}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -217,49 +217,8 @@
{% endif %} {% endif %}
</tbody> </tbody>
</table> </table>
{% if request.user.is_staff %} {% include "_modules/purchase_data.html" with data=rolling_stock %}
<table class="table table-striped"> {% include "_modules/properties.html" %}
<thead>
<tr>
<th colspan="2" scope="row">Purchase</th>
</tr>
</thead>
<tbody class="table-group-divider">
<tr>
<th class="w-33" scope="row">Shop</th>
<td>
{{ rolling_stock.shop | default:"-" }}
{% if rolling_stock.shop.website %} <a href="{{ rolling_stock.shop.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
</td>
</tr>
<tr>
<th class="w-33" scope="row">Purchase date</th>
<td>{{ rolling_stock.purchase_date | default:"-" }}</td>
</tr>
<tr>
<th scope="row">Price ({{ site_conf.currency }})</th>
<td>{{ rolling_stock.price | default:"-" }}</td>
</tr>
</tbody>
</table>
{% endif %}
{% if properties %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Properties</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for p in properties %}
<tr>
<th class="w-33" scope="row">{{ p.property }}</th>
<td>{{ p.value }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div> </div>
<div class="tab-pane" id="nav-class" role="tabpanel" aria-labelledby="nav-class-tab"> <div class="tab-pane" id="nav-class" role="tabpanel" aria-labelledby="nav-class-tab">
<table class="table table-striped"> <table class="table table-striped">
@@ -296,23 +255,7 @@
{% endif %} {% endif %}
</tbody> </tbody>
</table> </table>
{% if class_properties %} {% include "_modules/properties.html" with properties=class_properties %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Properties</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for p in class_properties %}
<tr>
<th class="w-33" scope="row">{{ p.property }}</th>
<td>{{ p.value }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div> </div>
<div class="tab-pane" id="nav-company" role="tabpanel" aria-labelledby="nav-company-tab"> <div class="tab-pane" id="nav-company" role="tabpanel" aria-labelledby="nav-company-tab">
<table class="table table-striped"> <table class="table table-striped">
@@ -402,43 +345,9 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="tab-pane" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab"> <div class="tab-pane table-responsive" id="nav-documents" role="tabpanel" aria-labelledby="nav-documents-tab">
{% if documents %} {% include "_modules/documents.html" %}
<table class="table table-striped"> {% include "_modules/documents.html" with documents=decoder_documents header="Decoder documents" %}
<thead>
<tr>
<th colspan="3" scope="row">Documents</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for d in documents.all %}
<tr>
<td class="w-33">{{ d.description }}</td>
<td><a href="{{ d.file.url }}" target="_blank">{{ d.filename }}</a></td>
<td class="text-end">{{ d.file.size | filesizeformat }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if decoder_documents %}
<table class="table table-striped">
<thead>
<tr>
<th colspan="3" scope="row">Decoder documents</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% for d in decoder_documents.all %}
<tr>
<td class="w-33">{{ d.description }}</td>
<td><a href="{{ d.file.url }}" target="_blank">{{ d.filename }}</a></td>
<td class="text-end">{{ d.file.size | filesizeformat }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div> </div>
<div class="tab-pane" id="nav-journal" role="tabpanel" aria-labelledby="nav-journal-tab"> <div class="tab-pane" id="nav-journal" role="tabpanel" aria-labelledby="nav-journal-tab">
<table class="table table-striped"> <table class="table table-striped">

View File

@@ -6,6 +6,7 @@ from urllib.parse import unquote
from django.conf import settings from django.conf import settings
from django.views import View from django.views import View
from django.urls import Resolver404
from django.http import Http404, HttpResponseBadRequest from django.http import Http404, HttpResponseBadRequest
from django.db.utils import OperationalError, ProgrammingError from django.db.utils import OperationalError, ProgrammingError
from django.db.models import F, Q, Count from django.db.models import F, Q, Count
@@ -36,30 +37,45 @@ def get_items_per_page():
return int(items_per_page) return int(items_per_page)
def get_order_by_field(): def get_items_ordering(config="items_ordering"):
try: try:
order_by = get_site_conf().items_ordering order_by = getattr(get_site_conf(), config)
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError):
order_by = "type" order_by = "type"
fields = [ fields = [
"rolling_class__type", "rolling_class__type", # 0
"rolling_class__company", "rolling_class__company", # 1
"rolling_class__identifier", "rolling_class__company__country", # 2
"road_number_int", "rolling_class__identifier", # 3
"road_number_int", # 4
] ]
if order_by == "type": order_map = {
return (fields[0], fields[1], fields[2], fields[3]) "type": (0, 1, 3, 4),
elif order_by == "company": "company": (1, 0, 3, 4),
return (fields[1], fields[0], fields[2], fields[3]) "country": (2, 0, 1, 3, 4),
elif order_by == "identifier": "cou+com": (2, 1, 0, 3, 4),
return (fields[2], fields[0], fields[1], fields[3]) "class": (0, 3, 1, 4),
}
return tuple(fields[i] for i in order_map.get(order_by, "type"))
class Render404(View): class Render404(View):
def get(self, request, exception): def get(self, request, exception):
return render(request, "base.html", {"title": "404 page not found"}) generic_message = "Page not found"
if isinstance(exception, Resolver404):
message = generic_message
else:
message = str(exception) if exception else generic_message
return render(
request,
"base.html",
{"title": message},
status=404,
)
class GetData(View): class GetData(View):
@@ -70,7 +86,7 @@ class GetData(View):
def get_data(self, request): def get_data(self, request):
return ( return (
RollingStock.objects.get_published(request.user) RollingStock.objects.get_published(request.user)
.order_by(*get_order_by_field()) .order_by(*get_items_ordering())
.filter(self.filter) .filter(self.filter)
) )
@@ -107,7 +123,9 @@ class GetHome(GetData):
return ( return (
RollingStock.objects.get_published(request.user) RollingStock.objects.get_published(request.user)
.filter(featured=True) .filter(featured=True)
.order_by(*get_order_by_field())[:max_items] .order_by(*get_items_ordering(config="featured_items_ordering"))[
:max_items
]
) or super().get_data(request) ) or super().get_data(request)
@@ -174,7 +192,7 @@ class SearchObjects(View):
RollingStock.objects.get_published(request.user) RollingStock.objects.get_published(request.user)
.filter(query) .filter(query)
.distinct() .distinct()
.order_by(*get_order_by_field()) .order_by(*get_items_ordering())
) )
data = list(roster) data = list(roster)
@@ -196,6 +214,7 @@ class SearchObjects(View):
Q( Q(
Q(title__icontains=search) Q(title__icontains=search)
| Q(description__icontains=search) | Q(description__icontains=search)
| Q(toc__title__icontains=search)
) )
) )
.distinct() .distinct()
@@ -217,6 +236,7 @@ class SearchObjects(View):
Q( Q(
Q(magazine__name__icontains=search) Q(magazine__name__icontains=search)
| Q(description__icontains=search) | Q(description__icontains=search)
| Q(toc__title__icontains=search)
) )
) )
.distinct() .distinct()
@@ -299,7 +319,7 @@ class GetManufacturerItem(View):
if search != "all": if search != "all":
roster = get_list_or_404( roster = get_list_or_404(
RollingStock.objects.get_published(request.user).order_by( RollingStock.objects.get_published(request.user).order_by(
*get_order_by_field() *get_items_ordering()
), ),
Q( Q(
Q(manufacturer=manufacturer) Q(manufacturer=manufacturer)
@@ -321,7 +341,7 @@ class GetManufacturerItem(View):
| Q(rolling_class__manufacturer=manufacturer) | Q(rolling_class__manufacturer=manufacturer)
) )
.distinct() .distinct()
.order_by(*get_order_by_field()) .order_by(*get_items_ordering())
) )
catalogs = Catalog.objects.get_published(request.user).filter( catalogs = Catalog.objects.get_published(request.user).filter(
manufacturer=manufacturer manufacturer=manufacturer
@@ -374,7 +394,7 @@ class GetObjectsFiltered(View):
RollingStock.objects.get_published(request.user) RollingStock.objects.get_published(request.user)
.filter(query) .filter(query)
.distinct() .distinct()
.order_by(*get_order_by_field()) .order_by(*get_items_ordering())
) )
data = list(roster) data = list(roster)
@@ -478,7 +498,7 @@ class GetRollingStock(View):
& Q(set=True) & Q(set=True)
) )
) )
.order_by(*get_order_by_field()) .order_by(*get_items_ordering())
) )
return render( return render(
@@ -518,7 +538,13 @@ class GetConsist(View):
RollingStock.objects.get_published(request.user).get( RollingStock.objects.get_published(request.user).get(
uuid=r.rolling_stock_id uuid=r.rolling_stock_id
) )
for r in consist.consist_item.all() for r in consist.consist_item.filter(load=False)
)
loads = list(
RollingStock.objects.get_published(request.user).get(
uuid=r.rolling_stock_id
)
for r in consist.consist_item.filter(load=True)
) )
paginator = Paginator(data, get_items_per_page()) paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page) data = paginator.get_page(page)
@@ -533,6 +559,7 @@ class GetConsist(View):
"title": consist, "title": consist,
"consist": consist, "consist": consist,
"data": data, "data": data,
"loads": loads,
"page_range": page_range, "page_range": page_range,
}, },
) )

View File

@@ -1,4 +1,4 @@
from ram.utils import git_suffix from ram.utils import git_suffix
__version__ = "0.18.9" __version__ = "0.19.8"
__version__ += git_suffix(__file__) __version__ += git_suffix(__file__)

View File

@@ -1,22 +1,60 @@
from django.contrib import admin
from django.conf import settings from django.conf import settings
from django.contrib import admin
from django.core.cache import cache
admin.site.site_header = settings.SITE_NAME admin.site.site_header = settings.SITE_NAME
def publish(modeladmin, request, queryset): def publish(modeladmin, request, queryset):
for obj in queryset: queryset.update(published=True)
obj.published = True cache.clear()
obj.save()
publish.short_description = "Publish selected items" publish.short_description = "Publish selected items"
def unpublish(modeladmin, request, queryset): def unpublish(modeladmin, request, queryset):
for obj in queryset: queryset.update(published=False)
obj.published = False cache.clear()
obj.save()
unpublish.short_description = "Unpublish selected items" unpublish.short_description = "Unpublish selected items"
def set_featured(modeladmin, request, queryset):
count = queryset.count()
if count > settings.FEATURED_ITEMS_MAX:
modeladmin.message_user(
request,
"You can only mark up to {} items as featured.".format(
settings.FEATURED_ITEMS_MAX
),
level="error",
)
return
featured = modeladmin.model.objects.filter(featured=True).count()
if featured + count > settings.FEATURED_ITEMS_MAX:
modeladmin.message_user(
request,
"There are already {} featured items. You can only mark {} more items as featured.".format( # noqa: E501
featured,
settings.FEATURED_ITEMS_MAX - featured,
),
level="error",
)
return
queryset.update(featured=True)
cache.clear()
set_featured.short_description = "Mark selected items as featured"
def unset_featured(modeladmin, request, queryset):
queryset.update(featured=False)
cache.clear()
unset_featured.short_description = (
"Unmark selected items as featured"
)

View File

@@ -34,3 +34,4 @@ ALLOWED_HOSTS = ["127.0.0.1", "myhost"]
CSRF_TRUSTED_ORIGINS = ["https://myhost"] CSRF_TRUSTED_ORIGINS = ["https://myhost"]
STATIC_URL = "static/" STATIC_URL = "static/"
MEDIA_URL = "media/" MEDIA_URL = "media/"
USE_X_ACCEL_REDIRECT = True

View File

@@ -206,6 +206,19 @@ ROLLING_STOCK_TYPES = [
FEATURED_ITEMS_MAX = 6 FEATURED_ITEMS_MAX = 6
# If True, use X-Accel-Redirect (Nginx)
# when using X-Accel-Redirect, we don't serve the file
# directly from Django, but let Nginx handle it
# in Nginx config, we need to map /private/ to
# the actual media files location with internal directive
# eg:
# location /private {
# internal;
# alias /path/to/media;
# }
# make also sure that the entire /media is _not_ mapped directly in Nginx
USE_X_ACCEL_REDIRECT = False
try: try:
from ram.local_settings import * from ram.local_settings import *
except ImportError: except ImportError:

View File

@@ -21,17 +21,22 @@ from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from ram.views import UploadImage from ram.views import UploadImage, DownloadFile
from portal.views import Render404 from portal.views import Render404
handler404 = Render404.as_view() handler404 = Render404.as_view()
urlpatterns = [ urlpatterns = [
path("", lambda r: redirect("portal/")), path("", lambda r: redirect("portal/")),
path("admin/", admin.site.urls),
path("tinymce/", include("tinymce.urls")), path("tinymce/", include("tinymce.urls")),
path("tinymce/upload_image", UploadImage.as_view(), name="upload_image"), path("tinymce/upload_image", UploadImage.as_view(), name="upload_image"),
path(
"media/files/<path:filename>",
DownloadFile.as_view(),
name="download_file",
),
path("portal/", include("portal.urls")), path("portal/", include("portal.urls")),
path("admin/", admin.site.urls),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# Enable the "/dcc" routing only if the "driver" app is active # Enable the "/dcc" routing only if the "driver" app is active
@@ -55,6 +60,7 @@ if settings.DEBUG:
if settings.REST_ENABLED: if settings.REST_ENABLED:
from django.views.generic import TemplateView from django.views.generic import TemplateView
from rest_framework.schemas import get_schema_view from rest_framework.schemas import get_schema_view
urlpatterns += [ urlpatterns += [
path( path(
"swagger/", "swagger/",

View File

@@ -5,19 +5,26 @@ import posixpath
from pathlib import Path from pathlib import Path
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from django.views import View from django.apps import apps
from django.conf import settings from django.conf import settings
from django.http import ( from django.http import (
Http404,
HttpResponse,
HttpResponseBadRequest, HttpResponseBadRequest,
HttpResponseForbidden, HttpResponseForbidden,
FileResponse,
JsonResponse, JsonResponse,
) )
from django.views import View
from django.utils.text import slugify as slugify from django.utils.text import slugify as slugify
from django.utils.encoding import smart_str
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from rest_framework.pagination import LimitOffsetPagination from rest_framework.pagination import LimitOffsetPagination
from ram.models import PrivateDocument
class CustomLimitOffsetPagination(LimitOffsetPagination): class CustomLimitOffsetPagination(LimitOffsetPagination):
default_limit = 10 default_limit = 10
@@ -67,3 +74,50 @@ class UploadImage(View):
), ),
} }
) )
class DownloadFile(View):
def get(self, request, filename, disposition="inline"):
# Clean up the filename to prevent directory traversal attacks
filename = os.path.basename(filename)
# Find a document where the stored file name matches
# 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:
break
file = doc.file
if not os.path.exists(file.path):
break
# in Nginx config, we need to map /private/ to
# the actual media files location with internal directive
# eg:
# location /private {
# internal;
# alias /path/to/media;
# }
if getattr(settings, "USE_X_ACCEL_REDIRECT", False):
response = HttpResponse()
response["Content-Type"] = ""
response["X-Accel-Redirect"] = f"/private/{file.name}"
else:
response = FileResponse(
open(file.path, "rb"), as_attachment=True
)
response["Content-Disposition"] = (
'{}; filename="{}"'.format(
disposition,
smart_str(os.path.basename(file.path))
)
)
return response
except model.DoesNotExist:
continue
raise Http404("File not found")

View File

@@ -6,8 +6,8 @@ from django.utils.html import format_html, format_html_join, strip_tags
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
from ram.admin import publish, unpublish
from ram.utils import generate_csv from ram.utils import generate_csv
from ram.admin import publish, unpublish, set_featured, unset_featured
from repository.models import RollingStockDocument from repository.models import RollingStockDocument
from portal.utils import get_site_conf from portal.utils import get_site_conf
from roster.models import ( from roster.models import (
@@ -303,37 +303,4 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
download_csv.short_description = "Download selected items as CSV" download_csv.short_description = "Download selected items as CSV"
def set_featured(modeladmin, request, queryset):
count = queryset.count()
if count > settings.FEATURED_ITEMS_MAX:
modeladmin.message_user(
request,
"You can only mark up to {} items as featured.".format(
settings.FEATURED_ITEMS_MAX
),
level="error",
)
return
featured = RollingStock.objects.filter(featured=True).count()
if featured + count > settings.FEATURED_ITEMS_MAX:
modeladmin.message_user(
request,
"There are already {} featured items. You can only mark {} more items as featured.".format( # noqa: E501
featured,
settings.FEATURED_ITEMS_MAX - featured,
),
level="error",
)
return
queryset.update(featured=True)
set_featured.short_description = "Mark selected rolling stock as featured"
def unset_featured(modeladmin, request, queryset):
queryset.update(featured=False)
unset_featured.short_description = (
"Unmark selected rolling stock as featured"
)
actions = [publish, unpublish, set_featured, unset_featured, download_csv] actions = [publish, unpublish, set_featured, unset_featured, download_csv]

View File

@@ -175,7 +175,12 @@ class RollingStock(BaseModel):
def clean(self, *args, **kwargs): def clean(self, *args, **kwargs):
if self.featured: if self.featured:
MAX = settings.FEATURED_ITEMS_MAX MAX = settings.FEATURED_ITEMS_MAX
if RollingStock.objects.filter(featured=True).count() > MAX - 1: featured_count = (
RollingStock.objects.filter(featured=True)
.exclude(uuid=self.uuid)
.count()
)
if featured_count > MAX - 1:
raise ValidationError( raise ValidationError(
"There are already {} featured items".format(MAX) "There are already {} featured items".format(MAX)
) )