diff --git a/ram/bookshelf/admin.py b/ram/bookshelf/admin.py index f67a8cd..7983c62 100644 --- a/ram/bookshelf/admin.py +++ b/ram/bookshelf/admin.py @@ -1,6 +1,12 @@ +import html + +from django.conf import settings from django.contrib import admin +from django.utils.html import strip_tags from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin +from ram.utils import generate_csv +from portal.utils import get_site_conf from bookshelf.models import ( BaseBookProperty, BaseBookImage, @@ -71,9 +77,25 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin): "number_of_pages", "publication_year", "description", + "tags", + ) + }, + ), + ( + "Purchase data", + { + "fields": ( "purchase_date", + "price", + ) + }, + ), + ( + "Notes", + { + "classes": ("collapse",), + "fields": ( "notes", - "tags", ) }, ), @@ -89,13 +111,66 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin): ), ) + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + form.base_fields["price"].label = "Price ({})".format( + get_site_conf().currency + ) + return form + @admin.display(description="Publisher") def get_publisher(self, obj): return obj.publisher.name @admin.display(description="Authors") def get_authors(self, obj): - return ", ".join(a.short_name() for a in obj.authors.all()) + return obj.authors_list + + def download_csv(modeladmin, request, queryset): + header = [ + "Title", + "Authors", + "Publisher", + "ISBN", + "Language", + "Number of Pages", + "Publication Year", + "Description", + "Tags", + "Purchase Date", + "Price ({})".format(get_site_conf().currency), + "Notes", + "Properties", + ] + + data = [] + for obj in queryset: + properties = settings.CSV_SEPARATOR_ALT.join( + "{}:{}".format(property.property.name, property.value) + for property in obj.property.all() + ) + data.append([ + obj.title, + obj.authors_list.replace(",", settings.CSV_SEPARATOR_ALT), + obj.publisher.name, + obj.ISBN, + dict(settings.LANGUAGES)[obj.language], + obj.number_of_pages, + obj.publication_year, + html.unescape(strip_tags(obj.description)), + settings.CSV_SEPARATOR_ALT.join( + t.name for t in obj.tags.all() + ), + obj.purchase_date, + obj.price, + html.unescape(strip_tags(obj.notes)), + properties, + ]) + + return generate_csv(header, data, "bookshelf_books.csv") + + download_csv.short_description = "Download selected items as CSV" + actions = [download_csv] @admin.register(Author) @@ -146,9 +221,25 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin): "number_of_pages", "publication_year", "description", + "tags", + ) + }, + ), + ( + "Purchase data", + { + "fields": ( "purchase_date", + "price", + ) + }, + ), + ( + "Notes", + { + "classes": ("collapse",), + "fields": ( "notes", - "tags", ) }, ), @@ -164,6 +255,61 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin): ), ) + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + form.base_fields["price"].label = "Price ({})".format( + get_site_conf().currency + ) + return form + @admin.display(description="Scales") def get_scales(self, obj): return "/".join(s.scale for s in obj.scales.all()) + + def download_csv(modeladmin, request, queryset): + header = [ + "Catalog", + "Manufacturer", + "Years", + "Scales", + "ISBN", + "Language", + "Number of Pages", + "Publication Year", + "Description", + "Tags", + "Purchase Date", + "Price ({})".format(get_site_conf().currency), + "Notes", + "Properties", + ] + + data = [] + for obj in queryset: + properties = settings.CSV_SEPARATOR_ALT.join( + "{}:{}".format(property.property.name, property.value) + for property in obj.property.all() + ) + data.append([ + obj.__str__, + obj.manufacturer.name, + obj.years, + obj.get_scales, + obj.ISBN, + dict(settings.LANGUAGES)[obj.language], + obj.number_of_pages, + obj.publication_year, + html.unescape(strip_tags(obj.description)), + settings.CSV_SEPARATOR_ALT.join( + t.name for t in obj.tags.all() + ), + obj.purchase_date, + obj.price, + html.unescape(strip_tags(obj.notes)), + properties, + ]) + + return generate_csv(header, data, "bookshelf_catalogs.csv") + + download_csv.short_description = "Download selected items as CSV" + actions = [download_csv] diff --git a/ram/bookshelf/migrations/0019_basebook_price.py b/ram/bookshelf/migrations/0019_basebook_price.py new file mode 100644 index 0000000..eb777b6 --- /dev/null +++ b/ram/bookshelf/migrations/0019_basebook_price.py @@ -0,0 +1,36 @@ +# Generated by Django 5.1.4 on 2024-12-29 17:06 + +from django.db import migrations, models + + +def price_to_property(apps, schema_editor): + basebook = apps.get_model("bookshelf", "BaseBook") + for row in basebook.objects.all(): + prop = row.property.filter(property__name__icontains="price") + for p in prop: + try: + row.price = float(p.value) + except ValueError: + pass + row.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookshelf", "0018_alter_basebookdocument_options"), + ] + + operations = [ + migrations.AddField( + model_name="basebook", + name="price", + field=models.DecimalField( + blank=True, decimal_places=2, max_digits=10, null=True + ), + ), + migrations.RunPython( + price_to_property, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/ram/bookshelf/models.py b/ram/bookshelf/models.py index b5c0e9a..e89780d 100644 --- a/ram/bookshelf/models.py +++ b/ram/bookshelf/models.py @@ -49,6 +49,12 @@ class BaseBook(BaseModel): number_of_pages = models.SmallIntegerField(null=True, blank=True) publication_year = models.SmallIntegerField(null=True, blank=True) description = tinymce.HTMLField(blank=True) + price = models.DecimalField( + max_digits=10, + decimal_places=2, + null=True, + blank=True, + ) purchase_date = models.DateField(null=True, blank=True) tags = models.ManyToManyField( Tag, related_name="bookshelf", blank=True @@ -114,9 +120,14 @@ class Meta: def __str__(self): return self.title + @property def publisher_name(self): return self.publisher.name + @property + def authors_list(self): + return ", ".join(a.short_name() for a in self.authors.all()) + def get_absolute_url(self): return reverse( "bookshelf_item", diff --git a/ram/bookshelf/serializers.py b/ram/bookshelf/serializers.py index d331a9c..aa93d98 100644 --- a/ram/bookshelf/serializers.py +++ b/ram/bookshelf/serializers.py @@ -1,6 +1,10 @@ from rest_framework import serializers -from bookshelf.models import Book, Author, Publisher -from metadata.serializers import TagSerializer +from bookshelf.models import Book, Catalog, Author, Publisher +from metadata.serializers import ( + ScaleSerializer, + ManufacturerSerializer, + TagSerializer +) class AuthorSerializer(serializers.ModelSerializer): @@ -22,5 +26,16 @@ class BookSerializer(serializers.ModelSerializer): class Meta: model = Book - fields = "__all__" + exclude = ("price",) + read_only_fields = ("creation_time", "updated_time") + + +class CatalogSerializer(serializers.ModelSerializer): + scales = ScaleSerializer(many=True) + manufacturer = ManufacturerSerializer() + tags = TagSerializer(many=True) + + class Meta: + model = Catalog + exclude = ("price",) read_only_fields = ("creation_time", "updated_time") diff --git a/ram/bookshelf/urls.py b/ram/bookshelf/urls.py index 3ccfd11..4e35427 100644 --- a/ram/bookshelf/urls.py +++ b/ram/bookshelf/urls.py @@ -1,7 +1,9 @@ from django.urls import path -from bookshelf.views import BookList, BookGet +from bookshelf.views import BookList, BookGet, CatalogList, CatalogGet urlpatterns = [ path("book/list", BookList.as_view()), path("book/get/", BookGet.as_view()), + path("catalog/list", CatalogList.as_view()), + path("catalog/get/", CatalogGet.as_view()), ] diff --git a/ram/bookshelf/views.py b/ram/bookshelf/views.py index 166df18..a689e3b 100644 --- a/ram/bookshelf/views.py +++ b/ram/bookshelf/views.py @@ -1,8 +1,8 @@ from rest_framework.generics import ListAPIView, RetrieveAPIView from rest_framework.schemas.openapi import AutoSchema -from bookshelf.models import Book -from bookshelf.serializers import BookSerializer +from bookshelf.models import Book, Catalog +from bookshelf.serializers import BookSerializer, CatalogSerializer class BookList(ListAPIView): @@ -19,3 +19,19 @@ class BookGet(RetrieveAPIView): def get_queryset(self): return Book.objects.get_published(self.request.user) + + +class CatalogList(ListAPIView): + serializer_class = CatalogSerializer + + def get_queryset(self): + return Catalog.objects.get_published(self.request.user) + + +class CatalogGet(RetrieveAPIView): + serializer_class = CatalogSerializer + lookup_field = "uuid" + schema = AutoSchema(operation_id_base="retrieveCatalogByUUID") + + def get_queryset(self): + return Book.objects.get_published(self.request.user) diff --git a/ram/portal/admin.py b/ram/portal/admin.py index 848189c..7a61808 100644 --- a/ram/portal/admin.py +++ b/ram/portal/admin.py @@ -17,6 +17,7 @@ class SiteConfigurationAdmin(SingletonModelAdmin): "about", "items_per_page", "items_ordering", + "currency", "footer", "footer_extended", ) diff --git a/ram/portal/migrations/0018_siteconfiguration_currency.py b/ram/portal/migrations/0018_siteconfiguration_currency.py new file mode 100644 index 0000000..b9d0483 --- /dev/null +++ b/ram/portal/migrations/0018_siteconfiguration_currency.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.4 on 2024-12-29 15:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "portal", + "0017_alter_flatpage_content_alter_siteconfiguration_about_and_more", + ), + ] + + operations = [ + migrations.AddField( + model_name="siteconfiguration", + name="currency", + field=models.CharField(default="EUR", max_length=3), + ), + ] diff --git a/ram/portal/models.py b/ram/portal/models.py index 5238d1b..feea71d 100644 --- a/ram/portal/models.py +++ b/ram/portal/models.py @@ -30,6 +30,7 @@ class SiteConfiguration(SingletonModel): ], default="type", ) + currency = models.CharField(max_length=3, default="EUR") footer = tinymce.HTMLField(blank=True) footer_extended = tinymce.HTMLField(blank=True) show_version = models.BooleanField(default=True) diff --git a/ram/portal/templates/bookshelf/book.html b/ram/portal/templates/bookshelf/book.html index 1377383..2bc935c 100644 --- a/ram/portal/templates/bookshelf/book.html +++ b/ram/portal/templates/bookshelf/book.html @@ -113,6 +113,12 @@ Purchase date {{ book.purchase_date|default:"-" }} + {% if request.user.is_staff %} + + Price ({{ site_conf.currency }}) + {{ book.price|default:"-" }} + + {% endif %} {% if properties %} diff --git a/ram/portal/templates/rollingstock.html b/ram/portal/templates/rollingstock.html index 51c12aa..7651d40 100644 --- a/ram/portal/templates/rollingstock.html +++ b/ram/portal/templates/rollingstock.html @@ -187,6 +187,12 @@ Purchase date {{ rolling_stock.purchase_date|default:"-" }} + {% if request.user.is_staff %} + + Price ({{ site_conf.currency }}) + {{ rolling_stock.price|default:"-" }} + + {% endif %} {% if properties %} diff --git a/ram/ram/__init__.py b/ram/ram/__init__.py index 9537dca..08ffa69 100644 --- a/ram/ram/__init__.py +++ b/ram/ram/__init__.py @@ -1,4 +1,4 @@ from ram.utils import git_suffix -__version__ = "0.14.3" +__version__ = "0.15.0" __version__ += git_suffix(__file__) diff --git a/ram/ram/settings.py b/ram/ram/settings.py index 8f9a6a9..da3b6fe 100644 --- a/ram/ram/settings.py +++ b/ram/ram/settings.py @@ -170,6 +170,9 @@ # The file must be placed in the root of the 'static' folder DEFAULT_CARD_IMAGE = "coming_soon.svg" +CSV_SEPARATOR = "," +CSV_SEPARATOR_ALT = ";" + DECODER_INTERFACES = [ (0, "Built-in"), (1, "NEM651"), diff --git a/ram/ram/utils.py b/ram/ram/utils.py index 309b8b6..230aad3 100644 --- a/ram/ram/utils.py +++ b/ram/ram/utils.py @@ -1,7 +1,10 @@ import os +import csv import hashlib import subprocess +from django.conf import settings +from django.http import HttpResponse from django.utils.html import format_html from django.utils.text import slugify as django_slugify from django.core.files.storage import FileSystemStorage @@ -57,3 +60,15 @@ def slugify(string, custom_separator=None): if custom_separator is not None: string = string.replace("-", custom_separator) return string + + +def generate_csv(header, data, filename, separator=settings.CSV_SEPARATOR): + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="{}"'.format( + filename + ) + writer = csv.writer(response) + writer.writerow(header) + for row in data: + writer.writerow(row) + return response diff --git a/ram/roster/admin.py b/ram/roster/admin.py index e047c1a..31e36f8 100644 --- a/ram/roster/admin.py +++ b/ram/roster/admin.py @@ -1,6 +1,13 @@ +import html + +from django.conf import settings from django.contrib import admin +from django.utils.html import strip_tags + from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin +from ram.utils import generate_csv +from portal.utils import get_site_conf from roster.models import ( RollingClass, RollingClassProperty, @@ -152,8 +159,6 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin): "era", "description", "production_year", - "purchase_date", - "notes", "tags", ) }, @@ -168,6 +173,24 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin): ) }, ), + ( + "Purchase data", + { + "fields": ( + "purchase_date", + "price", + ) + }, + ), + ( + "Notes", + { + "classes": ("collapse",), + "fields": ( + "notes", + ) + }, + ), ( "Audit", { @@ -179,3 +202,65 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin): }, ), ) + + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + form.base_fields["price"].label = "Price ({})".format( + get_site_conf().currency + ) + return form + + def download_csv(modeladmin, request, queryset): + header = [ + "Company", + "Identifier", + "Road Number", + "Manufacturer", + "Scale", + "Item Number", + "Set", + "Era", + "Description", + "Production Year", + "Notes", + "Tags", + "Decoder Interface", + "Decoder", + "Address", + "Purchase Date", + "Price ({})".format(get_site_conf().currency), + "Properties", + ] + data = [] + for obj in queryset: + properties = settings.CSV_SEPARATOR_ALT.join( + "{}:{}".format(property.property.name, property.value) + for property in obj.property.all() + ) + data.append([ + obj.rolling_class.company.name, + obj.rolling_class.identifier, + obj.road_number, + obj.manufacturer.name, + obj.scale.scale, + obj.item_number, + obj.set, + obj.era, + html.unescape(strip_tags(obj.description)), + obj.production_year, + html.unescape(strip_tags(obj.notes)), + settings.CSV_SEPARATOR_ALT.join( + t.name for t in obj.tags.all() + ), + obj.decoder_interface, + obj.decoder, + obj.address, + obj.purchase_date, + obj.price, + properties, + ]) + + return generate_csv(header, data, "rolling_stock.csv") + + download_csv.short_description = "Download selected items as CSV" + actions = [download_csv] diff --git a/ram/roster/migrations/0030_rollingstock_price.py b/ram/roster/migrations/0030_rollingstock_price.py new file mode 100644 index 0000000..99ac156 --- /dev/null +++ b/ram/roster/migrations/0030_rollingstock_price.py @@ -0,0 +1,36 @@ +# Generated by Django 5.1.4 on 2024-12-29 15:23 + +from django.db import migrations, models + + +def price_to_property(apps, schema_editor): + rollingstock = apps.get_model("roster", "RollingStock") + for row in rollingstock.objects.all(): + prop = row.property.filter(property__name__icontains="price") + for p in prop: + try: + row.price = float(p.value) + except ValueError: + pass + row.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("roster", "0029_alter_rollingstockimage_options"), + ] + + operations = [ + migrations.AddField( + model_name="rollingstock", + name="price", + field=models.DecimalField( + blank=True, decimal_places=2, max_digits=10, null=True + ), + ), + migrations.RunPython( + price_to_property, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/ram/roster/models.py b/ram/roster/models.py index 96e2fab..803c5b7 100644 --- a/ram/roster/models.py +++ b/ram/roster/models.py @@ -101,6 +101,12 @@ class RollingStock(BaseModel): ) production_year = models.SmallIntegerField(null=True, blank=True) purchase_date = models.DateField(null=True, blank=True) + price = models.DecimalField( + max_digits=10, + decimal_places=2, + null=True, + blank=True, + ) description = tinymce.HTMLField(blank=True) tags = models.ManyToManyField( Tag, related_name="rolling_stock", blank=True diff --git a/ram/roster/serializers.py b/ram/roster/serializers.py index 228e43a..e6f41be 100644 --- a/ram/roster/serializers.py +++ b/ram/roster/serializers.py @@ -28,5 +28,5 @@ class RollingStockSerializer(serializers.ModelSerializer): class Meta: model = RollingStock - fields = "__all__" + exclude = ("price",) read_only_fields = ("creation_time", "updated_time")