Files
django-ram/ram/portal/views.py
Daniele Viganò 676418cb67 Code refactoring to simplify template data contexts (#55)
* Fix a search filter when no catalogs are returned
* Code refactoring to simplify templates
* Remove duplicated code
* Remove dead code
* More improvements, clean up and add featured items in homepage
* Fix a type and better page navigation
2025-12-24 15:38:07 +01:00

826 lines
26 KiB
Python

import base64
import operator
from itertools import chain
from functools import reduce
from urllib.parse import unquote
from django.conf import settings
from django.views import View
from django.http import Http404, HttpResponseBadRequest
from django.db.utils import OperationalError, ProgrammingError
from django.db.models import F, Q, Count
from django.db.models.functions import Lower
from django.shortcuts import render, get_object_or_404, get_list_or_404
from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator
from portal.utils import get_site_conf
from portal.models import Flatpage
from roster.models import RollingStock
from consist.models import Consist
from bookshelf.models import Book, Catalog, Magazine, MagazineIssue
from metadata.models import (
Company,
Manufacturer,
Scale,
RollingStockType,
Tag,
)
def get_items_per_page():
try:
items_per_page = get_site_conf().items_per_page
except (OperationalError, ProgrammingError):
items_per_page = 6
return int(items_per_page)
def get_order_by_field():
try:
order_by = get_site_conf().items_ordering
except (OperationalError, ProgrammingError):
order_by = "type"
fields = [
"rolling_class__type",
"rolling_class__company",
"rolling_class__identifier",
"road_number_int",
]
if order_by == "type":
return (fields[0], fields[1], fields[2], fields[3])
elif order_by == "company":
return (fields[1], fields[0], fields[2], fields[3])
elif order_by == "identifier":
return (fields[2], fields[0], fields[1], fields[3])
class Render404(View):
def get(self, request, exception):
return render(request, "base.html", {"title": "404 page not found"})
class GetData(View):
title = None
template = "pagination.html"
filter = Q() # empty filter by default
def get_data(self, request):
return (
RollingStock.objects.get_published(request.user)
.order_by(*get_order_by_field())
.filter(self.filter)
)
def get(self, request, page=1):
if self.title is None or self.template is None:
raise Exception("title and template must be defined")
data = list(self.get_data(request))
paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page)
page_range = paginator.get_elided_page_range(
data.number, on_each_side=1, on_ends=1
)
return render(
request,
self.template,
{
"title": self.title,
"data": data,
"matches": paginator.count,
"page_range": page_range,
},
)
class GetHome(GetData):
title = "Home"
template = "home.html"
def get_data(self, request):
max_items = min(settings.FEATURED_ITEMS_MAX, get_items_per_page())
return (
RollingStock.objects.get_published(request.user)
.filter(featured=True)
.order_by(*get_order_by_field())[:max_items]
) or super().get_data(request)
class GetRoster(GetData):
title = "The Roster"
class SearchObjects(View):
def run_search(self, request, search, _filter, page=1):
"""
Run the search query on the database and return the results.
param request: HTTP request
param search: search string
param _filter: filter to apply (type, company, manufacturer, scale)
param page: page number for pagination
return: tuple (data, matches, page_range)
1. data: list of dicts with keys "type" and "item"
2. matches: total number of matches
3. page_range: elided page range for pagination
"""
if _filter is None:
query = reduce(
operator.or_,
(
Q(
Q(rolling_class__identifier__icontains=s)
| Q(rolling_class__description__icontains=s)
| Q(rolling_class__type__type__icontains=s)
| Q(road_number__icontains=s)
| Q(item_number=s)
| Q(rolling_class__company__name__icontains=s)
| Q(rolling_class__company__country__icontains=s)
| Q(manufacturer__name__icontains=s)
| Q(scale__scale__icontains=s)
| Q(tags__name__icontains=s)
)
for s in search.split()
),
)
elif _filter == "type":
query = Q(
Q(rolling_class__type__type__icontains=search)
| Q(rolling_class__type__category__icontains=search)
)
elif _filter == "company":
query = Q(
Q(rolling_class__company__name__icontains=search)
| Q(rolling_class__company__extended_name__icontains=search)
)
elif _filter == "manufacturer":
query = Q(
Q(manufacturer__name__icontains=search)
| Q(rolling_class__manufacturer__name__icontains=search)
)
elif _filter == "scale":
query = Q(scale__scale__icontains=search)
else:
raise Http404
# FIXME duplicated code!
# FIXME see if it makes sense to filter calatogs and books by scale
# and manufacturer as well
roster = (
RollingStock.objects.get_published(request.user)
.filter(query)
.distinct()
.order_by(*get_order_by_field())
)
data = list(roster)
if _filter is None:
consists = (
Consist.objects.get_published(request.user)
.filter(
Q(
Q(identifier__icontains=search)
| Q(company__name__icontains=search)
)
)
.distinct()
)
data = list(chain(data, consists))
books = (
Book.objects.get_published(request.user)
.filter(
Q(
Q(title__icontains=search)
| Q(description__icontains=search)
)
)
.distinct()
)
catalogs = (
Catalog.objects.get_published(request.user)
.filter(
Q(
Q(manufacturer__name__icontains=search)
| Q(description__icontains=search)
)
)
.distinct()
)
data = list(chain(data, books, catalogs))
magazine_issues = (
MagazineIssue.objects.get_published(request.user)
.filter(
Q(
Q(magazine__name__icontains=search)
| Q(description__icontains=search)
)
)
.distinct()
)
data = list(chain(data, magazine_issues))
paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page)
page_range = paginator.get_elided_page_range(
data.number, on_each_side=1, on_ends=1
)
return data, paginator.count, page_range
def split_search(self, search):
search = search.strip().split(":")
if not search:
raise Http404
elif len(search) == 1: # no filter
_filter = None
search = search[0].strip()
elif len(search) == 2: # filter: search
_filter = search[0].strip().lower()
search = search[1].strip()
else:
return HttpResponseBadRequest
return _filter, search
def get(self, request, search, page=1):
try:
encoded_search = search
search = base64.b64decode(search.encode()).decode()
except Exception:
encoded_search = base64.b64encode(search.encode()).decode()
_filter, keyword = self.split_search(search)
data, matches, page_range = self.run_search(
request, keyword, _filter, page
)
return render(
request,
"search.html",
{
"title": 'Search: "{}"'.format(search),
"search": search,
"encoded_search": encoded_search,
"data": data,
"matches": matches,
"page_range": page_range,
},
)
def post(self, request, page=1):
search = request.POST.get("search")
return self.get(request, search, page)
class GetManufacturerItem(View):
def get(self, request, manufacturer, search="all", page=1):
"""
Get all items from a specific manufacturer. If `search` is not "all",
filter by item number as well, for example to get all itmes from the
same set.
The view returns both rolling stock and catalogs.
param request: HTTP request
param manufacturer: Manufacturer slug
param search: item number slug or "all"
param page: page number for pagination
return: rendered template
1. manufacturer: Manufacturer object
2. search: item number slug or "all"
3. data: list of dicts with keys "type" and "item"
4. matches: total number of matches
5. page_range: elided page range for pagination
"""
manufacturer = get_object_or_404(
Manufacturer, slug__iexact=manufacturer
)
if search != "all":
roster = get_list_or_404(
RollingStock.objects.get_published(request.user).order_by(
*get_order_by_field()
),
Q(
Q(manufacturer=manufacturer)
& Q(item_number_slug__exact=search)
),
)
catalogs = [] # no catalogs when searching for a specific item
title = "{0}: {1}".format(
manufacturer,
# all returned records must have the same `item_number``;
# just pick it up the first result, otherwise `search`
roster[0].item_number if roster else search,
)
else:
roster = (
RollingStock.objects.get_published(request.user)
.filter(
Q(manufacturer=manufacturer)
| Q(rolling_class__manufacturer=manufacturer)
)
.distinct()
.order_by(*get_order_by_field())
)
catalogs = Catalog.objects.get_published(request.user).filter(
manufacturer=manufacturer
)
title = "Manufacturer: {0}".format(manufacturer)
data = list(chain(roster, catalogs))
paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page)
page_range = paginator.get_elided_page_range(
data.number, on_each_side=1, on_ends=1
)
return render(
request,
"manufacturer.html",
{
"title": title,
"manufacturer": manufacturer,
"search": search,
"data": data,
"matches": paginator.count,
"page_range": page_range,
},
)
class GetObjectsFiltered(View):
def run_filter(self, request, search, _filter, page=1):
if _filter == "type":
title = get_object_or_404(RollingStockType, slug__iexact=search)
query = Q(rolling_class__type__slug__iexact=search)
elif _filter == "company":
title = get_object_or_404(Company, slug__iexact=search)
query = Q(rolling_class__company__slug__iexact=search)
query_2nd = Q(company__slug__iexact=search)
elif _filter == "scale":
title = get_object_or_404(Scale, slug__iexact=search)
query = Q(scale__slug__iexact=search)
query_2nd = Q(
consist_item__rolling_stock__scale__slug__iexact=search
)
elif _filter == "tag":
title = get_object_or_404(Tag, slug__iexact=search)
query = Q(tags__slug__iexact=search)
query_2nd = query # For tags the 2nd level query doesn't change
else:
raise Http404
roster = (
RollingStock.objects.get_published(request.user)
.filter(query)
.distinct()
.order_by(*get_order_by_field())
)
data = list(roster)
if _filter == "scale":
catalogs = (
Catalog.objects.get_published(request.user)
.filter(scales__slug=search)
.distinct()
)
data = list(chain(data, catalogs))
try: # Execute only if query_2nd is defined
consists = (
Consist.objects.get_published(request.user)
.filter(query_2nd)
.distinct()
)
data = list(chain(data, consists))
if _filter == "tag": # Books can be filtered only by tag
books = (
Book.objects.get_published(request.user)
.filter(query_2nd)
.distinct()
)
catalogs = (
Catalog.objects.get_published(request.user)
.filter(query_2nd)
.distinct()
)
magazine_issues = (
MagazineIssue.objects.get_published(request.user)
.filter(query_2nd)
.distinct()
)
data = list(chain(data, books, catalogs, magazine_issues))
except NameError:
pass
paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page)
page_range = paginator.get_elided_page_range(
data.number, on_each_side=1, on_ends=1
)
return data, title, paginator.count, page_range
def get(self, request, search, _filter, page=1):
data, title, matches, page_range = self.run_filter(
request, unquote(search), _filter, page
)
return render(
request,
"filter.html",
{
"title": "{0}: {1}".format(_filter.capitalize(), title),
"search": search,
"filter": _filter,
"data": data,
"matches": matches,
"page_range": page_range,
},
)
class GetRollingStock(View):
def get(self, request, uuid):
try:
rolling_stock = RollingStock.objects.get_published(
request.user
).get(uuid=uuid)
except ObjectDoesNotExist:
raise Http404
# FIXME there's likely a better and more efficient way of doing this
# but keeping KISS for now
decoder_documents = []
class_properties = rolling_stock.rolling_class.property.get_public(
request.user
)
properties = rolling_stock.property.get_public(request.user)
documents = rolling_stock.document.get_public(request.user)
journal = rolling_stock.journal.get_public(request.user)
if rolling_stock.decoder:
decoder_documents = rolling_stock.decoder.document.get_public(
request.user
)
consists = list(
Consist.objects.get_published(request.user).filter(
consist_item__rolling_stock=rolling_stock
)
)
trainset = list(
RollingStock.objects.get_published(request.user)
.filter(
Q(
Q(item_number__exact=rolling_stock.item_number)
& Q(set=True)
)
)
.order_by(*get_order_by_field())
)
return render(
request,
"rollingstock.html",
{
"title": rolling_stock,
"rolling_stock": rolling_stock,
"class_properties": class_properties,
"properties": properties,
"decoder_documents": decoder_documents,
"documents": documents,
"journal": journal,
"set": trainset,
"consists": consists,
},
)
class Consists(GetData):
title = "Consists"
def get_data(self, request):
return Consist.objects.get_published(request.user).all()
class GetConsist(View):
def get(self, request, uuid, page=1):
try:
consist = Consist.objects.get_published(request.user).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.all()
)
paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page)
page_range = paginator.get_elided_page_range(
data.number, on_each_side=1, on_ends=1
)
return render(
request,
"consist.html",
{
"title": consist,
"consist": consist,
"data": data,
"page_range": page_range,
},
)
class Manufacturers(GetData):
title = "Manufacturers"
def get_data(self, request):
return (
Manufacturer.objects.filter(self.filter)
.annotate(
num_rollingstock=(
Count(
"rollingstock",
filter=Q(
rollingstock__in=(
RollingStock.objects.get_published(
request.user
)
)
),
distinct=True,
)
)
)
.annotate(
num_rollingclass=(
Count(
"rollingclass__rolling_stock",
filter=Q(
rollingclass__rolling_stock__in=(
RollingStock.objects.get_published(
request.user
)
),
),
distinct=True,
)
)
)
.annotate(
num_catalogs=(
Count(
"catalogs",
filter=Q(
catalogs__in=(
Catalog.objects.get_published(request.user)
),
),
distinct=True,
)
)
)
.annotate(
num_items=(
F("num_rollingstock")
+ F("num_rollingclass")
+ F("num_catalogs")
)
)
.order_by("name")
)
# overload get method to filter by category
def get(self, request, category, page=1):
if category not in ("real", "model"):
raise Http404
self.filter = Q(category=category)
return super().get(request, page)
class Companies(GetData):
title = "Companies"
def get_data(self, request):
return (
Company.objects.annotate(
num_rollingstock=(
Count(
"rollingclass__rolling_stock",
filter=Q(
rollingclass__rolling_stock__in=(
RollingStock.objects.get_published(
request.user
)
)
),
distinct=True,
)
)
)
.annotate(
num_consists=(
Count(
"consist",
filter=Q(
consist__in=(
Consist.objects.get_published(request.user)
),
),
distinct=True,
)
)
)
.annotate(num_items=F("num_rollingstock") + F("num_consists"))
.order_by("name")
)
class Scales(GetData):
title = "Scales"
def get_data(self, request):
return (
Scale.objects.annotate(
num_rollingstock=Count(
"rollingstock",
filter=Q(
rollingstock__in=RollingStock.objects.get_published(
request.user
)
),
distinct=True,
),
num_consists=Count(
"consist",
filter=Q(
consist__in=Consist.objects.get_published(request.user)
),
distinct=True,
),
num_catalogs=Count("catalogs", distinct=True),
)
.annotate(
num_items=(
F("num_rollingstock")
+ F("num_consists")
+ F("num_catalogs")
)
)
.order_by("-ratio_int", "-tracks", "scale")
)
class Types(GetData):
title = "Types"
def get_data(self, request):
return RollingStockType.objects.annotate(
num_items=Count(
"rollingclass__rolling_stock",
filter=Q(
rollingclass__rolling_stock__in=(
RollingStock.objects.get_published(request.user)
)
),
)
).order_by("order")
class Books(GetData):
title = "Books"
def get_data(self, request):
return Book.objects.get_published(request.user).all()
class Catalogs(GetData):
title = "Catalogs"
def get_data(self, request):
return Catalog.objects.get_published(request.user).all()
class Magazines(GetData):
title = "Magazines"
def get_data(self, request):
return (
Magazine.objects.get_published(request.user)
.order_by(Lower("name"))
.annotate(
issues=Count(
"issue",
filter=Q(
issue__in=(
MagazineIssue.objects.get_published(request.user)
)
),
)
)
)
class GetMagazine(View):
def get(self, request, uuid, page=1):
try:
magazine = Magazine.objects.get_published(request.user).get(
uuid=uuid
)
except ObjectDoesNotExist:
raise Http404
data = list(magazine.issue.get_published(request.user).all())
paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page)
page_range = paginator.get_elided_page_range(
data.number, on_each_side=1, on_ends=1
)
return render(
request,
"magazine.html",
{
"title": magazine,
"magazine": magazine,
"data": data,
"matches": paginator.count,
"page_range": page_range,
},
)
class GetMagazineIssue(View):
def get(self, request, uuid, magazine, page=1):
try:
issue = MagazineIssue.objects.get_published(request.user).get(
uuid=uuid,
magazine__uuid=magazine,
)
except ObjectDoesNotExist:
raise Http404
properties = issue.property.get_public(request.user)
documents = issue.document.get_public(request.user)
return render(
request,
"bookshelf/book.html",
{
"title": issue,
"data": issue,
"documents": documents,
"properties": properties,
},
)
class GetBookCatalog(View):
def get_object(self, request, uuid, selector):
if selector == "book":
return Book.objects.get_published(request.user).get(uuid=uuid)
elif selector == "catalog":
return Catalog.objects.get_published(request.user).get(uuid=uuid)
else:
raise Http404
def get(self, request, uuid, selector):
try:
book = self.get_object(request, uuid, selector)
except ObjectDoesNotExist:
raise Http404
properties = book.property.get_public(request.user)
documents = book.document.get_public(request.user)
return render(
request,
"bookshelf/book.html",
{
"title": book,
"data": book,
"documents": documents,
"properties": properties,
},
)
class GetFlatpage(View):
def get(self, request, flatpage):
try:
flatpage = Flatpage.objects.get_published(request.user).get(
path=flatpage
)
except ObjectDoesNotExist:
raise Http404
return render(
request,
"flatpages/flatpage.html",
{"title": flatpage.name, "flatpage": flatpage},
)