diff --git a/arduino/CommandStation-EX b/arduino/CommandStation-EX index 3b15491..7736d32 160000 --- a/arduino/CommandStation-EX +++ b/arduino/CommandStation-EX @@ -1 +1 @@ -Subproject commit 3b1549160879e1c9ec18a1bd632eb90d3e139e96 +Subproject commit 7736d32c5ad1b4c9feee77b5c0c1e58078e1425f diff --git a/ram/bookshelf/admin.py b/ram/bookshelf/admin.py index 0e8a88b..ba57d98 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 = ( @@ -344,3 +354,47 @@ class CatalogAdmin(SortableAdminBase, admin.ModelAdmin): download_csv.short_description = "Download selected items as CSV" actions = [publish, unpublish, download_csv] + + +@admin.register(Issue) +class MagazineIssueAdmin(admin.ModelAdmin): + inlines = ( + BookPropertyInline, + BookImageInline, + MagazineIssueDocInline, + ) + list_display = ( + "__str__", + "issue_number", + "published", + ) + # autocomplete_fields = ("publisher",) + # readonly_fields = ("creation_time", "updated_time") + # search_fields = ("title", "publisher__name") + # list_filter = ("publisher__name", "language") + + def get_model_perms(self, request): + """ + Return empty perms dict thus hiding the model from admin index. + """ + return {} + + actions = [publish, unpublish] + + +@admin.register(Magazine) +class MagazineAdmin(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", "language") + + actions = [publish, unpublish] diff --git a/ram/bookshelf/migrations/0024_issue_magazine_magazineissue.py b/ram/bookshelf/migrations/0024_issue_magazine_magazineissue.py new file mode 100644 index 0000000..eb4ca91 --- /dev/null +++ b/ram/bookshelf/migrations/0024_issue_magazine_magazineissue.py @@ -0,0 +1,226 @@ +# Generated by Django 5.2.8 on 2025-11-13 23:01 + +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", "0023_delete_basebookdocument"), + ("metadata", "0025_alter_company_options_alter_manufacturer_options_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="Issue", + 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)), + ], + options={ + "abstract": False, + }, + bases=("bookshelf.basebook",), + ), + 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)), + ( + "image", + models.ImageField( + blank=True, + storage=ram.utils.DeduplicatedStorage, + upload_to=bookshelf.models.magazine_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"), + ("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=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="magazine_issue", + to="bookshelf.issue", + ), + ), + ( + "magazine", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="magazine_issue", + to="bookshelf.magazine", + ), + ), + ], + options={ + "ordering": ["magazine", "issue"], + "unique_together": {("magazine", "issue")}, + }, + ), + ] diff --git a/ram/bookshelf/models.py b/ram/bookshelf/models.py index 3469a31..c45308b 100644 --- a/ram/bookshelf/models.py +++ b/ram/bookshelf/models.py @@ -3,6 +3,7 @@ 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_countries.fields import CountryField from ram.utils import DeduplicatedStorage @@ -153,3 +154,62 @@ 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.issue_number}" diff --git a/ram/repository/models.py b/ram/repository/models.py index 00f8a0b..5be856c 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, Issue class GenericDocument(PrivateDocument): @@ -77,6 +76,20 @@ class CatalogDocument(PrivateDocument): ] +class MagazineIssueDocument(PrivateDocument): + issue = models.ForeignKey( + Issue, 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"