diff --git a/arduino/CommandStation-EX b/arduino/CommandStation-EX index 3b15491..313d2cd 160000 --- a/arduino/CommandStation-EX +++ b/arduino/CommandStation-EX @@ -1 +1 @@ -Subproject commit 3b1549160879e1c9ec18a1bd632eb90d3e139e96 +Subproject commit 313d2cd3e044e347f588af1d33569809d17b2d8d diff --git a/arduino/WebThrottle-EX b/arduino/WebThrottle-EX index eb43d79..eeec7d4 160000 --- a/arduino/WebThrottle-EX +++ b/arduino/WebThrottle-EX @@ -1 +1 @@ -Subproject commit eb43d7906ffa726a3d28fca9f64eb609e8739499 +Subproject commit eeec7d4af6cb2794ef9d1bc5fd2303fa014c0c21 diff --git a/arduino/arduino-cli b/arduino/arduino-cli index fa6eafc..08ff7e2 160000 --- a/arduino/arduino-cli +++ b/arduino/arduino-cli @@ -1 +1 @@ -Subproject commit fa6eafcbbea301eeece900f0501e88d288487974 +Subproject commit 08ff7e2b76b7cd6394c4d09420b10cf0592b4405 diff --git a/arduino/dcc-ex.github.io b/arduino/dcc-ex.github.io index a0f886b..190d3ad 160000 --- a/arduino/dcc-ex.github.io +++ b/arduino/dcc-ex.github.io @@ -1 +1 @@ -Subproject commit a0f886b69ff23ae8f3a391a9e8584554c286111e +Subproject commit 190d3adfa1c24a963a16c1c810694ac7899fd8bb diff --git a/ram/bookshelf/admin.py b/ram/bookshelf/admin.py index a5bf51c..fb5c22d 100644 --- a/ram/bookshelf/admin.py +++ b/ram/bookshelf/admin.py @@ -8,7 +8,11 @@ from adminsortable2.admin import SortableAdminBase, SortableInlineAdminMixin from ram.admin import publish, unpublish from ram.utils import generate_csv from portal.utils import get_site_conf -from repository.models import BookDocument, CatalogDocument +from repository.models import ( + BookDocument, + CatalogDocument, + MagazineIssueDocument +) from bookshelf.models import ( BaseBookProperty, BaseBookImage, @@ -16,6 +20,8 @@ from bookshelf.models import ( Author, Publisher, Catalog, + Magazine, + MagazineIssue, ) @@ -48,6 +54,10 @@ class CatalogDocInline(BookDocInline): model = CatalogDocument +class MagazineIssueDocInline(BookDocInline): + model = MagazineIssueDocument + + @admin.register(Book) class BookAdmin(SortableAdminBase, admin.ModelAdmin): inlines = ( @@ -66,7 +76,7 @@ class BookAdmin(SortableAdminBase, admin.ModelAdmin): autocomplete_fields = ("authors", "publisher", "shop") readonly_fields = ("invoices", "creation_time", "updated_time") search_fields = ("title", "publisher__name", "authors__last_name") - list_filter = ("publisher__name", "authors") + list_filter = ("publisher__name", "authors", "published") fieldsets = ( ( @@ -229,7 +239,12 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin): autocomplete_fields = ("manufacturer",) readonly_fields = ("invoices", "creation_time", "updated_time") search_fields = ("manufacturer__name", "years", "scales__scale") - list_filter = ("manufacturer__name", "publication_year", "scales__scale") + list_filter = ( + "manufacturer__name", + "publication_year", + "scales__scale", + "published", + ) fieldsets = ( ( @@ -346,3 +361,142 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin): download_csv.short_description = "Download selected items as CSV" actions = [publish, unpublish, download_csv] + + +@admin.register(MagazineIssue) +class MagazineIssueAdmin(SortableAdminBase, admin.ModelAdmin): + inlines = ( + BookPropertyInline, + BookImageInline, + MagazineIssueDocInline, + ) + list_display = ( + "__str__", + "issue_number", + "published", + ) + autocomplete_fields = ("shop",) + readonly_fields = ("magazine", "creation_time", "updated_time") + + def get_model_perms(self, request): + """ + Return empty perms dict thus hiding the model from admin index. + """ + return {} + + fieldsets = ( + ( + None, + { + "fields": ( + "published", + "magazine", + "issue_number", + "publication_year", + "publication_month", + "ISBN", + "language", + "number_of_pages", + "description", + "tags", + ) + }, + ), + ( + "Purchase data", + { + "classes": ("collapse",), + "fields": ( + "shop", + "purchase_date", + "price", + ), + }, + ), + ( + "Notes", + {"classes": ("collapse",), "fields": ("notes",)}, + ), + ( + "Audit", + { + "classes": ("collapse",), + "fields": ( + "creation_time", + "updated_time", + ), + }, + ), + ) + actions = [publish, unpublish] + + +class MagazineIssueInline(admin.TabularInline): + model = MagazineIssue + min_num = 0 + extra = 0 + autocomplete_fields = ("shop",) + show_change_link = True + fields = ( + "preview", + "published", + "issue_number", + "publication_year", + "publication_month", + "number_of_pages", + "language", + ) + readonly_fields = ("preview",) + + class Media: + js = ('admin/js/magazine_issue_defaults.js',) + + +@admin.register(Magazine) +class MagazineAdmin(SortableAdminBase, admin.ModelAdmin): + inlines = ( + MagazineIssueInline, + ) + + list_display = ( + "__str__", + "publisher", + "published", + ) + autocomplete_fields = ("publisher",) + readonly_fields = ("creation_time", "updated_time") + search_fields = ("name", "publisher__name") + list_filter = ("publisher__name", "published") + + fieldsets = ( + ( + None, + { + "fields": ( + "published", + "name", + "publisher", + "ISBN", + "language", + "description", + "image", + "tags", + ) + }, + ), + ( + "Notes", + {"classes": ("collapse",), "fields": ("notes",)}, + ), + ( + "Audit", + { + "classes": ("collapse",), + "fields": ( + "creation_time", + "updated_time", + ), + }, + ), + ) + actions = [publish, unpublish] diff --git a/ram/bookshelf/migrations/0025_magazine_magazineissue.py b/ram/bookshelf/migrations/0025_magazine_magazineissue.py new file mode 100644 index 0000000..eaf801d --- /dev/null +++ b/ram/bookshelf/migrations/0025_magazine_magazineissue.py @@ -0,0 +1,224 @@ +# Generated by Django 6.0 on 2025-12-08 17:47 + +import bookshelf.models +import django.db.models.deletion +import ram.utils +import tinymce.models +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookshelf", "0024_alter_basebook_language"), + ("metadata", "0025_alter_company_options_alter_manufacturer_options_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="Magazine", + 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)), + ("name", models.CharField(max_length=200)), + ("ISBN", models.CharField(blank=True, max_length=17)), + ( + "image", + models.ImageField( + blank=True, + storage=ram.utils.DeduplicatedStorage, + upload_to=bookshelf.models.book_image_upload, + ), + ), + ( + "language", + models.CharField( + choices=[ + ("af", "Afrikaans"), + ("ar", "Arabic"), + ("ar-dz", "Algerian Arabic"), + ("ast", "Asturian"), + ("az", "Azerbaijani"), + ("bg", "Bulgarian"), + ("be", "Belarusian"), + ("bn", "Bengali"), + ("br", "Breton"), + ("bs", "Bosnian"), + ("ca", "Catalan"), + ("ckb", "Central Kurdish (Sorani)"), + ("cs", "Czech"), + ("cy", "Welsh"), + ("da", "Danish"), + ("de", "German"), + ("dsb", "Lower Sorbian"), + ("el", "Greek"), + ("en", "English"), + ("en-au", "Australian English"), + ("en-gb", "British English"), + ("eo", "Esperanto"), + ("es", "Spanish"), + ("es-ar", "Argentinian Spanish"), + ("es-co", "Colombian Spanish"), + ("es-mx", "Mexican Spanish"), + ("es-ni", "Nicaraguan Spanish"), + ("es-ve", "Venezuelan Spanish"), + ("et", "Estonian"), + ("eu", "Basque"), + ("fa", "Persian"), + ("fi", "Finnish"), + ("fr", "French"), + ("fy", "Frisian"), + ("ga", "Irish"), + ("gd", "Scottish Gaelic"), + ("gl", "Galician"), + ("he", "Hebrew"), + ("hi", "Hindi"), + ("hr", "Croatian"), + ("hsb", "Upper Sorbian"), + ("ht", "Haitian Creole"), + ("hu", "Hungarian"), + ("hy", "Armenian"), + ("ia", "Interlingua"), + ("id", "Indonesian"), + ("ig", "Igbo"), + ("io", "Ido"), + ("is", "Icelandic"), + ("it", "Italian"), + ("ja", "Japanese"), + ("ka", "Georgian"), + ("kab", "Kabyle"), + ("kk", "Kazakh"), + ("km", "Khmer"), + ("kn", "Kannada"), + ("ko", "Korean"), + ("ky", "Kyrgyz"), + ("lb", "Luxembourgish"), + ("lt", "Lithuanian"), + ("lv", "Latvian"), + ("mk", "Macedonian"), + ("ml", "Malayalam"), + ("mn", "Mongolian"), + ("mr", "Marathi"), + ("ms", "Malay"), + ("my", "Burmese"), + ("nb", "Norwegian Bokmål"), + ("ne", "Nepali"), + ("nl", "Dutch"), + ("nn", "Norwegian Nynorsk"), + ("os", "Ossetic"), + ("pa", "Punjabi"), + ("pl", "Polish"), + ("pt", "Portuguese"), + ("pt-br", "Brazilian Portuguese"), + ("ro", "Romanian"), + ("ru", "Russian"), + ("sk", "Slovak"), + ("sl", "Slovenian"), + ("sq", "Albanian"), + ("sr", "Serbian"), + ("sr-latn", "Serbian Latin"), + ("sv", "Swedish"), + ("sw", "Swahili"), + ("ta", "Tamil"), + ("te", "Telugu"), + ("tg", "Tajik"), + ("th", "Thai"), + ("tk", "Turkmen"), + ("tr", "Turkish"), + ("tt", "Tatar"), + ("udm", "Udmurt"), + ("ug", "Uyghur"), + ("uk", "Ukrainian"), + ("ur", "Urdu"), + ("uz", "Uzbek"), + ("vi", "Vietnamese"), + ("zh-hans", "Simplified Chinese"), + ("zh-hant", "Traditional Chinese"), + ], + default="en", + max_length=7, + ), + ), + ( + "publisher", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="bookshelf.publisher", + ), + ), + ( + "tags", + models.ManyToManyField( + blank=True, related_name="magazine", to="metadata.tag" + ), + ), + ], + options={ + "ordering": ["name"], + }, + ), + migrations.CreateModel( + name="MagazineIssue", + fields=[ + ( + "basebook_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="bookshelf.basebook", + ), + ), + ("issue_number", models.CharField(max_length=100)), + ( + "publication_month", + models.SmallIntegerField( + blank=True, + choices=[ + (1, "January"), + (2, "February"), + (3, "March"), + (4, "April"), + (5, "May"), + (6, "June"), + (7, "July"), + (8, "August"), + (9, "September"), + (10, "October"), + (11, "November"), + (12, "December"), + ], + null=True, + ), + ), + ( + "magazine", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="issue", + to="bookshelf.magazine", + ), + ), + ], + options={ + "ordering": ["magazine", "issue_number"], + "unique_together": {("magazine", "issue_number")}, + }, + bases=("bookshelf.basebook",), + ), + ] diff --git a/ram/bookshelf/models.py b/ram/bookshelf/models.py index 3469a31..0aa0be8 100644 --- a/ram/bookshelf/models.py +++ b/ram/bookshelf/models.py @@ -3,6 +3,8 @@ import shutil from django.db import models from django.conf import settings from django.urls import reverse +from django.utils.dates import MONTHS +from django.core.exceptions import ValidationError from django_countries.fields import CountryField from ram.utils import DeduplicatedStorage @@ -153,3 +155,72 @@ class Catalog(BaseBook): def get_scales(self): return "/".join([s.scale for s in self.scales.all()]) get_scales.short_description = "Scales" + + +class Magazine(BaseModel): + name = models.CharField(max_length=200) + publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE) + ISBN = models.CharField(max_length=17, blank=True) # 13 + dashes + image = models.ImageField( + blank=True, + upload_to=book_image_upload, + storage=DeduplicatedStorage, + ) + language = models.CharField( + max_length=7, + choices=settings.LANGUAGES, + default='en' + ) + tags = models.ManyToManyField( + Tag, related_name="magazine", blank=True + ) + + def delete(self, *args, **kwargs): + shutil.rmtree( + os.path.join( + settings.MEDIA_ROOT, "images", "magazines", str(self.uuid) + ), + ignore_errors=True + ) + super(Magazine, self).delete(*args, **kwargs) + + class Meta: + ordering = ["name"] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse( + "bookshelf_item", + kwargs={"selector": "magazine", "uuid": self.uuid} + ) + + +class MagazineIssue(BaseBook): + magazine = models.ForeignKey( + Magazine, on_delete=models.CASCADE, related_name="issue" + ) + issue_number = models.CharField(max_length=100) + publication_month = models.SmallIntegerField( + null=True, + blank=True, + choices=MONTHS.items() + ) + + class Meta: + unique_together = ("magazine", "issue_number") + ordering = ["magazine", "issue_number"] + + def __str__(self): + return f"{self.magazine.name} - {self.issue_number}" + + def clean(self): + if self.magazine.published is False and self.published is True: + raise ValidationError( + "Cannot set an issue as published if the magazine is not " + "published." + ) + + def preview(self): + return self.image.first().image_thumbnail(100) diff --git a/ram/bookshelf/static/admin/js/magazine_issue_defaults.js b/ram/bookshelf/static/admin/js/magazine_issue_defaults.js new file mode 100644 index 0000000..a400ca7 --- /dev/null +++ b/ram/bookshelf/static/admin/js/magazine_issue_defaults.js @@ -0,0 +1,16 @@ +document.addEventListener('formset:added', function(event) { + const newForm = event.target; // the new inline form element + + const defaultLanguage = document.querySelector('#id_language').value; + const defaultStatus = document.querySelector('#id_published').checked; + + const languageInput = newForm.querySelector('select[name$="language"]'); + const statusInput = newForm.querySelector('input[name$="published"]'); + + if (languageInput) { + languageInput.value = defaultLanguage; + } + if (statusInput) { + statusInput.checked = defaultStatus; + } +}); diff --git a/ram/ram/__init__.py b/ram/ram/__init__.py index 14731ee..4e27f29 100644 --- a/ram/ram/__init__.py +++ b/ram/ram/__init__.py @@ -1,4 +1,4 @@ from ram.utils import git_suffix -__version__ = "0.17.15" +__version__ = "0.18.00" __version__ += git_suffix(__file__) diff --git a/ram/ram/settings.py b/ram/ram/settings.py index 5071c9d..a0af5bf 100644 --- a/ram/ram/settings.py +++ b/ram/ram/settings.py @@ -150,7 +150,7 @@ REST_FRAMEWORK = { } TINYMCE_DEFAULT_CONFIG = { - "height": "500px", + "height": "300px", "menubar": False, "plugins": "autolink lists link image charmap preview anchor " "searchreplace visualblocks code fullscreen insertdatetime media " diff --git a/ram/repository/migrations/0004_magazineissuedocument.py b/ram/repository/migrations/0004_magazineissuedocument.py new file mode 100644 index 0000000..702c0a5 --- /dev/null +++ b/ram/repository/migrations/0004_magazineissuedocument.py @@ -0,0 +1,65 @@ +# Generated by Django 6.0 on 2025-12-08 17:47 + +import django.db.models.deletion +import ram.utils +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("bookshelf", "0025_magazine_magazineissue"), + ( + "repository", + "0003_alter_bookdocument_file_alter_catalogdocument_file_and_more", + ), + ] + + operations = [ + migrations.CreateModel( + name="MagazineIssueDocument", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("description", models.CharField(blank=True, max_length=128)), + ( + "file", + models.FileField( + storage=ram.utils.DeduplicatedStorage, upload_to="files/" + ), + ), + ("creation_time", models.DateTimeField(auto_now_add=True)), + ("updated_time", models.DateTimeField(auto_now=True)), + ( + "private", + models.BooleanField( + default=False, + help_text="Document will be visible only to logged users", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="document", + to="bookshelf.magazineissue", + ), + ), + ], + options={ + "verbose_name_plural": "Magazines documents", + "constraints": [ + models.UniqueConstraint( + fields=("issue", "file"), name="unique_issue_file" + ) + ], + }, + ), + ] diff --git a/ram/repository/models.py b/ram/repository/models.py index 00f8a0b..08e25d2 100644 --- a/ram/repository/models.py +++ b/ram/repository/models.py @@ -1,12 +1,11 @@ from django.db import models -from django.core.exceptions import ValidationError from tinymce import models as tinymce from ram.models import PrivateDocument from metadata.models import Decoder, Shop, Tag from roster.models import RollingStock -from bookshelf.models import Book, Catalog +from bookshelf.models import Book, Catalog, MagazineIssue class GenericDocument(PrivateDocument): @@ -77,6 +76,20 @@ class CatalogDocument(PrivateDocument): ] +class MagazineIssueDocument(PrivateDocument): + issue = models.ForeignKey( + MagazineIssue, on_delete=models.CASCADE, related_name="document" + ) + + class Meta: + verbose_name_plural = "Magazines documents" + constraints = [ + models.UniqueConstraint( + fields=["issue", "file"], name="unique_issue_file" + ) + ] + + class RollingStockDocument(PrivateDocument): rolling_stock = models.ForeignKey( RollingStock, on_delete=models.CASCADE, related_name="document"