Add a CSV export functionality in admin and add price fields (#41)

* Implement an action do download data in csv
* Refactor CSV download
* Move price to main models and add csv to bookshelf
* Update template and API
* Small refactoring
This commit is contained in:
2024-12-29 21:46:57 +01:00
committed by GitHub
parent 7eddd1b52b
commit 026ab06354
18 changed files with 421 additions and 15 deletions

View File

@@ -1,6 +1,12 @@
import html
from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.utils.html import strip_tags
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
from ram.utils import generate_csv
from portal.utils import get_site_conf
from bookshelf.models import ( from bookshelf.models import (
BaseBookProperty, BaseBookProperty,
BaseBookImage, BaseBookImage,
@@ -71,12 +77,28 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin):
"number_of_pages", "number_of_pages",
"publication_year", "publication_year",
"description", "description",
"purchase_date",
"notes",
"tags", "tags",
) )
}, },
), ),
(
"Purchase data",
{
"fields": (
"purchase_date",
"price",
)
},
),
(
"Notes",
{
"classes": ("collapse",),
"fields": (
"notes",
)
},
),
( (
"Audit", "Audit",
{ {
@@ -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") @admin.display(description="Publisher")
def get_publisher(self, obj): def get_publisher(self, obj):
return obj.publisher.name return obj.publisher.name
@admin.display(description="Authors") @admin.display(description="Authors")
def get_authors(self, obj): 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) @admin.register(Author)
@@ -146,12 +221,28 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin):
"number_of_pages", "number_of_pages",
"publication_year", "publication_year",
"description", "description",
"purchase_date",
"notes",
"tags", "tags",
) )
}, },
), ),
(
"Purchase data",
{
"fields": (
"purchase_date",
"price",
)
},
),
(
"Notes",
{
"classes": ("collapse",),
"fields": (
"notes",
)
},
),
( (
"Audit", "Audit",
{ {
@@ -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") @admin.display(description="Scales")
def get_scales(self, obj): def get_scales(self, obj):
return "/".join(s.scale for s in obj.scales.all()) 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]

View File

@@ -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
),
]

View File

@@ -49,6 +49,12 @@ class BaseBook(BaseModel):
number_of_pages = models.SmallIntegerField(null=True, blank=True) number_of_pages = models.SmallIntegerField(null=True, blank=True)
publication_year = models.SmallIntegerField(null=True, blank=True) publication_year = models.SmallIntegerField(null=True, blank=True)
description = tinymce.HTMLField(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) purchase_date = models.DateField(null=True, blank=True)
tags = models.ManyToManyField( tags = models.ManyToManyField(
Tag, related_name="bookshelf", blank=True Tag, related_name="bookshelf", blank=True
@@ -114,9 +120,14 @@ class Book(BaseBook):
def __str__(self): def __str__(self):
return self.title return self.title
@property
def publisher_name(self): def publisher_name(self):
return self.publisher.name 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): def get_absolute_url(self):
return reverse( return reverse(
"bookshelf_item", "bookshelf_item",

View File

@@ -1,6 +1,10 @@
from rest_framework import serializers from rest_framework import serializers
from bookshelf.models import Book, Author, Publisher from bookshelf.models import Book, Catalog, Author, Publisher
from metadata.serializers import TagSerializer from metadata.serializers import (
ScaleSerializer,
ManufacturerSerializer,
TagSerializer
)
class AuthorSerializer(serializers.ModelSerializer): class AuthorSerializer(serializers.ModelSerializer):
@@ -22,5 +26,16 @@ class BookSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Book 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") read_only_fields = ("creation_time", "updated_time")

View File

@@ -1,7 +1,9 @@
from django.urls import path from django.urls import path
from bookshelf.views import BookList, BookGet from bookshelf.views import BookList, BookGet, CatalogList, CatalogGet
urlpatterns = [ urlpatterns = [
path("book/list", BookList.as_view()), path("book/list", BookList.as_view()),
path("book/get/<uuid:uuid>", BookGet.as_view()), path("book/get/<uuid:uuid>", BookGet.as_view()),
path("catalog/list", CatalogList.as_view()),
path("catalog/get/<uuid:uuid>", CatalogGet.as_view()),
] ]

View File

@@ -1,8 +1,8 @@
from rest_framework.generics import ListAPIView, RetrieveAPIView from rest_framework.generics import ListAPIView, RetrieveAPIView
from rest_framework.schemas.openapi import AutoSchema from rest_framework.schemas.openapi import AutoSchema
from bookshelf.models import Book from bookshelf.models import Book, Catalog
from bookshelf.serializers import BookSerializer from bookshelf.serializers import BookSerializer, CatalogSerializer
class BookList(ListAPIView): class BookList(ListAPIView):
@@ -19,3 +19,19 @@ class BookGet(RetrieveAPIView):
def get_queryset(self): def get_queryset(self):
return Book.objects.get_published(self.request.user) 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)

View File

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

View File

@@ -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),
),
]

View File

@@ -30,6 +30,7 @@ class SiteConfiguration(SingletonModel):
], ],
default="type", default="type",
) )
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)
show_version = models.BooleanField(default=True) show_version = models.BooleanField(default=True)

View File

@@ -113,6 +113,12 @@
<th scope="row">Purchase date</th> <th scope="row">Purchase date</th>
<td>{{ book.purchase_date|default:"-" }}</td> <td>{{ book.purchase_date|default:"-" }}</td>
</tr> </tr>
{% if request.user.is_staff %}
<tr>
<th scope="row">Price ({{ site_conf.currency }})</th>
<td>{{ book.price|default:"-" }}</td>
</tr>
{% endif %}
</tbody> </tbody>
</table> </table>
{% if properties %} {% if properties %}

View File

@@ -187,6 +187,12 @@
<th scope="row">Purchase date</th> <th scope="row">Purchase date</th>
<td>{{ rolling_stock.purchase_date|default:"-" }}</td> <td>{{ rolling_stock.purchase_date|default:"-" }}</td>
</tr> </tr>
{% if request.user.is_staff %}
<tr>
<th scope="row">Price ({{ site_conf.currency }})</th>
<td>{{ rolling_stock.price|default:"-" }}</td>
</tr>
{% endif %}
</tbody> </tbody>
</table> </table>
{% if properties %} {% if properties %}

View File

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

View File

@@ -170,6 +170,9 @@ SITE_NAME = "Railroad Assets Manger"
# The file must be placed in the root of the 'static' folder # The file must be placed in the root of the 'static' folder
DEFAULT_CARD_IMAGE = "coming_soon.svg" DEFAULT_CARD_IMAGE = "coming_soon.svg"
CSV_SEPARATOR = ","
CSV_SEPARATOR_ALT = ";"
DECODER_INTERFACES = [ DECODER_INTERFACES = [
(0, "Built-in"), (0, "Built-in"),
(1, "NEM651"), (1, "NEM651"),

View File

@@ -1,7 +1,10 @@
import os import os
import csv
import hashlib import hashlib
import subprocess import subprocess
from django.conf import settings
from django.http import HttpResponse
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.text import slugify as django_slugify from django.utils.text import slugify as django_slugify
from django.core.files.storage import FileSystemStorage from django.core.files.storage import FileSystemStorage
@@ -57,3 +60,15 @@ def slugify(string, custom_separator=None):
if custom_separator is not None: if custom_separator is not None:
string = string.replace("-", custom_separator) string = string.replace("-", custom_separator)
return string 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

View File

@@ -1,6 +1,13 @@
import html
from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.utils.html import strip_tags
from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin
from ram.utils import generate_csv
from portal.utils import get_site_conf
from roster.models import ( from roster.models import (
RollingClass, RollingClass,
RollingClassProperty, RollingClassProperty,
@@ -152,8 +159,6 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
"era", "era",
"description", "description",
"production_year", "production_year",
"purchase_date",
"notes",
"tags", "tags",
) )
}, },
@@ -168,6 +173,24 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
) )
}, },
), ),
(
"Purchase data",
{
"fields": (
"purchase_date",
"price",
)
},
),
(
"Notes",
{
"classes": ("collapse",),
"fields": (
"notes",
)
},
),
( (
"Audit", "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]

View File

@@ -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
),
]

View File

@@ -101,6 +101,12 @@ class RollingStock(BaseModel):
) )
production_year = models.SmallIntegerField(null=True, blank=True) production_year = models.SmallIntegerField(null=True, blank=True)
purchase_date = models.DateField(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) description = tinymce.HTMLField(blank=True)
tags = models.ManyToManyField( tags = models.ManyToManyField(
Tag, related_name="rolling_stock", blank=True Tag, related_name="rolling_stock", blank=True

View File

@@ -28,5 +28,5 @@ class RollingStockSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = RollingStock model = RollingStock
fields = "__all__" exclude = ("price",)
read_only_fields = ("creation_time", "updated_time") read_only_fields = ("creation_time", "updated_time")