36 Commits

Author SHA1 Message Date
54a68d9b1f Fix data retreival issue on GetData (#35) 2024-04-21 15:34:16 +02:00
aa02404dfe Fix an ordering issue on items in a set query 2024-04-21 09:56:10 +02:00
e4ad98fa38 Implement support for sets and other improvements (#34)
* Add a boolean to define item as part of a set
* Add contextual help in admin
* Introduce support to sets and to item code lookup
Also review the url path for pagination
2024-04-21 00:31:52 +02:00
b37f5420c5 Update to Bootstrap 5.3.3 (#33)
* Update to Bootstrap 5.3.3
* Remove support for python 3.9
2024-04-09 23:45:58 +02:00
4b74a69f3f Add the possbility to provide descriptions (#32)
to class, rolling stock, book
2024-03-02 15:45:42 +01:00
e7d34ce8e0 Remove unused args in upload_image 2024-02-17 23:06:41 +01:00
19eb70c492 Replace ckeditor with tinymce (#30)
* Replace ckeditor with tinymce due to deprecation
* Remove any ckeditor dependency from old migrations
   Disable alters, replace create with plain models.TextField
* Reformat files
* Add more hardening in image_upload
2024-02-17 23:05:18 +01:00
4428b8c11d Fix a RuntimeWarning introduced in Django 5 (#29) 2024-01-20 22:08:10 +01:00
8400a5acd3 Add a sample background to sample_data 2023-11-12 15:30:13 +01:00
7dadf23f5f Make pylibmc optional in requirements-prod.txt 2023-11-04 23:58:51 +01:00
4a12201d22 Make Document and Image files not nullable 2023-11-04 23:54:56 +01:00
830da80302 Keep media folder clean (#28)
* Reorg roster, portal and bookshelf media
* Extend media reorg to consists
* Delete roster and bookshelf images on delte.
   Do not delete others data that might be dedup! 
* Bump version
2023-10-31 11:16:55 +01:00
416ca5bbc6 eu.gif is part of dajngo-countries 2023-10-28 14:00:52 +02:00
03fc82c38d Enable csrf protection 2023-10-28 13:56:43 +02:00
ec8684dbc0 Add a "None" country and "Europe" with flags 2023-10-28 13:55:21 +02:00
7ec8baf733 Replace \t with spaces in base.html 2023-10-28 09:29:11 +02:00
86589ad718 More w3c minor fixes 2023-10-27 23:20:36 +02:00
98fed02a40 Fix a table in rollingstock.html 2023-10-27 23:16:23 +02:00
9602f67e0e Remove a spurious tag 2023-10-27 23:14:09 +02:00
5bb6279095 Extend UX improvements on other pages 2023-10-27 23:11:21 +02:00
84cdee42a6 Fix html syntax in rollingstock.html 2023-10-27 22:58:24 +02:00
168b424df7 Bump version 2023-10-27 22:46:19 +02:00
e1400fe720 Remove health page 2023-10-27 22:26:24 +02:00
26dea2fb35 Improve rollingstock page UX on mobile 2023-10-27 22:26:05 +02:00
ef767ec33d Fix a pretty-print on companies 2023-10-23 18:54:57 +02:00
b23801dbf0 Clear cache on save if active 2023-10-21 21:42:03 +02:00
c7fa54e90e Rename roster methods in portal view 2023-10-17 22:46:55 +02:00
9164ba494f Update examples to implement caching 2023-10-17 22:40:31 +02:00
97989c3384 Improve UX and filtering 2023-10-17 13:44:30 +02:00
7865bf04f0 Add consists view in rolling stock and them in company filter 2023-10-16 22:48:46 +02:00
e6f1480894 Change login menu icon on mobile 2023-10-12 22:33:55 +02:00
8d8ede4c06 Improve page layout on mobile 2023-10-11 22:39:29 +02:00
87e1107156 Bugfixing (#27)
* Enforce ordering on some metadata models
* Fix a 500 error while accessing flat pages
* Clean up HTML and fix cards (missing class)
* Make the "driver" app optional and disabled by default
2023-10-10 22:17:21 +02:00
448ecae070 Add Python 3.12 flow 2023-10-09 23:17:00 +02:00
2b0fdc4487 Workaround for python 3.12 on Fedora 39 2023-10-09 23:16:06 +02:00
764240d67a Fix bookshelf default sorting 2023-10-09 23:09:05 +02:00
73 changed files with 1468 additions and 249 deletions

View File

@@ -13,7 +13,7 @@ jobs:
strategy: strategy:
max-parallel: 2 max-parallel: 2
matrix: matrix:
python-version: ['3.9', '3.10', '3.11'] python-version: ['3.10', '3.11', '3.12']
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3

1
.gitignore vendored
View File

@@ -10,7 +10,6 @@ __pycache__/
.Python .Python
build/ build/
develop-eggs/ develop-eggs/
dist/
downloads/ downloads/
eggs/ eggs/
.eggs/ .eggs/

View File

@@ -49,7 +49,7 @@ It has been developed with:
## Requirements ## Requirements
- Python 3.9+ - Python 3.10+
- A USB port when running Arduino hardware (and adaptors if you have a Mac) - A USB port when running Arduino hardware (and adaptors if you have a Mac)
## Web portal installation ## Web portal installation

View File

@@ -1,6 +1,7 @@
# Generated by Django 4.2.5 on 2023-10-01 20:16 # Generated by Django 4.2.5 on 2023-10-01 20:16
import ckeditor_uploader.fields # ckeditor removal
# import ckeditor_uploader.fields
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
import uuid import uuid
@@ -47,7 +48,8 @@ class Migration(migrations.Migration):
("ISBN", models.CharField(max_length=13, unique=True)), ("ISBN", models.CharField(max_length=13, unique=True)),
("publication_year", models.SmallIntegerField(blank=True, null=True)), ("publication_year", models.SmallIntegerField(blank=True, null=True)),
("purchase_date", models.DateField(blank=True, null=True)), ("purchase_date", models.DateField(blank=True, null=True)),
("notes", ckeditor_uploader.fields.RichTextUploadingField(blank=True)), # ("notes", ckeditor_uploader.fields.RichTextUploadingField(blank=True)),
("notes", models.TextField(blank=True)),
("creation_time", models.DateTimeField(auto_now_add=True)), ("creation_time", models.DateTimeField(auto_now_add=True)),
("updated_time", models.DateTimeField(auto_now=True)), ("updated_time", models.DateTimeField(auto_now=True)),
("authors", models.ManyToManyField(to="bookshelf.author")), ("authors", models.ManyToManyField(to="bookshelf.author")),

View File

@@ -0,0 +1,20 @@
# Generated by Django 4.2.6 on 2023-10-09 21:08
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0007_alter_book_options"),
]
operations = [
migrations.AlterModelOptions(
name="author",
options={"ordering": ["last_name", "first_name"]},
),
migrations.AlterModelOptions(
name="publisher",
options={"ordering": ["name"]},
),
]

View File

@@ -0,0 +1,49 @@
# Generated by Django 4.2.6 on 2023-10-30 13:16
import os
import sys
import shutil
import ram.utils
import bookshelf.models
from django.db import migrations, models
from django.conf import settings
def move_images(apps, schema_editor):
sys.stdout.write("\n Processing files. Please await...")
for r in bookshelf.models.BookImage.objects.all():
fname = os.path.basename(r.image.path)
new_image = bookshelf.models.book_image_upload(r, fname)
new_path = os.path.join(settings.MEDIA_ROOT, new_image)
os.makedirs(os.path.dirname(new_path), exist_ok=True)
try:
shutil.move(r.image.path, new_path)
except FileNotFoundError:
sys.stderr.write(" !! FileNotFoundError: {}\n".format(new_image))
pass
r.image.name = new_image
r.save()
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0008_alter_author_options_alter_publisher_options"),
]
operations = [
migrations.AlterField(
model_name="bookimage",
name="image",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to=bookshelf.models.book_image_upload,
),
),
migrations.RunPython(
move_images,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 4.2.6 on 2023-11-04 22:53
import bookshelf.models
from django.db import migrations, models
import ram.utils
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0009_alter_bookimage_image"),
]
operations = [
migrations.AlterField(
model_name="bookimage",
name="image",
field=models.ImageField(
storage=ram.utils.DeduplicatedStorage,
upload_to=bookshelf.models.book_image_upload,
),
),
]

View File

@@ -0,0 +1,121 @@
# Generated by Django 5.0.1 on 2024-01-20 21:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0010_alter_bookimage_image"),
]
operations = [
migrations.AlterField(
model_name="book",
name="language",
field=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,
),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.0.2 on 2024-02-17 12:19
import tinymce.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0011_alter_book_language"),
]
operations = [
migrations.AlterField(
model_name="book",
name="notes",
field=tinymce.models.HTMLField(blank=True),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.0.2 on 2024-03-02 14:31
import tinymce.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("bookshelf", "0012_alter_book_notes"),
]
operations = [
migrations.AddField(
model_name="book",
name="description",
field=tinymce.models.HTMLField(blank=True),
),
]

View File

@@ -1,10 +1,12 @@
import os
import shutil
from uuid import uuid4 from uuid import uuid4
from django.db import models from django.db import models
from django.conf import settings from django.conf import settings
from django.urls import reverse from django.urls import reverse
from django_countries.fields import CountryField from django_countries.fields import CountryField
from ckeditor_uploader.fields import RichTextUploadingField from tinymce import models as tinymce
from metadata.models import Tag from metadata.models import Tag
from ram.utils import DeduplicatedStorage from ram.utils import DeduplicatedStorage
@@ -16,6 +18,9 @@ class Publisher(models.Model):
country = CountryField(blank=True) country = CountryField(blank=True)
website = models.URLField(blank=True) website = models.URLField(blank=True)
class Meta:
ordering = ["name"]
def __str__(self): def __str__(self):
return self.name return self.name
@@ -24,6 +29,9 @@ class Author(models.Model):
first_name = models.CharField(max_length=100) first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100) last_name = models.CharField(max_length=100)
class Meta:
ordering = ["last_name", "first_name"]
def __str__(self): def __str__(self):
return f"{self.last_name}, {self.first_name}" return f"{self.last_name}, {self.first_name}"
@@ -44,11 +52,12 @@ class Book(models.Model):
) )
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)
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
) )
notes = RichTextUploadingField(blank=True) notes = tinymce.HTMLField(blank=True)
creation_time = models.DateTimeField(auto_now_add=True) creation_time = models.DateTimeField(auto_now_add=True)
updated_time = models.DateTimeField(auto_now=True) updated_time = models.DateTimeField(auto_now=True)
@@ -64,16 +73,32 @@ class Book(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return reverse("book", kwargs={"uuid": self.uuid}) return reverse("book", kwargs={"uuid": self.uuid})
def delete(self, *args, **kwargs):
shutil.rmtree(
os.path.join(
settings.MEDIA_ROOT, "images", "books", str(self.uuid)
),
ignore_errors=True
)
super(Book, self).delete(*args, **kwargs)
def book_image_upload(instance, filename):
return os.path.join(
"images",
"books",
str(instance.book.uuid),
filename
)
class BookImage(Image): class BookImage(Image):
book = models.ForeignKey( book = models.ForeignKey(
Book, on_delete=models.CASCADE, related_name="image" Book, on_delete=models.CASCADE, related_name="image"
) )
image = models.ImageField( image = models.ImageField(
upload_to="images/books/", # FIXME, find a better way to replace this upload_to=book_image_upload,
storage=DeduplicatedStorage, storage=DeduplicatedStorage,
null=True,
blank=True
) )

View File

@@ -1,6 +1,7 @@
# Generated by Django 4.1 on 2022-08-23 15:54 # Generated by Django 4.1 on 2022-08-23 15:54
import ckeditor_uploader.fields # ckeditor removal
# import ckeditor_uploader.fields
from django.db import migrations from django.db import migrations
@@ -11,9 +12,9 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.AlterField( # migrations.AlterField(
model_name="consist", # model_name="consist",
name="notes", # name="notes",
field=ckeditor_uploader.fields.RichTextUploadingField(blank=True), # field=ckeditor_uploader.fields.RichTextUploadingField(blank=True),
), # ),
] ]

View File

@@ -0,0 +1,51 @@
# Generated by Django 4.2.6 on 2023-10-31 09:41
import os
import sys
import shutil
import ram.utils
from django.conf import settings
from django.db import migrations, models
def move_images(apps, schema_editor):
sys.stdout.write("\n Processing files. Please await...")
model = apps.get_model("consist", "Consist")
for r in model.objects.all():
if not r.image: # exit the loop if there's no image
continue
fname = os.path.basename(r.image.path)
new_image = os.path.join("images", "consists", fname)
new_path = os.path.join(settings.MEDIA_ROOT, new_image)
os.makedirs(os.path.dirname(new_path), exist_ok=True)
try:
shutil.move(r.image.path, new_path)
except FileNotFoundError:
sys.stderr.write(" !! FileNotFoundError: {}\n".format(new_image))
pass
r.image.name = new_image
r.save()
class Migration(migrations.Migration):
dependencies = [
("consist", "0008_alter_consist_options"),
]
operations = [
migrations.AlterField(
model_name="consist",
name="image",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to="images/consists",
),
),
migrations.RunPython(
move_images,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.0.2 on 2024-02-17 12:19
import tinymce.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("consist", "0009_alter_consist_image"),
]
operations = [
migrations.AlterField(
model_name="consist",
name="notes",
field=tinymce.models.HTMLField(blank=True),
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 5.0.4 on 2024-04-20 12:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("consist", "0010_alter_consist_notes"),
]
operations = [
migrations.AlterField(
model_name="consist",
name="consist_address",
field=models.SmallIntegerField(
blank=True,
default=None,
help_text="DCC consist address if enabled",
null=True,
),
),
migrations.AlterField(
model_name="consist",
name="era",
field=models.CharField(
blank=True, help_text="Era or epoch of the consist", max_length=32
),
),
]

View File

@@ -1,8 +1,10 @@
import os
from uuid import uuid4 from uuid import uuid4
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from ckeditor_uploader.fields import RichTextUploadingField from tinymce import models as tinymce
from ram.utils import DeduplicatedStorage from ram.utils import DeduplicatedStorage
from metadata.models import Company, Tag from metadata.models import Company, Tag
@@ -14,14 +16,24 @@ class Consist(models.Model):
identifier = models.CharField(max_length=128, unique=False) identifier = models.CharField(max_length=128, unique=False)
tags = models.ManyToManyField(Tag, related_name="consist", blank=True) tags = models.ManyToManyField(Tag, related_name="consist", blank=True)
consist_address = models.SmallIntegerField( consist_address = models.SmallIntegerField(
default=None, null=True, blank=True default=None,
null=True,
blank=True,
help_text="DCC consist address if enabled",
) )
company = models.ForeignKey(Company, on_delete=models.CASCADE) company = models.ForeignKey(Company, on_delete=models.CASCADE)
era = models.CharField(max_length=32, blank=True) era = models.CharField(
image = models.ImageField( max_length=32,
upload_to="images/", storage=DeduplicatedStorage, null=True, blank=True blank=True,
help_text="Era or epoch of the consist",
) )
notes = RichTextUploadingField(blank=True) image = models.ImageField(
upload_to=os.path.join("images", "consists"),
storage=DeduplicatedStorage,
null=True,
blank=True,
)
notes = tinymce.HTMLField(blank=True)
creation_time = models.DateTimeField(auto_now_add=True) creation_time = models.DateTimeField(auto_now_add=True)
updated_time = models.DateTimeField(auto_now=True) updated_time = models.DateTimeField(auto_now=True)

View File

@@ -0,0 +1,20 @@
# Generated by Django 4.2.6 on 2023-10-10 12:44
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("metadata", "0013_decoderdocument_private"),
]
operations = [
migrations.AlterModelOptions(
name="decoder",
options={"ordering": ["manufacturer", "name"]},
),
migrations.AlterModelOptions(
name="tag",
options={"ordering": ["name"]},
),
]

View File

@@ -0,0 +1,80 @@
# Generated by Django 4.2.6 on 2023-10-30 13:16
import os
import sys
import shutil
import ram.utils
from django.conf import settings
from django.db import migrations, models
def move_images(apps, schema_editor):
fields = {
"Company": ["companies", "logo"],
"Decoder": ["decoders", "image"],
"Manufacturer": ["manufacturers", "logo"],
}
sys.stdout.write("\n Processing files. Please await...")
for m in fields.items():
model = apps.get_model("metadata", m[0])
for r in model.objects.all():
field = getattr(r, m[1][1])
if not field: # exit the loop if there's no image
continue
fname = os.path.basename(field.path)
new_image = os.path.join("images", m[1][0], fname)
new_path = os.path.join(settings.MEDIA_ROOT, new_image)
os.makedirs(os.path.dirname(new_path), exist_ok=True)
try:
shutil.move(field.path, new_path)
except FileNotFoundError:
sys.stderr.write(
" !! FileNotFoundError: {}\n".format(new_image)
)
pass
field.name = new_image
r.save()
class Migration(migrations.Migration):
dependencies = [
("metadata", "0014_alter_decoder_options_alter_tag_options"),
]
operations = [
migrations.AlterField(
model_name="company",
name="logo",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to="images/companies",
),
),
migrations.AlterField(
model_name="decoder",
name="image",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to="images/decoders",
),
),
migrations.AlterField(
model_name="manufacturer",
name="logo",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to="images/manufacturers",
),
),
migrations.RunPython(
move_images,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 4.2.6 on 2023-11-04 22:53
from django.db import migrations, models
import ram.utils
class Migration(migrations.Migration):
dependencies = [
("metadata", "0015_alter_company_logo_alter_decoder_image_and_more"),
]
operations = [
migrations.AlterField(
model_name="decoderdocument",
name="file",
field=models.FileField(
storage=ram.utils.DeduplicatedStorage(), upload_to="files/"
),
),
]

View File

@@ -0,0 +1,20 @@
# Generated by Django 5.0.4 on 2024-04-20 12:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("metadata", "0016_alter_decoderdocument_file"),
]
operations = [
migrations.AlterField(
model_name="property",
name="private",
field=models.BooleanField(
default=False, help_text="Property will be only visible to logged users"
),
),
]

View File

@@ -1,3 +1,4 @@
import os
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.conf import settings from django.conf import settings
@@ -10,7 +11,10 @@ from ram.utils import DeduplicatedStorage, get_image_preview, slugify
class Property(models.Model): class Property(models.Model):
name = models.CharField(max_length=128, unique=True) name = models.CharField(max_length=128, unique=True)
private = models.BooleanField(default=False) private = models.BooleanField(
default=False,
help_text="Property will be only visible to logged users",
)
class Meta: class Meta:
verbose_name_plural = "Properties" verbose_name_plural = "Properties"
@@ -28,7 +32,10 @@ class Manufacturer(models.Model):
) )
website = models.URLField(blank=True) website = models.URLField(blank=True)
logo = models.ImageField( logo = models.ImageField(
upload_to="images/", storage=DeduplicatedStorage, null=True, blank=True upload_to=os.path.join("images", "manufacturers"),
storage=DeduplicatedStorage,
null=True,
blank=True,
) )
class Meta: class Meta:
@@ -58,7 +65,10 @@ class Company(models.Model):
country = CountryField() country = CountryField()
freelance = models.BooleanField(default=False) freelance = models.BooleanField(default=False)
logo = models.ImageField( logo = models.ImageField(
upload_to="images/", storage=DeduplicatedStorage, null=True, blank=True upload_to=os.path.join("images", "companies"),
storage=DeduplicatedStorage,
null=True,
blank=True,
) )
class Meta: class Meta:
@@ -76,6 +86,9 @@ class Company(models.Model):
} }
) )
def extended_name_pp(self):
return "({})".format(self.extended_name) if self.extended_name else ""
def logo_thumbnail(self): def logo_thumbnail(self):
return get_image_preview(self.logo.url) return get_image_preview(self.logo.url)
@@ -92,9 +105,15 @@ class Decoder(models.Model):
version = models.CharField(max_length=64, blank=True) version = models.CharField(max_length=64, blank=True)
sound = models.BooleanField(default=False) sound = models.BooleanField(default=False)
image = models.ImageField( image = models.ImageField(
upload_to="images/", storage=DeduplicatedStorage, null=True, blank=True upload_to=os.path.join("images", "decoders"),
storage=DeduplicatedStorage,
null=True,
blank=True,
) )
class Meta(object):
ordering = ["manufacturer", "name"]
def __str__(self): def __str__(self):
return "{0} - {1}".format(self.manufacturer, self.name) return "{0} - {1}".format(self.manufacturer, self.name)
@@ -163,6 +182,9 @@ class Tag(models.Model):
name = models.CharField(max_length=128, unique=True) name = models.CharField(max_length=128, unique=True)
slug = models.CharField(max_length=128, unique=True) slug = models.CharField(max_length=128, unique=True)
class Meta(object):
ordering = ["name"]
def __str__(self): def __str__(self):
return self.name return self.name

View File

@@ -6,6 +6,7 @@ from portal.models import SiteConfiguration, Flatpage
@admin.register(SiteConfiguration) @admin.register(SiteConfiguration)
class SiteConfigurationAdmin(SingletonModelAdmin): class SiteConfigurationAdmin(SingletonModelAdmin):
readonly_fields = ("site_name",)
fieldsets = ( fieldsets = (
( (
None, None,

View File

@@ -1,7 +1,8 @@
# Generated by Django 4.1 on 2022-08-23 15:54 # Generated by Django 4.1 on 2022-08-23 15:54
import ckeditor.fields # ckeditor dependency removal
import ckeditor_uploader.fields # import ckeditor.fields
# import ckeditor_uploader.fields
from django.db import migrations from django.db import migrations
@@ -12,24 +13,24 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.AlterField( # migrations.AlterField(
model_name="flatpage", # model_name="flatpage",
name="content", # name="content",
field=ckeditor_uploader.fields.RichTextUploadingField(), # field=ckeditor_uploader.fields.RichTextUploadingField(),
), # ),
migrations.AlterField( # migrations.AlterField(
model_name="siteconfiguration", # model_name="siteconfiguration",
name="about", # name="about",
field=ckeditor.fields.RichTextField(blank=True), # field=ckeditor.fields.RichTextField(blank=True),
), # ),
migrations.AlterField( # migrations.AlterField(
model_name="siteconfiguration", # model_name="siteconfiguration",
name="footer", # name="footer",
field=ckeditor.fields.RichTextField(blank=True), # field=ckeditor.fields.RichTextField(blank=True),
), # ),
migrations.AlterField( # migrations.AlterField(
model_name="siteconfiguration", # model_name="siteconfiguration",
name="footer_extended", # name="footer_extended",
field=ckeditor.fields.RichTextField(blank=True), # field=ckeditor.fields.RichTextField(blank=True),
), # ),
] ]

View File

@@ -0,0 +1,16 @@
# Generated by Django 5.0.1 on 2024-01-20 21:02
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("portal", "0015_siteconfiguration_use_cdn"),
]
operations = [
migrations.RemoveField(
model_name="siteconfiguration",
name="site_name",
),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 5.0.2 on 2024-02-17 12:19
import tinymce.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("portal", "0016_remove_siteconfiguration_site_name"),
]
operations = [
migrations.AlterField(
model_name="flatpage",
name="content",
field=tinymce.models.HTMLField(),
),
migrations.AlterField(
model_name="siteconfiguration",
name="about",
field=tinymce.models.HTMLField(blank=True),
),
migrations.AlterField(
model_name="siteconfiguration",
name="footer",
field=tinymce.models.HTMLField(blank=True),
),
migrations.AlterField(
model_name="siteconfiguration",
name="footer_extended",
field=tinymce.models.HTMLField(blank=True),
),
]

View File

@@ -1,23 +1,20 @@
import django import django
from django.db import models from django.db import models
from django.conf import settings
from django.urls import reverse from django.urls import reverse
from django.dispatch.dispatcher import receiver from django.dispatch.dispatcher import receiver
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from solo.models import SingletonModel from solo.models import SingletonModel
from ckeditor.fields import RichTextField from tinymce import models as tinymce
from ckeditor_uploader.fields import RichTextUploadingField
from ram import __version__ as app_version from ram import __version__ as app_version
from ram.utils import slugify from ram.utils import slugify
class SiteConfiguration(SingletonModel): class SiteConfiguration(SingletonModel):
site_name = models.CharField(
max_length=256, default="Railroad Assets Manager"
)
site_author = models.CharField(max_length=256, blank=True) site_author = models.CharField(max_length=256, blank=True)
about = RichTextField(blank=True) about = tinymce.HTMLField(blank=True)
items_per_page = models.CharField( items_per_page = models.CharField(
max_length=2, max_length=2,
choices=[(str(x * 3), str(x * 3)) for x in range(2, 11)], choices=[(str(x * 3), str(x * 3)) for x in range(2, 11)],
@@ -32,8 +29,8 @@ class SiteConfiguration(SingletonModel):
], ],
default="type", default="type",
) )
footer = RichTextField(blank=True) footer = tinymce.HTMLField(blank=True)
footer_extended = RichTextField(blank=True) footer_extended = tinymce.HTMLField(blank=True)
show_version = models.BooleanField(default=True) show_version = models.BooleanField(default=True)
use_cdn = models.BooleanField(default=True) use_cdn = models.BooleanField(default=True)
extra_head = models.TextField(blank=True) extra_head = models.TextField(blank=True)
@@ -44,6 +41,9 @@ class SiteConfiguration(SingletonModel):
def __str__(self): def __str__(self):
return "Site Configuration" return "Site Configuration"
def site_name(self):
return settings.SITE_NAME
def version(self): def version(self):
return app_version return app_version
@@ -55,7 +55,7 @@ class Flatpage(models.Model):
name = models.CharField(max_length=256, unique=True) name = models.CharField(max_length=256, unique=True)
path = models.CharField(max_length=256, unique=True) path = models.CharField(max_length=256, unique=True)
published = models.BooleanField(default=False) published = models.BooleanField(default=False)
content = RichTextUploadingField() content = tinymce.HTMLField()
creation_time = models.DateTimeField(auto_now_add=True) creation_time = models.DateTimeField(auto_now_add=True)
updated_time = models.DateTimeField(auto_now=True) updated_time = models.DateTimeField(auto_now=True)

View File

@@ -1,14 +1,14 @@
/*! /*!
* Bootstrap Icons v1.11.1 (https://icons.getbootstrap.com/) * Bootstrap Icons v1.11.3 (https://icons.getbootstrap.com/)
* Copyright 2019-2023 The Bootstrap Authors * Copyright 2019-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE) * Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE)
*/ */
@font-face { @font-face {
font-display: block; font-display: block;
font-family: "bootstrap-icons"; font-family: "bootstrap-icons";
src: url("./fonts/bootstrap-icons.woff2?2820a3852bdb9a5832199cc61cec4e65") format("woff2"), src: url("./fonts/bootstrap-icons.woff2?dd67030699838ea613ee6dbda90effa6") format("woff2"),
url("./fonts/bootstrap-icons.woff?2820a3852bdb9a5832199cc61cec4e65") format("woff"); url("./fonts/bootstrap-icons.woff?dd67030699838ea613ee6dbda90effa6") format("woff");
} }
.bi::before, .bi::before,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -7,6 +7,16 @@ html[data-bs-theme='dark'] .navbar svg {
width: 100%; width: 100%;
} }
td > img.logo {
max-width: 200px;
max-height: 48px;
}
td > img.logo-xl {
max-width: 400px;
max-height: 96px;
}
.btn > span { .btn > span {
display: inline-block; display: inline-block;
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

View File

@@ -16,11 +16,11 @@
<link rel="icon" href="{% static "favicon.png" %}" sizes="any"> <link rel="icon" href="{% static "favicon.png" %}" sizes="any">
<link rel="icon" href="{% static "favicon.svg" %}" type="image/svg+xml"> <link rel="icon" href="{% static "favicon.svg" %}" type="image/svg+xml">
{% if site_conf.use_cdn %} {% if site_conf.use_cdn %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
{% else %} {% else %}
<link href="{% static "bootstrap@5.3.2/dist/css/bootstrap.min.css" %}" rel="stylesheet"> <link href="{% static "bootstrap@5.3.3/dist/css/bootstrap.min.css" %}" rel="stylesheet">
<link href="{% static "bootstrap-icons@1.11.1/font/bootstrap-icons.css" %}" rel="stylesheet"> <link href="{% static "bootstrap-icons@1.11.3/font/bootstrap-icons.css" %}" rel="stylesheet">
{% endif %} {% endif %}
<link href="{% static "css/main.css" %}?v={{ site_conf.version }}" rel="stylesheet"> <link href="{% static "css/main.css" %}?v={{ site_conf.version }}" rel="stylesheet">
<style> <style>
@@ -115,13 +115,26 @@
}) })
})() })()
</script> </script>
<script>
document.addEventListener('DOMContentLoaded', function () {
var selectElement = document.getElementById('tabSelector');
selectElement.addEventListener('change', function () {
var selectedTabId = this.value;
var tabs = document.querySelectorAll('.tab-pane');
tabs.forEach(function (tab) {
tab.classList.remove('show', 'active');
});
document.getElementById(selectedTabId).classList.add('show', 'active');
});
});
</script>
{% block extra_head %} {% block extra_head %}
{{ site_conf.extra_head | safe }} {{ site_conf.extra_head | safe }}
{% endblock %} {% endblock %}
</head> </head>
<body> <body>
<header> <header>
<nav class="navbar navbar-expand-lg bg-body-tertiary shadow-sm"> <nav class="navbar navbar-expand-sm bg-body-tertiary shadow-sm">
<div class="container d-flex"> <div class="container d-flex">
<div class="me-auto"> <div class="me-auto">
<a href="{% url 'index' %}" class="navbar-brand d-flex align-items-center"> <a href="{% url 'index' %}" class="navbar-brand d-flex align-items-center">
@@ -146,29 +159,30 @@
<nav class="navbar navbar-expand-lg"> <nav class="navbar navbar-expand-lg">
<div class="container-fluid g-0"> <div class="container-fluid g-0">
<a class="navbar-brand" href="{% url 'index' %}">Home</a> <a class="navbar-brand" href="{% url 'index' %}">Home</a>
<div class="navbar-collapse" id="navbarSupportedContent"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"> <ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item dropdown"> <li class="nav-item">
<a class="nav-link dropdown-toggle" href="#" id="rosterDropdownMenu" role="button" data-bs-toggle="dropdown" aria-expanded="false"> <a class="nav-link" href="{% url 'roster' %}">Roster</a>
Roster
</a>
<ul class="dropdown-menu" aria-labelledby="rosterDropdownMenu">
<li><a class="dropdown-item" href="{% url 'roster' %}">Rolling stock</a></li>
<li><a class="dropdown-item" href="{% url 'companies' %}">Companies</a></li>
<li><a class="dropdown-item" href="{% url 'types' %}">Types</a></li>
<li><a class="dropdown-item" href="{% url 'scales' %}">Scales</a></li>
</ul>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{% url 'consists' %}">Consists</a> <a class="nav-link" href="{% url 'consists' %}">Consists</a>
</li> </li>
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="manufacturersDropdownMenu" role="button" data-bs-toggle="dropdown" aria-expanded="false"> <a class="nav-link dropdown-toggle" href="#" id="filterDropdownMenu" role="button" data-bs-toggle="dropdown" aria-expanded="false">
Manufacturers Search by
</a> </a>
<ul class="dropdown-menu" aria-labelledby="manufacturersDropdownMenu"> <ul class="dropdown-menu" aria-labelledby="filterDropdownMenu">
<li><a class="dropdown-item" href="{% url 'manufacturers' category='model' %}">Models</a></li> <li class="ps-2 text-secondary">Model</li>
<li><a class="dropdown-item" href="{% url 'manufacturers' category='real' %}">Real</a></li> <li><a class="dropdown-item" href="{% url 'scales' %}">Scale</a></li>
<li><a class="dropdown-item" href="{% url 'manufacturers' category='model' %}">Manufacturer</a></li>
<li><hr class="dropdown-divider"></li>
<li class="ps-2 text-secondary">Prototype</li>
<li><a class="dropdown-item" href="{% url 'types' %}">Type</a></li>
<li><a class="dropdown-item" href="{% url 'companies' %}">Company</a></li>
<li><a class="dropdown-item" href="{% url 'manufacturers' category='real' %}">Manufacturer</a></li>
</ul> </ul>
</li> </li>
{% show_bookshelf_menu %} {% show_bookshelf_menu %}
@@ -202,9 +216,9 @@
</main> </main>
{% include 'includes/footer.html' %} {% include 'includes/footer.html' %}
{% if site_conf.use_cdn %} {% if site_conf.use_cdn %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
{% else %} {% else %}
<script src="{% static "bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" %}"></script> <script src="{% static "bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" %}"></script>
{% endif %} {% endif %}
</body> </body>
</html> </html>

View File

@@ -43,13 +43,18 @@
<section class="py-4 text-start container"> <section class="py-4 text-start container">
<div class="row"> <div class="row">
<div class="mx-auto"> <div class="mx-auto">
<nav class="nav nav-tabs flex-column flex-md-row mb-2" id="nav-tab"> <nav class="nav nav-tabs d-none d-lg-flex flex-row mb-2" id="nav-tab" role="tablist">
<button class="nav-link active" id="nav-summary-tab" data-bs-toggle="tab" data-bs-target="#nav-summary" type="button" role="tab" aria-controls="nav-summary" aria-selected="true">Summary</button> <button class="nav-link active" id="nav-summary-tab" data-bs-toggle="tab" data-bs-target="#nav-summary" type="button" role="tab" aria-controls="nav-summary" aria-selected="true">Summary</button>
{% if book.notes %}<button class="nav-link" id="nav-notes-tab" data-bs-toggle="tab" data-bs-target="#nav-notes" type="button" role="tab" aria-controls="nav-notes" aria-selected="false">Notes</button>{% endif %} {% if book.notes %}<button class="nav-link" id="nav-notes-tab" data-bs-toggle="tab" data-bs-target="#nav-notes" type="button" role="tab" aria-controls="nav-notes" aria-selected="false">Notes</button>{% endif %}
</nav> </nav>
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
<option value="nav-summary" selected>Summary</option>
{% if book.notes %}<option value="nav-notes">Notes</option>{% endif %}
</select>
<div class="tab-content" id="nav-tabContent"> <div class="tab-content" id="nav-tabContent">
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab"> <div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
<table class="table table-striped"> <table class="table table-striped">
{{ book.description | safe }}
<thead> <thead>
<tr> <tr>
<th colspan="2" scope="row">Book</th> <th colspan="2" scope="row">Book</th>

View File

@@ -3,11 +3,11 @@
<p class="lead text-muted">Results found: {{ matches }}</p> <p class="lead text-muted">Results found: {{ matches }}</p>
{% endblock %} {% endblock %}
{% block cards_layout %} {% block cards_layout %}
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3"> <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
{% block cards %} {% block cards %}
{% for d in data %} {% for d in data %}
{% if d.type == "rolling_stock" %} {% if d.type == "rolling_stock" %}
{% include "cards/rolling_stock.html" %} {% include "cards/roster.html" %}
{% elif d.type == "company" %} {% elif d.type == "company" %}
{% include "cards/company.html" %} {% include "cards/company.html" %}
{% elif d.type == "rolling_stock_type" %} {% elif d.type == "rolling_stock_type" %}

View File

@@ -1,11 +1,7 @@
<div class="col"> <div class="col">
<div class="card shadow-sm"> <div class="card shadow-sm">
{% if d.item.image.all %} {% if d.item.image.exists %}
<a href="{{ d.item.get_absolute_url }}"> <a href="{{d.item.get_absolute_url}}"><img class="card-img-top" src="{{ d.item.image.first.image.url }}" alt="{{ d.item }}"></a>
{% for r in d.item.image.all %}
{% if forloop.first %}<img src="{{ r.image.url }}" alt="Card image cap">{% endif %}
{% endfor %}
</a>
{% endif %} {% endif %}
<div class="card-body"> <div class="card-body">
<p class="card-text" style="position: relative;"> <p class="card-text" style="position: relative;">

View File

@@ -14,7 +14,7 @@
{% if d.item.logo %} {% if d.item.logo %}
<tr> <tr>
<th class="w-33" scope="row">Logo</th> <th class="w-33" scope="row">Logo</th>
<td><img style="max-height: 48px" src="{{ d.item.logo.url }}" /></td> <td><img class="logo" src="{{ d.item.logo.url }}" alt="{{ d.item.name }} logo"></td>
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
@@ -27,7 +27,7 @@
</tr> </tr>
<tr> <tr>
<th class="w-33" scope="row">Country</th> <th class="w-33" scope="row">Country</th>
<td>{{ d.item.country.name }} <img src="{{ d.item.country.flag }}" alt="{{ d.item.country }}" /> <td>{{ d.item.country.name }} <img src="{{ d.item.country.flag }}" alt="{{ d.item.country }}">
</tr> </tr>
{% if d.item.freelance %} {% if d.item.freelance %}
<tr> <tr>

View File

@@ -2,12 +2,10 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<a href="{{ d.item.get_absolute_url }}"> <a href="{{ d.item.get_absolute_url }}">
{% if d.item.image %} {% if d.item.image %}
<img src="{{ d.item.image.url }}" alt="Card image cap"> <img class="card-img-top" src="{{ d.item.image.url }}" alt="{{ d.item }}">
{% else %} {% else %}
{% with d.item.consist_item.first.rolling_stock as r %} {% with d.item.consist_item.first.rolling_stock as r %}
{% for i in r.image.all %} <img class="card-img-top" src="{{ r.image.first.image.url }}" alt="{{ d.item }}">
{% if forloop.first %}<img src="{{ i.image.url }}" alt="Card image cap">{% endif %}
{% endfor %}
{% endwith %} {% endwith %}
{% endif %} {% endif %}
</a> </a>
@@ -46,7 +44,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row">Length</th> <th scope="row">Length</th>
<td>{{ d.item.consist_item.all | length }}</td> <td>{{ d.item.consist_item.count }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -14,7 +14,7 @@
{% if d.item.logo %} {% if d.item.logo %}
<tr> <tr>
<th class="w-33" scope="row">Logo</th> <th class="w-33" scope="row">Logo</th>
<td><img style="max-height: 48px" src="{{ d.item.logo.url }}" /></td> <td><img class="logo" src="{{ d.item.logo.url }}" alt="{{ d.item.name }} logo"></td>
</tr> </tr>
{% endif %} {% endif %}
{% if d.item.website %} {% if d.item.website %}

View File

@@ -2,10 +2,10 @@
<div class="col"> <div class="col">
<div class="card shadow-sm"> <div class="card shadow-sm">
{% if d.item.image.exists %} {% if d.item.image.exists %}
<a href="{{d.item.get_absolute_url}}"><img class="card-img-top" src="{{ d.item.image.first.image.url }}" alt="{{ d }}"></a> <a href="{{d.item.get_absolute_url}}"><img class="card-img-top" src="{{ d.item.image.first.image.url }}" alt="{{ d.item }}"></a>
{% else %} {% else %}
<!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) --> <!-- Do not show the "Coming soon" image when running in a single card column mode (e.g. on mobile) -->
<a href="{{d.item.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d }}"></a> <a href="{{d.item.get_absolute_url}}"><img class="card-img-top d-none d-sm-block" src="{% static DEFAULT_CARD_IMAGE %}" alt="{{ d.item }}"></a>
{% endif %} {% endif %}
<div class="card-body"> <div class="card-body">
<p class="card-text" style="position: relative;"> <p class="card-text" style="position: relative;">
@@ -60,7 +60,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row">Item number</th> <th scope="row">Item number</th>
<td>{{ d.item.item_number }}</td> <td>{{ d.item.item_number }}{%if d.item.set %} | <a class="badge text-bg-primary" href="{% url 'manufacturer' manufacturer=d.item.manufacturer search=d.item.item_number %}">SET</a>{% endif %}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -16,7 +16,7 @@
<div id="carouselControls" class="carousel carousel-dark slide" data-bs-ride="carousel"> <div id="carouselControls" class="carousel carousel-dark slide" data-bs-ride="carousel">
<div class="carousel-inner"> <div class="carousel-inner">
<div class="carousel-item active"> <div class="carousel-item active">
<img src="{{ consist.image.url }}" class="d-block w-100 rounded img-thumbnail" alt="..."> <img src="{{ consist.image.url }}" class="d-block w-100 rounded img-thumbnail" alt="Consist cover">
</div> </div>
</div> </div>
</div> </div>
@@ -66,10 +66,14 @@
<section class="py-4 text-start container"> <section class="py-4 text-start container">
<div class="row"> <div class="row">
<div class="mx-auto"> <div class="mx-auto">
<nav class="nav nav-tabs flex-column flex-md-row mb-2" id="nav-tab"> <nav class="nav nav-tabs d-none d-lg-flex flex-row mb-2" id="nav-tab" role="tablist">
<button class="nav-link active" id="nav-summary-tab" data-bs-toggle="tab" data-bs-target="#nav-summary" type="button" role="tab" aria-controls="nav-summary" aria-selected="true">Summary</button> <button class="nav-link active" id="nav-summary-tab" data-bs-toggle="tab" data-bs-target="#nav-summary" type="button" role="tab" aria-controls="nav-summary" aria-selected="true">Summary</button>
{% if consist.notes %}<button class="nav-link" id="nav-notes-tab" data-bs-toggle="tab" data-bs-target="#nav-notes" type="button" role="tab" aria-controls="nav-notes" aria-selected="false">Notes</button>{% endif %} {% if consist.notes %}<button class="nav-link" id="nav-notes-tab" data-bs-toggle="tab" data-bs-target="#nav-notes" type="button" role="tab" aria-controls="nav-notes" aria-selected="false">Notes</button>{% endif %}
</nav> </nav>
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
<option value="nav-summary" selected>Summary</option>
{% if consist.notes %}<option value="nav-notes">Notes</option>{% endif %}
</select>
<div class="tab-content" id="nav-tabContent"> <div class="tab-content" id="nav-tabContent">
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab"> <div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
<table class="table table-striped"> <table class="table table-striped">
@@ -81,7 +85,9 @@
<tbody class="table-group-divider"> <tbody class="table-group-divider">
<tr> <tr>
<th class="w-33" scope="row">Company</th> <th class="w-33" scope="row">Company</th>
<td><abbr title="{{ consist.company.extended_name }}">{{ consist.company }}</abbr></td> <td>
<a href="{% url 'filtered' _filter="company" search=consist.company.slug %}">{{ consist.company }}</a> ({{ consist.company.extended_name }})
</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Era</th> <th scope="row">Era</th>

View File

@@ -1,4 +1,7 @@
<div class="navbar-collapse justify-content-end" id="loginNavbar"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#loginNavbar" aria-controls="loginNavbar" aria-expanded="false" aria-label="Toggle navigation">
<span class="bi bi-person-fill-gear"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="loginNavbar">
<ul class="navbar-nav"> <ul class="navbar-nav">
<li class="nav-item dropdown"> <li class="nav-item dropdown">
{% if request.user.is_staff %} {% if request.user.is_staff %}
@@ -14,7 +17,6 @@
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{% url 'admin:index' %}">Admin</a></li> <li><a class="dropdown-item" href="{% url 'admin:index' %}">Admin</a></li>
<li><a class="dropdown-item" href="{% url 'admin:portal_siteconfiguration_changelist' %}">Site configuration</a></li> <li><a class="dropdown-item" href="{% url 'admin:portal_siteconfiguration_changelist' %}">Site configuration</a></li>
<li><a class="dropdown-item" href="{% url 'admin:driver_driverconfiguration_changelist' %}">DCC configuration</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="{% url 'admin:logout' %}?next={{ request.path }}">Logout</a></li> <li><a class="dropdown-item text-danger" href="{% url 'admin:logout' %}?next={{ request.path }}">Logout</a></li>
</ul> </ul>

View File

@@ -1,4 +1,4 @@
<form class="d-flex needs-validation" action="{% url 'search' %}" method="post" novalidate> <form class="d-flex needs-validation" action="{% url 'search' %}" method="post" novalidate>{% csrf_token %}
<div class="input-group has-validation"> <div class="input-group has-validation">
<input class="form-control" type="search" list="datalistOptions" placeholder="Search" aria-label="Search" name="search" id="searchValidation" required> <input class="form-control" type="search" list="datalistOptions" placeholder="Search" aria-label="Search" name="search" id="searchValidation" required>
<datalist id="datalistOptions"> <datalist id="datalistOptions">

View File

@@ -0,0 +1,40 @@
{% extends "cards.html" %}
{% block pagination %}
{% if data.has_other_pages %}
<nav aria-label="Page navigation example">
<ul class="pagination justify-content-center mt-4 mb-0">
{% if data.has_previous %}
<li class="page-item">
<a class="page-link" href="{% url 'manufacturer_pagination' manufacturer=manufacturer search=search page=data.previous_page_number %}#main-content" tabindex="-1">Previous</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Previous</span>
</li>
{% endif %}
{% for i in page_range %}
{% if data.number == i %}
<li class="page-item active">
<span class="page-link">{{ i }}</span>
</li>
{% else %}
{% if i == data.paginator.ELLIPSIS %}
<li class="page-item"><span class="page-link">{{ i }}</span></li>
{% else %}
<li class="page-item"><a class="page-link" href="{% url 'manufacturer_pagination' manufacturer=manufacturer search=search page=i %}#main-content">{{ i }}</a></li>
{% endif %}
{% endif %}
{% endfor %}
{% if data.has_next %}
<li class="page-item">
<a class="page-link" href="{% url 'manufacturer_pagination' manufacturer=manufacturer search=search page=data.next_page_number %}#main-content" tabindex="-1">Next</a>
</li>
{% else %}
<li class="page-item disabled">
<span class="page-link">Next</span>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}

View File

@@ -43,15 +43,31 @@
<section class="py-4 text-start container"> <section class="py-4 text-start container">
<div class="row"> <div class="row">
<div class="mx-auto"> <div class="mx-auto">
<nav class="nav nav-tabs flex-column flex-md-row mb-2" id="nav-tab"> <nav class="nav nav-tabs d-none d-lg-flex flex-row mb-2" id="nav-tab" role="tablist">
<button class="nav-link active" id="nav-summary-tab" data-bs-toggle="tab" data-bs-target="#nav-summary" type="button" role="tab" aria-controls="nav-summary" aria-selected="true">Summary</button> <button class="nav-link active" id="nav-summary-tab" data-bs-toggle="tab" data-bs-target="#nav-summary" type="button" role="tab" aria-controls="nav-summary" aria-selected="true">Summary</button>
<button class="nav-link" id="nav-model-tab" data-bs-toggle="tab" data-bs-target="#nav-model" type="button" role="tab" aria-controls="nav-model" aria-selected="false">Model data</button> <button class="nav-link" id="nav-model-tab" data-bs-toggle="tab" data-bs-target="#nav-model" type="button" role="tab" aria-controls="nav-model" aria-selected="false">Model</button>
<button class="nav-link" id="nav-class-tab" data-bs-toggle="tab" data-bs-target="#nav-class" type="button" role="tab" aria-controls="nav-class" aria-selected="false">Class data</button> <button class="nav-link" id="nav-class-tab" data-bs-toggle="tab" data-bs-target="#nav-class" type="button" role="tab" aria-controls="nav-class" aria-selected="false">Class</button>
<button class="nav-link" id="nav-company-tab" data-bs-toggle="tab" data-bs-target="#nav-company" type="button" role="tab" aria-controls="nav-company" aria-selected="false">Company</button>
{% if rolling_stock.decoder %}<button class="nav-link" id="nav-dcc-tab" data-bs-toggle="tab" data-bs-target="#nav-dcc" type="button" role="tab" aria-controls="nav-dcc" aria-selected="false">DCC</button>{% endif %} {% if rolling_stock.decoder %}<button class="nav-link" id="nav-dcc-tab" data-bs-toggle="tab" data-bs-target="#nav-dcc" type="button" role="tab" aria-controls="nav-dcc" aria-selected="false">DCC</button>{% endif %}
{% if documents or decoder_documents %}<button class="nav-link" id="nav-documents-tab" data-bs-toggle="tab" data-bs-target="#nav-documents" type="button" role="tab" aria-controls="nav-documents" aria-selected="false">Documents</button>{% endif %} {% if documents or decoder_documents %}<button class="nav-link" id="nav-documents-tab" data-bs-toggle="tab" data-bs-target="#nav-documents" type="button" role="tab" aria-controls="nav-documents" aria-selected="false">Documents</button>{% endif %}
{% if journal %}<button class="nav-link" id="nav-journal-tab" data-bs-toggle="tab" data-bs-target="#nav-journal" type="button" role="tab" aria-controls="nav-journal" aria-selected="false">Journal</button>{% endif %} {% if journal %}<button class="nav-link" id="nav-journal-tab" data-bs-toggle="tab" data-bs-target="#nav-journal" type="button" role="tab" aria-controls="nav-journal" aria-selected="false">Journal</button>{% endif %}
{% if rolling_stock.notes %}<button class="nav-link" id="nav-notes-tab" data-bs-toggle="tab" data-bs-target="#nav-notes" type="button" role="tab" aria-controls="nav-notes" aria-selected="false">Notes</button>{% endif %} {% if rolling_stock.notes %}<button class="nav-link" id="nav-notes-tab" data-bs-toggle="tab" data-bs-target="#nav-notes" type="button" role="tab" aria-controls="nav-notes" aria-selected="false">Notes</button>{% endif %}
{% if set %}<button class="nav-link" id="nav-set-tab" data-bs-toggle="tab" data-bs-target="#nav-set" type="button" role="tab" aria-controls="nav-set" aria-selected="false">Set</button>{% endif %}
{% if consists %}<button class="nav-link" id="nav-consists-tab" data-bs-toggle="tab" data-bs-target="#nav-consists" type="button" role="tab" aria-controls="nav-consists" aria-selected="false">Consists</button>{% endif %}
</nav> </nav>
<select class="form-select d-lg-none mb-2" id="tabSelector" aria-label="Tab selector">
<option value="nav-summary" selected>Summary</option>
<option value="nav-model">Model</option>
<option value="nav-class">Class</option>
<option value="nav-company">Company</option>
{% if rolling_stock.decoder %}<option value="nav-dcc">DCC</option>{% endif %}
{% if documents or decoder_documents %}<option value="nav-documents">Documents</option>{% endif %}
{% if journal %}<option value="nav-journal">Journal</option>{% endif %}
{% if rolling_stock.notes %}<option value="nav-notes">Notes</option>{% endif %}
{% if set %}<option value="nav-set">Set</option>{% endif %}
{% if consists %}<option value="nav-consists">Consists</option>{% endif %}
</select>
{% with class=rolling_stock.rolling_class company=rolling_stock.rolling_class.company %}
<div class="tab-content" id="nav-tabContent"> <div class="tab-content" id="nav-tabContent">
<div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab"> <div class="tab-pane show active" id="nav-summary" role="tabpanel" aria-labelledby="nav-summary-tab">
<table class="table table-striped"> <table class="table table-striped">
@@ -63,17 +79,17 @@
<tbody class="table-group-divider"> <tbody class="table-group-divider">
<tr> <tr>
<th class="w-33" scope="row">Type</th> <th class="w-33" scope="row">Type</th>
<td>{{ rolling_stock.rolling_class.type }}</td> <td>{{ class.type }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Company</th> <th scope="row">Company</th>
<td> <td>
<a href="{% url 'filtered' _filter="company" search=rolling_stock.rolling_class.company.slug %}"><abbr title="{{ rolling_stock.rolling_class.company.extended_name }}">{{ rolling_stock.rolling_class.company }}</abbr></a> <a href="{% url 'filtered' _filter="company" search=company.slug %}">{{ company }}</a> {{ company.extended_name_pp }}
</td> </td>
</tr> </tr>
<tr> <tr>
<th scope="row">Class</th> <th scope="row">Class</th>
<td>{{ rolling_stock.rolling_class.identifier }}</td> <td>{{ class.identifier }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Road number</th> <th scope="row">Road number</th>
@@ -95,7 +111,7 @@
<tr> <tr>
<th class="w-33" scope="row">Manufacturer</th> <th class="w-33" scope="row">Manufacturer</th>
<td>{%if rolling_stock.manufacturer %} <td>{%if rolling_stock.manufacturer %}
<a href="{% url 'filtered' _filter="manufacturer" search=rolling_stock.manufacturer.slug %}">{{ rolling_stock.manufacturer }}{% if rolling_stock.manufacturer.website %}</a> <a href="{{ rolling_stock.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %} <a href="{% url 'filtered' _filter="manufacturer" search=rolling_stock.manufacturer.slug %}">{{ rolling_stock.manufacturer }}</a>{% if rolling_stock.manufacturer.website %} <a href="{{ rolling_stock.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
{% endif %}</td> {% endif %}</td>
</tr> </tr>
<tr> <tr>
@@ -104,7 +120,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row">Item number</th> <th scope="row">Item number</th>
<td>{{ rolling_stock.item_number }}</td> <td>{{ rolling_stock.item_number }}{%if rolling_stock.set %} | <a class="badge text-bg-primary" href="{% url 'manufacturer' manufacturer=rolling_stock.manufacturer search=rolling_stock.item_number %}">SET</a>{% endif %}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -135,6 +151,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="tab-pane" id="nav-model" role="tabpanel" aria-labelledby="nav-model-tab"> <div class="tab-pane" id="nav-model" role="tabpanel" aria-labelledby="nav-model-tab">
{{ rolling_stock.description | safe }}
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
@@ -144,9 +161,11 @@
<tbody class="table-group-divider"> <tbody class="table-group-divider">
<tr> <tr>
<th class="w-33" scope="row">Manufacturer</th> <th class="w-33" scope="row">Manufacturer</th>
<td>{%if rolling_stock.manufacturer %} <td>
<a href="{% url 'filtered' _filter="manufacturer" search=rolling_stock.manufacturer.slug %}">{{ rolling_stock.manufacturer }}{% if rolling_stock.manufacturer.website %}</a> <a href="{{ rolling_stock.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %} {%if rolling_stock.manufacturer %}
{% endif %}</td> <a href="{% url 'filtered' _filter="manufacturer" search=rolling_stock.manufacturer.slug %}">{{ rolling_stock.manufacturer }}</a>{% if rolling_stock.manufacturer.website %} <a href="{{ rolling_stock.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
{% else %}-{% endif %}
</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Scale</th> <th scope="row">Scale</th>
@@ -154,7 +173,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row">Item number</th> <th scope="row">Item number</th>
<td>{{ rolling_stock.item_number }}</td> <td>{{ rolling_stock.item_number }}{%if rolling_stock.set %} | <a class="badge text-bg-primary" href="{% url 'manufacturer' manufacturer=rolling_stock.manufacturer search=rolling_stock.item_number %}">SET</a>{% endif %}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Era</th> <th scope="row">Era</th>
@@ -189,6 +208,7 @@
{% endif %} {% endif %}
</div> </div>
<div class="tab-pane" id="nav-class" role="tabpanel" aria-labelledby="nav-class-tab"> <div class="tab-pane" id="nav-class" role="tabpanel" aria-labelledby="nav-class-tab">
{{ class.description | safe }}
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
@@ -198,27 +218,19 @@
<tbody class="table-group-divider"> <tbody class="table-group-divider">
<tr> <tr>
<th class="w-33" scope="row">Class</th> <th class="w-33" scope="row">Class</th>
<td>{{ rolling_stock.rolling_class.identifier }}</td> <td>{{ class.identifier }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Type</th> <th scope="row">Type</th>
<td>{{ rolling_stock.rolling_class.type }}</td> <td>{{ class.type }}</td>
</tr>
<tr>
<th scope="row">Company</th>
<td>
<a href="{% url 'filtered' _filter="company" search=rolling_stock.rolling_class.company.slug %}">{{ rolling_stock.rolling_class.company }}</a> ({{ rolling_stock.rolling_class.company.extended_name }})
</td>
</tr>
<tr>
<th scope="row">Country</th>
<td>{{ rolling_stock.rolling_class.company.country.name }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Manufacturer</th> <th scope="row">Manufacturer</th>
<td>{%if rolling_stock.rolling_class.manufacturer %} <td>
<a href="{% url 'filtered' _filter="manufacturer" search=rolling_stock.rolling_class.manufacturer.slug %}">{{ rolling_stock.rolling_class.manufacturer }}{% if rolling_stock.rolling_class.manufacturer.website %}</a> <a href="{{ rolling_stock.rolling_class.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %} {%if class.manufacturer %}
{% endif %}</td> <a href="{% url 'filtered' _filter="manufacturer" search=class.manufacturer.slug %}">{{ class.manufacturer }}</a>{% if class.manufacturer.website %} <a href="{{ class.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
{% else %}-{% endif %}
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -240,11 +252,42 @@
</table> </table>
{% endif %} {% endif %}
</div> </div>
<div class="tab-pane" id="nav-company" role="tabpanel" aria-labelledby="nav-company-tab">
<table class="table table-striped">
<thead>
<tr>
<th colspan="2" scope="row">Company data</th>
</tr>
</thead>
<tbody class="table-group-divider">
{% if company.logo %}
<tr>
<th class="w-33" scope="row">Logo</th>
<td><img class="logo-xl" src="{{ company.logo.url }}" alt="{{ company }} logo"></td>
</tr>
{% endif %}
<tr>
<th class="w-33" scope="row">Name</th>
<td><a href="{% url 'filtered' _filter="company" search=company.slug %}">{{ company.name }}</a> {{ company.extended_name_pp }}</td>
</tr>
<tr>
<th class="w-33" scope="row">Country</th>
<td>{{ company.country.name }} <img src="{{ company.country.flag }}" alt="{{ company.country }}">
</tr>
{% if company.freelance %}
<tr>
<th class="w-33" scope="row">Notes</th>
<td>A <em>freelance</em> company</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<div class="tab-pane" id="nav-dcc" role="tabpanel" aria-labelledby="nav-dcc-tab"> <div class="tab-pane" id="nav-dcc" role="tabpanel" aria-labelledby="nav-dcc-tab">
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th colspan="2" scope="row">DCC data</th> <th colspan="2" scope="row">Decoder data</th>
</tr> </tr>
</thead> </thead>
<tbody class="table-group-divider"> <tbody class="table-group-divider">
@@ -317,14 +360,14 @@
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th colspan="3" scope="row">Journal</th> <th colspan="2" scope="row">Journal</th>
</tr> </tr>
</thead> </thead>
<tbody class="table-group-divider"> <tbody class="table-group-divider">
{% for j in journal %} {% for j in journal %}
<tr> <tr>
<th class="w-33" scope="row">{{ j.date }}</th> <th class="w-33" scope="row">{{ j.date }}</th>
<td>{{ j.log | safe }}</a></td> <td>{{ j.log | safe }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -333,7 +376,22 @@
<div class="tab-pane" id="nav-notes" role="tabpanel" aria-labelledby="nav-notes-tab"> <div class="tab-pane" id="nav-notes" role="tabpanel" aria-labelledby="nav-notes-tab">
{{ rolling_stock.notes | safe }} {{ rolling_stock.notes | safe }}
</div> </div>
<div class="tab-pane" id="nav-set" role="tabpanel" aria-labelledby="nav-set-tab">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3 mb-3">
{% for d in set %}
{% include "cards/roster.html" %}
{% endfor %}
</div>
</div>
<div class="tab-pane" id="nav-consists" role="tabpanel" aria-labelledby="nav-cosists-tab">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3 mb-3">
{% for d in consists %}
{% include "cards/consist.html" %}
{% endfor %}
</div>
</div>
</div> </div>
{% endwith %}
<div class="d-grid gap-2 d-md-flex justify-content-md-end"> <div class="d-grid gap-2 d-md-flex justify-content-md-end">
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:roster_rollingstock_change' rolling_stock.pk %}">Edit</a>{% endif %} {% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:roster_rollingstock_change' rolling_stock.pk %}">Edit</a>{% endif %}
</div> </div>

View File

@@ -3,7 +3,8 @@ from django.urls import path
from portal.views import ( from portal.views import (
GetData, GetData,
GetRoster, GetRoster,
GetRosterFiltered, GetObjectsFiltered,
GetManufacturerItem,
GetFlatpage, GetFlatpage,
GetRollingStock, GetRollingStock,
GetConsist, GetConsist,
@@ -14,13 +15,17 @@ from portal.views import (
Types, Types,
Books, Books,
GetBook, GetBook,
SearchRoster, SearchObjects,
) )
urlpatterns = [ urlpatterns = [
path("", GetData.as_view(template="home.html"), name="index"), path("", GetData.as_view(template="home.html"), name="index"),
path("roster", GetRoster.as_view(), name="roster"), path("roster", GetRoster.as_view(), name="roster"),
path("roster/<int:page>", GetRoster.as_view(), name="roster_pagination"), path(
"roster/page/<int:page>",
GetRoster.as_view(),
name="roster_pagination"
),
path( path(
"page/<str:flatpage>", "page/<str:flatpage>",
GetFlatpage.as_view(), GetFlatpage.as_view(),
@@ -32,13 +37,13 @@ urlpatterns = [
name="consists" name="consists"
), ),
path( path(
"consists/<int:page>", "consists/page/<int:page>",
Consists.as_view(template="consists.html"), Consists.as_view(template="consists.html"),
name="consists_pagination" name="consists_pagination"
), ),
path("consist/<uuid:uuid>", GetConsist.as_view(), name="consist"), path("consist/<uuid:uuid>", GetConsist.as_view(), name="consist"),
path( path(
"consist/<uuid:uuid>/<int:page>", "consist/<uuid:uuid>/page/<int:page>",
GetConsist.as_view(), GetConsist.as_view(),
name="consist_pagination", name="consist_pagination",
), ),
@@ -48,7 +53,7 @@ urlpatterns = [
name="companies" name="companies"
), ),
path( path(
"companies/<int:page>", "companies/page/<int:page>",
Companies.as_view(template="companies.html"), Companies.as_view(template="companies.html"),
name="companies_pagination", name="companies_pagination",
), ),
@@ -58,7 +63,7 @@ urlpatterns = [
name="manufacturers" name="manufacturers"
), ),
path( path(
"manufacturers/<str:category>/<int:page>", "manufacturers/<str:category>/page/<int:page>",
Manufacturers.as_view(template="manufacturers.html"), Manufacturers.as_view(template="manufacturers.html"),
name="manufacturers_pagination", name="manufacturers_pagination",
), ),
@@ -68,7 +73,7 @@ urlpatterns = [
name="scales" name="scales"
), ),
path( path(
"scales/<int:page>", "scales/page/<int:page>",
Scales.as_view(template="scales.html"), Scales.as_view(template="scales.html"),
name="scales_pagination" name="scales_pagination"
), ),
@@ -78,7 +83,7 @@ urlpatterns = [
name="types" name="types"
), ),
path( path(
"types/<int:page>", "types/page/<int:page>",
Types.as_view(template="types.html"), Types.as_view(template="types.html"),
name="types_pagination" name="types_pagination"
), ),
@@ -88,29 +93,49 @@ urlpatterns = [
name="books" name="books"
), ),
path( path(
"bookshelf/books/<int:page>", "bookshelf/books/page/<int:page>",
Books.as_view(template="bookshelf/books.html"), Books.as_view(template="bookshelf/books.html"),
name="books_pagination" name="books_pagination"
), ),
path("bookshelf/book/<uuid:uuid>", GetBook.as_view(), name="book"), path("bookshelf/book/<uuid:uuid>", GetBook.as_view(), name="book"),
path( path(
"search", "search",
SearchRoster.as_view(http_method_names=["post"]), SearchObjects.as_view(http_method_names=["post"]),
name="search", name="search",
), ),
path( path(
"search/<str:search>/<int:page>", "search/<str:search>/page/<int:page>",
SearchRoster.as_view(), SearchObjects.as_view(),
name="search_pagination", name="search_pagination",
), ),
path(
"manufacturer/<str:manufacturer>",
GetManufacturerItem.as_view(),
name="manufacturer",
),
path(
"manufacturer/<str:manufacturer>/page/<int:page>",
GetManufacturerItem.as_view(),
name="manufacturer_pagination",
),
path(
"manufacturer/<str:manufacturer>/<str:search>",
GetManufacturerItem.as_view(),
name="manufacturer",
),
path(
"manufacturer/<str:manufacturer>/<str:search>/page/<int:page>",
GetManufacturerItem.as_view(),
name="manufacturer_pagination",
),
path( path(
"<str:_filter>/<str:search>", "<str:_filter>/<str:search>",
GetRosterFiltered.as_view(), GetObjectsFiltered.as_view(),
name="filtered", name="filtered",
), ),
path( path(
"<str:_filter>/<str:search>/<int:page>", "<str:_filter>/<str:search>/page/<int:page>",
GetRosterFiltered.as_view(), GetObjectsFiltered.as_view(),
name="filtered_pagination", name="filtered_pagination",
), ),
path("<uuid:uuid>", GetRollingStock.as_view(), name="rolling_stock"), path("<uuid:uuid>", GetRollingStock.as_view(), name="rolling_stock"),

View File

@@ -7,7 +7,7 @@ from django.views import View
from django.http import Http404, HttpResponseBadRequest from django.http import Http404, HttpResponseBadRequest
from django.db.utils import OperationalError, ProgrammingError from django.db.utils import OperationalError, ProgrammingError
from django.db.models import Q from django.db.models import Q
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404, get_list_or_404
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.core.paginator import Paginator from django.core.paginator import Paginator
@@ -21,7 +21,15 @@ from metadata.models import (
) )
def order_by_fields(): def get_items_per_page():
try:
items_per_page = get_site_conf().items_per_page
except (OperationalError, ProgrammingError):
items_per_page = 6
return items_per_page
def get_order_by_field():
try: try:
order_by = get_site_conf().items_ordering order_by = get_site_conf().items_ordering
except (OperationalError, ProgrammingError): except (OperationalError, ProgrammingError):
@@ -46,19 +54,22 @@ class GetData(View):
title = "Home" title = "Home"
template = "roster.html" template = "roster.html"
item_type = "rolling_stock" item_type = "rolling_stock"
queryset = RollingStock.objects.order_by(*order_by_fields()) filter = Q() # empty filter by default
def get_data(self):
return RollingStock.objects.order_by(
*get_order_by_field()
).filter(self.filter)
def get(self, request, page=1): def get(self, request, page=1):
site_conf = get_site_conf()
data = [] data = []
for item in self.queryset: for item in self.get_data():
data.append({ data.append({
"type": self.item_type, "type": self.item_type,
"item": item "item": item
}) })
paginator = Paginator(data, site_conf.items_per_page) paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page) data = paginator.get_page(page)
page_range = paginator.get_elided_page_range( page_range = paginator.get_elided_page_range(
data.number, on_each_side=2, on_ends=1 data.number, on_each_side=2, on_ends=1
@@ -78,14 +89,15 @@ class GetData(View):
class GetRoster(GetData): class GetRoster(GetData):
title = "Rolling stock" title = "Roster"
item_type = "rolling_stock" item_type = "rolling_stock"
queryset = RollingStock.objects.order_by(*order_by_fields())
def get_data(self):
return RollingStock.objects.order_by(*get_order_by_field())
class SearchRoster(View): class SearchObjects(View):
def run_search(self, request, search, _filter, page=1): def run_search(self, request, search, _filter, page=1):
site_conf = get_site_conf()
if _filter is None: if _filter is None:
query = reduce( query = reduce(
operator.or_, operator.or_,
@@ -130,7 +142,7 @@ class SearchRoster(View):
rolling_stock = ( rolling_stock = (
RollingStock.objects.filter(query) RollingStock.objects.filter(query)
.distinct() .distinct()
.order_by(*order_by_fields()) .order_by(*get_order_by_field())
) )
for item in rolling_stock: for item in rolling_stock:
data.append({ data.append({
@@ -162,7 +174,7 @@ class SearchRoster(View):
"item": item "item": item
}) })
paginator = Paginator(data, site_conf.items_per_page) paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page) data = paginator.get_page(page)
page_range = paginator.get_elided_page_range( page_range = paginator.get_elided_page_range(
data.number, on_each_side=2, on_ends=1 data.number, on_each_side=2, on_ends=1
@@ -215,34 +227,82 @@ class SearchRoster(View):
return self.get(request, search, page) return self.get(request, search, page)
class GetRosterFiltered(View): class GetManufacturerItem(View):
def get(self, request, manufacturer, search="all", page=1):
if search != "all":
rolling_stock = get_list_or_404(
RollingStock.objects.order_by(*get_order_by_field()),
Q(
Q(manufacturer__name__iexact=manufacturer)
& Q(item_number__exact=search)
)
)
title = "{0}: {1}".format(
rolling_stock[0].manufacturer,
search
)
else:
rolling_stock = get_list_or_404(
RollingStock.objects.order_by(*get_order_by_field()),
Q(rolling_class__manufacturer__slug__iexact=manufacturer)
| Q(manufacturer__slug__iexact=manufacturer)
)
title = "Manufacturer: {0}".format(
get_object_or_404(Manufacturer, slug__iexact=manufacturer)
)
data = []
for item in rolling_stock:
data.append({
"type": "rolling_stock",
"item": item
})
paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page)
page_range = paginator.get_elided_page_range(
data.number, on_each_side=2, 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): def run_filter(self, request, search, _filter, page=1):
site_conf = get_site_conf()
if _filter == "type": if _filter == "type":
title = RollingStockType.objects.get(slug__iexact=search) title = get_object_or_404(RollingStockType, slug__iexact=search)
query = Q(rolling_class__type__slug__iexact=search) query = Q(rolling_class__type__slug__iexact=search)
elif _filter == "company": elif _filter == "company":
title = get_object_or_404(Company, slug__iexact=search) title = get_object_or_404(Company, slug__iexact=search)
query = Q(rolling_class__company__slug__iexact=search) query = Q(rolling_class__company__slug__iexact=search)
elif _filter == "manufacturer": query_2nd = Q(company__slug__iexact=search)
title = get_object_or_404(Manufacturer, slug__iexact=search)
query = Q(
Q(rolling_class__manufacturer__slug__iexact=search)
| Q(manufacturer__slug__iexact=search)
)
elif _filter == "scale": elif _filter == "scale":
title = get_object_or_404(Scale, slug__iexact=search) title = get_object_or_404(Scale, slug__iexact=search)
query = Q(scale__slug__iexact=search) query = Q(scale__slug__iexact=search)
query_2nd = Q(
consist_item__rolling_stock__scale__slug__iexact=search
)
elif _filter == "tag": elif _filter == "tag":
title = get_object_or_404(Tag, slug__iexact=search) title = get_object_or_404(Tag, slug__iexact=search)
query = Q(tags__slug__iexact=search) query = Q(tags__slug__iexact=search)
query_2nd = query # For tags the 2nd level query doesn't change
else: else:
raise Http404 raise Http404
rolling_stock = ( rolling_stock = (
RollingStock.objects.filter(query) RollingStock.objects.filter(query)
.distinct() .distinct()
.order_by(*order_by_fields()) .order_by(*get_order_by_field())
) )
data = [] data = []
@@ -252,9 +312,9 @@ class GetRosterFiltered(View):
"item": item "item": item
}) })
if _filter == "tag": try: # Execute only if query_2nd is defined
consists = ( consists = (
Consist.objects.filter(query) Consist.objects.filter(query_2nd)
.distinct() .distinct()
) )
for item in consists: for item in consists:
@@ -262,17 +322,20 @@ class GetRosterFiltered(View):
"type": "consist", "type": "consist",
"item": item "item": item
}) })
books = ( if _filter == "tag": # Books can be filtered only by tag
Book.objects.filter(query) books = (
.distinct() Book.objects.filter(query_2nd)
) .distinct()
for item in books: )
data.append({ for item in books:
"type": "book", data.append({
"item": item "type": "book",
}) "item": item
})
except NameError:
pass
paginator = Paginator(data, site_conf.items_per_page) paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page) data = paginator.get_page(page)
page_range = paginator.get_elided_page_range( page_range = paginator.get_elided_page_range(
data.number, on_each_side=2, on_ends=1 data.number, on_each_side=2, on_ends=1
@@ -331,6 +394,23 @@ class GetRollingStock(View):
private=False private=False
) )
consists = [{
"type": "consist",
"item": c
} for c in Consist.objects.filter(
consist_item__rolling_stock=rolling_stock
)] # A dict with "item" is required by the consists card
set = [{
"type": "set",
"item": s
} for s in RollingStock.objects.filter(
Q(
Q(item_number__exact=rolling_stock.item_number)
& Q(set=True)
)
).order_by(*get_order_by_field())]
return render( return render(
request, request,
"rollingstock.html", "rollingstock.html",
@@ -342,6 +422,8 @@ class GetRollingStock(View):
"decoder_documents": decoder_documents, "decoder_documents": decoder_documents,
"documents": documents, "documents": documents,
"journal": journal, "journal": journal,
"set": set,
"consists": consists,
}, },
) )
@@ -349,12 +431,13 @@ class GetRollingStock(View):
class Consists(GetData): class Consists(GetData):
title = "Consists" title = "Consists"
item_type = "consist" item_type = "consist"
queryset = Consist.objects.all()
def get_data(self):
return Consist.objects.all()
class GetConsist(View): class GetConsist(View):
def get(self, request, uuid, page=1): def get(self, request, uuid, page=1):
site_conf = get_site_conf()
try: try:
consist = Consist.objects.get(uuid=uuid) consist = Consist.objects.get(uuid=uuid)
except ObjectDoesNotExist: except ObjectDoesNotExist:
@@ -364,7 +447,7 @@ class GetConsist(View):
"item": RollingStock.objects.get(uuid=r.rolling_stock_id) "item": RollingStock.objects.get(uuid=r.rolling_stock_id)
} for r in consist.consist_item.all()] } for r in consist.consist_item.all()]
paginator = Paginator(data, site_conf.items_per_page) paginator = Paginator(data, get_items_per_page())
data = paginator.get_page(page) data = paginator.get_page(page)
page_range = paginator.get_elided_page_range( page_range = paginator.get_elided_page_range(
data.number, on_each_side=2, on_ends=1 data.number, on_each_side=2, on_ends=1
@@ -385,20 +468,25 @@ class GetConsist(View):
class Manufacturers(GetData): class Manufacturers(GetData):
title = "Manufacturers" title = "Manufacturers"
item_type = "manufacturer" item_type = "manufacturer"
queryset = None # Set via method get
def get_data(self):
return Manufacturer.objects.filter(self.filter)
# overload get method to filter by category # overload get method to filter by category
def get(self, request, category, page=1): def get(self, request, category, page=1):
if category not in ("real", "model"): if category not in ("real", "model"):
raise Http404 raise Http404
self.queryset = Manufacturer.objects.filter(category=category) self.filter = Q(category=category)
return super().get(request, page) return super().get(request, page)
class Companies(GetData): class Companies(GetData):
title = "Companies" title = "Companies"
item_type = "company" item_type = "company"
queryset = Company.objects.all()
def get_data(self):
return Company.objects.all()
class Scales(GetData): class Scales(GetData):
@@ -406,17 +494,24 @@ class Scales(GetData):
item_type = "scale" item_type = "scale"
queryset = Scale.objects.all() queryset = Scale.objects.all()
def get_data(self):
return Scale.objects.all()
class Types(GetData): class Types(GetData):
title = "Types" title = "Types"
item_type = "rolling_stock_type" item_type = "rolling_stock_type"
queryset = RollingStockType.objects.all()
def get_data(self):
return RollingStockType.objects.all()
class Books(GetData): class Books(GetData):
title = "Books" title = "Books"
item_type = "book" item_type = "book"
queryset = Book.objects.all()
def get_data(self):
return Book.objects.all()
class GetBook(View): class GetBook(View):
@@ -444,14 +539,15 @@ class GetBook(View):
class GetFlatpage(View): class GetFlatpage(View):
def get(self, request, flatpage): def get(self, request, flatpage):
_filter = Q(published=True) # Show only published pages
if request.user.is_authenticated:
_filter = Q() # Reset the filter if user is authenticated
try: try:
flatpage = Flatpage.objects.get(path=flatpage) flatpage = Flatpage.objects.filter(_filter).get(path=flatpage)
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise Http404 raise Http404
if not request.user.is_authenticated:
flatpage = flatpage.filter(published=False)
return render( return render(
request, request,
"flatpages/flatpage.html", "flatpages/flatpage.html",

View File

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

View File

@@ -1,10 +1,4 @@
from django.contrib import admin from django.contrib import admin
from django.db.utils import OperationalError, ProgrammingError from django.conf import settings
from portal.utils import get_site_conf
try: admin.site.site_header = settings.SITE_NAME
site_name = get_site_conf().site_name
except (OperationalError, ProgrammingError):
site_name = "Train Assets Manager"
admin.site.site_header = site_name

14
ram/ram/apps.py Normal file
View File

@@ -0,0 +1,14 @@
from django.conf import settings
from django.apps import AppConfig
class RamConfig(AppConfig):
name = "ram"
def ready(self):
cache_middleware = set([
"django.middleware.cache.UpdateCacheMiddleware",
"django.middleware.cache.FetchFromCacheMiddleware",
])
if cache_middleware.issubset(settings.MIDDLEWARE):
from ram.signals import clear_cache # noqa: F401

View File

@@ -1,7 +1,8 @@
# vim: syntax=python # vim: syntax=python
from django.conf import settings
""" """
Django local_settings for ram project. Example of changes suitable for production
""" """
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
@@ -12,9 +13,23 @@ SECRET_KEY = (
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = False DEBUG = False
# SECURITY WARNING: cache middlewares must be loaded before cookies one
MIDDLEWARE = [
"django.middleware.cache.UpdateCacheMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.cache.FetchFromCacheMiddleware",
] + settings.MIDDLEWARE
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://127.0.0.1:6379",
}
}
CACHE_MIDDLEWARE_SECONDS = 300
STORAGE_DIR = BASE_DIR / "storage" STORAGE_DIR = BASE_DIR / "storage"
ALLOWED_HOSTS = ["127.0.0.1"] ALLOWED_HOSTS = ["127.0.0.1", "myhost"]
CSRF_TRUSTED_ORIGINS = ["https://myhost"] CSRF_TRUSTED_ORIGINS = ["https://myhost"]
ROOT_URLCONF = "ram.urls"
STATIC_URL = "static/" STATIC_URL = "static/"
MEDIA_URL = "media/" MEDIA_URL = "media/"

View File

@@ -11,8 +11,6 @@ class Document(models.Model):
file = models.FileField( file = models.FileField(
upload_to="files/", upload_to="files/",
storage=DeduplicatedStorage(), storage=DeduplicatedStorage(),
null=True,
blank=True,
) )
private = models.BooleanField(default=False) private = models.BooleanField(default=False)
@@ -34,7 +32,8 @@ class Document(models.Model):
class Image(models.Model): class Image(models.Model):
order = models.PositiveIntegerField(default=0, blank=False, null=False) order = models.PositiveIntegerField(default=0, blank=False, null=False)
image = models.ImageField( image = models.ImageField(
upload_to="images/", storage=DeduplicatedStorage, null=True, blank=True upload_to="images/",
storage=DeduplicatedStorage,
) )
def image_thumbnail(self): def image_thumbnail(self):

View File

@@ -41,17 +41,14 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"health_check",
"health_check.db",
"adminsortable2", "adminsortable2",
"django_countries", "django_countries",
"solo", "solo",
"ckeditor", "tinymce",
"ckeditor_uploader",
"rest_framework", "rest_framework",
"ram", "ram",
"portal", "portal",
"driver", # "driver", # uncomment this to enable the "driver" API
"metadata", "metadata",
"roster", "roster",
"consist", "consist",
@@ -62,7 +59,7 @@ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
# 'django.middleware.csrf.CsrfViewMiddleware', "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
@@ -144,12 +141,31 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
MEDIA_URL = "media/" MEDIA_URL = "media/"
MEDIA_ROOT = STORAGE_DIR / "media" MEDIA_ROOT = STORAGE_DIR / "media"
CKEDITOR_UPLOAD_PATH = "uploads/"
TINYMCE_DEFAULT_CONFIG = {
"height": "500px",
"menubar": False,
"plugins": "autolink lists link image charmap preview anchor "
"searchreplace visualblocks code fullscreen insertdatetime media "
"table paste code",
"toolbar": "undo redo | "
"bold italic underline strikethrough removeformat | "
"fontsizeselect formatselect | "
"alignleft aligncenter alignright alignjustify | "
"outdent indent numlist bullist | "
"insertfile image media pageembed template link anchor codesample | "
"charmap | "
"fullscreen preview code",
"images_upload_url": "/tinymce/upload_image",
}
COUNTRIES_OVERRIDE = { COUNTRIES_OVERRIDE = {
"ZZ": "Freelance", "EU": "Europe",
"XX": "None",
} }
SITE_NAME = "Railroad Assets Manger"
# Image used on cards without a custom image uploaded. # Image used on cards without a custom image uploaded.
# 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"

8
ram/ram/signals.py Normal file
View File

@@ -0,0 +1,8 @@
from django.core.cache import cache
from django.db.models.signals import post_save
from django.dispatch import receiver
@receiver(post_save)
def clear_cache(sender, **kwargs):
cache.clear()

View File

@@ -13,24 +13,33 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.apps import apps
from django.conf import settings from django.conf import settings
from django.shortcuts import redirect from django.shortcuts import redirect
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from ram.views import UploadImage
urlpatterns = [ urlpatterns = [
path("", lambda r: redirect("portal/")), path("", lambda r: redirect("portal/")),
path("ckeditor/", include("ckeditor_uploader.urls")), path("tinymce/", include("tinymce.urls")),
path("tinymce/upload_image", UploadImage.as_view(), name="upload_image"),
path("portal/", include("portal.urls")), path("portal/", include("portal.urls")),
path("ht/", include("health_check.urls")),
path("admin/", admin.site.urls), path("admin/", admin.site.urls),
path("api/v1/consist/", include("consist.urls")), path("api/v1/consist/", include("consist.urls")),
path("api/v1/roster/", include("roster.urls")), path("api/v1/roster/", include("roster.urls")),
path("api/v1/dcc/", include("driver.urls")),
path("api/v1/bookshelf/", include("bookshelf.urls")), path("api/v1/bookshelf/", include("bookshelf.urls")),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# Enable the "/dcc" routing only if the "driver" app is active
if apps.is_installed("driver"):
urlpatterns += [
path("api/v1/dcc/", include("driver.urls")),
]
if settings.DEBUG: if settings.DEBUG:
from django.views.generic import TemplateView from django.views.generic import TemplateView
from rest_framework.schemas import get_schema_view from rest_framework.schemas import get_schema_view
@@ -54,3 +63,7 @@ if settings.DEBUG:
name="openapi-schema", name="openapi-schema",
), ),
] ]
if apps.is_installed("debug_toolbar"):
urlpatterns += [
path("__debug__/", include("debug_toolbar.urls")),
]

62
ram/ram/views.py Normal file
View File

@@ -0,0 +1,62 @@
import os
import datetime
import posixpath
from pathlib import Path
from PIL import Image, UnidentifiedImageError
from django.views import View
from django.conf import settings
from django.http import (
HttpResponseBadRequest,
HttpResponseForbidden,
JsonResponse,
)
from django.utils.text import slugify as slugify
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
@method_decorator(csrf_exempt, name="dispatch")
class UploadImage(View):
def post(self, request):
if not request.user.is_authenticated:
raise HttpResponseForbidden()
file_obj = request.FILES["file"]
file_name, file_extension = os.path.splitext(file_obj.name)
file_name = slugify(file_name) + file_extension
try:
Image.open(file_obj)
except UnidentifiedImageError:
return HttpResponseBadRequest()
today = datetime.date.today()
container = (
"uploads",
today.strftime("%Y"),
today.strftime("%m"),
today.strftime("%d"),
)
dir_path = os.path.join(settings.MEDIA_ROOT, *(p for p in container))
file_path = os.path.normpath(os.path.join(dir_path, file_name))
# even if we apply slugify to the file name, add more hardening
# to avoid any path transversal risk
if not file_path.startswith(str(settings.MEDIA_ROOT)):
return HttpResponseBadRequest()
Path(dir_path).mkdir(parents=True, exist_ok=True)
with open(file_path, "wb+") as f:
for chunk in file_obj.chunks():
f.write(chunk)
return JsonResponse(
{
"message": "Image uploaded successfully",
"location": posixpath.join(
settings.MEDIA_URL, *(p for p in container), file_name
),
}
)

View File

@@ -28,6 +28,7 @@ class RollingClass(admin.ModelAdmin):
"company__name", "company__name",
"type__type", "type__type",
) )
save_as = True
class RollingStockDocInline(admin.TabularInline): class RollingStockDocInline(admin.TabularInline):
@@ -140,7 +141,9 @@ class RollingStockAdmin(SortableAdminBase, admin.ModelAdmin):
"scale", "scale",
"manufacturer", "manufacturer",
"item_number", "item_number",
"set",
"era", "era",
"description",
"production_year", "production_year",
"purchase_date", "purchase_date",
"notes", "notes",

View File

@@ -1,6 +1,7 @@
# Generated by Django 4.1 on 2022-08-23 15:54 # Generated by Django 4.1 on 2022-08-23 15:54
import ckeditor_uploader.fields # ckeditor removal
# import ckeditor_uploader.fields
from django.db import migrations from django.db import migrations
@@ -11,9 +12,9 @@ class Migration(migrations.Migration):
] ]
operations = [ operations = [
migrations.AlterField( # migrations.AlterField(
model_name="rollingstock", # model_name="rollingstock",
name="notes", # name="notes",
field=ckeditor_uploader.fields.RichTextUploadingField(blank=True), # field=ckeditor_uploader.fields.RichTextUploadingField(blank=True),
), # ),
] ]

View File

@@ -1,6 +1,7 @@
# Generated by Django 4.1 on 2022-08-27 12:43 # Generated by Django 4.1 on 2022-08-27 12:43
import ckeditor_uploader.fields # ckeditor removal
# import ckeditor_uploader.fields
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
@@ -25,7 +26,8 @@ class Migration(migrations.Migration):
), ),
), ),
("date", models.DateField()), ("date", models.DateField()),
("log", ckeditor_uploader.fields.RichTextUploadingField()), # ("log", ckeditor_uploader.fields.RichTextUploadingField()),
("log", models.TextField()),
("private", models.BooleanField(default=False)), ("private", models.BooleanField(default=False)),
("creation_time", models.DateTimeField(auto_now_add=True)), ("creation_time", models.DateTimeField(auto_now_add=True)),
("updated_time", models.DateTimeField(auto_now=True)), ("updated_time", models.DateTimeField(auto_now=True)),

View File

@@ -0,0 +1,49 @@
# Generated by Django 4.2.6 on 2023-10-30 13:16
import os
import sys
import shutil
import ram.utils
import roster.models
from django.db import migrations, models
from django.conf import settings
def move_images(apps, schema_editor):
sys.stdout.write("\n Processing files. Please await...")
for r in roster.models.RollingStockImage.objects.all():
fname = os.path.basename(r.image.path)
new_image = roster.models.rolling_stock_image_upload(r, fname)
new_path = os.path.join(settings.MEDIA_ROOT, new_image)
os.makedirs(os.path.dirname(new_path), exist_ok=True)
try:
shutil.move(r.image.path, new_path)
except FileNotFoundError:
sys.stderr.write(" !! FileNotFoundError: {}\n".format(new_image))
pass
r.image.name = new_image
r.save()
class Migration(migrations.Migration):
dependencies = [
("roster", "0019_rollingstockdocument_private"),
]
operations = [
migrations.AlterField(
model_name="rollingstockimage",
name="image",
field=models.ImageField(
blank=True,
null=True,
storage=ram.utils.DeduplicatedStorage,
upload_to=roster.models.rolling_stock_image_upload,
),
),
migrations.RunPython(
move_images,
reverse_code=migrations.RunPython.noop
),
]

View File

@@ -0,0 +1,29 @@
# Generated by Django 4.2.6 on 2023-11-04 22:53
from django.db import migrations, models
import ram.utils
import roster.models
class Migration(migrations.Migration):
dependencies = [
("roster", "0020_alter_rollingstockimage_image"),
]
operations = [
migrations.AlterField(
model_name="rollingstockdocument",
name="file",
field=models.FileField(
storage=ram.utils.DeduplicatedStorage(), upload_to="files/"
),
),
migrations.AlterField(
model_name="rollingstockimage",
name="image",
field=models.ImageField(
storage=ram.utils.DeduplicatedStorage,
upload_to=roster.models.rolling_stock_image_upload,
),
),
]

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.0.2 on 2024-02-17 12:19
import tinymce.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("roster", "0021_alter_rollingstockdocument_file_and_more"),
]
operations = [
migrations.AlterField(
model_name="rollingstock",
name="notes",
field=tinymce.models.HTMLField(blank=True),
),
migrations.AlterField(
model_name="rollingstockjournal",
name="log",
field=tinymce.models.HTMLField(),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.0.2 on 2024-03-02 13:30
import tinymce.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("roster", "0022_alter_rollingstock_notes_and_more"),
]
operations = [
migrations.AlterField(
model_name="rollingclass",
name="description",
field=tinymce.models.HTMLField(blank=True),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 5.0.2 on 2024-03-02 14:30
import tinymce.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("roster", "0023_alter_rollingclass_description"),
]
operations = [
migrations.AddField(
model_name="rollingstock",
name="description",
field=tinymce.models.HTMLField(blank=True),
),
]

View File

@@ -0,0 +1,40 @@
# Generated by Django 5.0.4 on 2024-04-20 12:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("roster", "0024_rollingstock_description"),
]
operations = [
migrations.AddField(
model_name="rollingstock",
name="set",
field=models.BooleanField(default=False, help_text="Part of a set"),
),
migrations.AlterField(
model_name="rollingstock",
name="era",
field=models.CharField(
blank=True, help_text="Era or epoch of the model", max_length=32
),
),
migrations.AlterField(
model_name="rollingstock",
name="item_number",
field=models.CharField(
blank=True, help_text="Catalog item number or code", max_length=32
),
),
migrations.AlterField(
model_name="rollingstockjournal",
name="private",
field=models.BooleanField(
default=False,
help_text="Journal log will be visible only to logged users",
),
),
]

View File

@@ -1,14 +1,16 @@
import os
import re import re
import shutil
from uuid import uuid4 from uuid import uuid4
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.conf import settings from django.conf import settings
from django.dispatch import receiver from django.dispatch import receiver
from ckeditor_uploader.fields import RichTextUploadingField from tinymce import models as tinymce
from ram.models import Document, Image, PropertyInstance from ram.models import Document, Image, PropertyInstance
from ram.utils import get_image_preview from ram.utils import DeduplicatedStorage
from metadata.models import ( from metadata.models import (
Scale, Scale,
Manufacturer, Manufacturer,
@@ -23,7 +25,7 @@ class RollingClass(models.Model):
identifier = models.CharField(max_length=128, unique=False) identifier = models.CharField(max_length=128, unique=False)
type = models.ForeignKey(RollingStockType, on_delete=models.CASCADE) type = models.ForeignKey(RollingStockType, on_delete=models.CASCADE)
company = models.ForeignKey(Company, on_delete=models.CASCADE) company = models.ForeignKey(Company, on_delete=models.CASCADE)
description = models.CharField(max_length=256, blank=True) description = tinymce.HTMLField(blank=True)
manufacturer = models.ForeignKey( manufacturer = models.ForeignKey(
Manufacturer, Manufacturer,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@@ -72,7 +74,15 @@ class RollingStock(models.Model):
limit_choices_to={"category": "model"}, limit_choices_to={"category": "model"},
) )
scale = models.ForeignKey(Scale, on_delete=models.CASCADE) scale = models.ForeignKey(Scale, on_delete=models.CASCADE)
item_number = models.CharField(max_length=32, blank=True) item_number = models.CharField(
max_length=32,
blank=True,
help_text="Catalog item number or code",
)
set = models.BooleanField(
default=False,
help_text="Part of a set",
)
decoder_interface = models.PositiveSmallIntegerField( decoder_interface = models.PositiveSmallIntegerField(
choices=settings.DECODER_INTERFACES, null=True, blank=True choices=settings.DECODER_INTERFACES, null=True, blank=True
) )
@@ -80,13 +90,18 @@ class RollingStock(models.Model):
Decoder, on_delete=models.CASCADE, null=True, blank=True Decoder, on_delete=models.CASCADE, null=True, blank=True
) )
address = models.SmallIntegerField(default=None, null=True, blank=True) address = models.SmallIntegerField(default=None, null=True, blank=True)
era = models.CharField(max_length=32, blank=True) era = models.CharField(
max_length=32,
blank=True,
help_text="Era or epoch of the model",
)
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)
notes = RichTextUploadingField(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
) )
notes = tinymce.HTMLField(blank=True)
creation_time = models.DateTimeField(auto_now_add=True) creation_time = models.DateTimeField(auto_now_add=True)
updated_time = models.DateTimeField(auto_now=True) updated_time = models.DateTimeField(auto_now=True)
@@ -106,6 +121,15 @@ class RollingStock(models.Model):
def company(self): def company(self):
return str(self.rolling_class.company) return str(self.rolling_class.company)
def delete(self, *args, **kwargs):
shutil.rmtree(
os.path.join(
settings.MEDIA_ROOT, "images", "rollingstock", str(self.uuid)
),
ignore_errors=True
)
super(RollingStock, self).delete(*args, **kwargs)
@receiver(models.signals.pre_save, sender=RollingStock) @receiver(models.signals.pre_save, sender=RollingStock)
def pre_save_running_number(sender, instance, *args, **kwargs): def pre_save_running_number(sender, instance, *args, **kwargs):
@@ -126,10 +150,23 @@ class RollingStockDocument(Document):
unique_together = ("rolling_stock", "file") unique_together = ("rolling_stock", "file")
def rolling_stock_image_upload(instance, filename):
return os.path.join(
"images",
"rollingstock",
str(instance.rolling_stock.uuid),
filename
)
class RollingStockImage(Image): class RollingStockImage(Image):
rolling_stock = models.ForeignKey( rolling_stock = models.ForeignKey(
RollingStock, on_delete=models.CASCADE, related_name="image" RollingStock, on_delete=models.CASCADE, related_name="image"
) )
image = models.ImageField(
upload_to=rolling_stock_image_upload,
storage=DeduplicatedStorage,
)
class RollingStockProperty(PropertyInstance): class RollingStockProperty(PropertyInstance):
@@ -151,8 +188,11 @@ class RollingStockJournal(models.Model):
blank=False, blank=False,
) )
date = models.DateField() date = models.DateField()
log = RichTextUploadingField() log = tinymce.HTMLField()
private = models.BooleanField(default=False) private = models.BooleanField(
default=False,
help_text="Journal log will be visible only to logged users",
)
creation_time = models.DateTimeField(auto_now_add=True) creation_time = models.DateTimeField(auto_now_add=True)
updated_time = models.DateTimeField(auto_now=True) updated_time = models.DateTimeField(auto_now=True)

View File

@@ -1 +1,2 @@
gunicorn gunicorn
# Optional: # pylibmc

View File

@@ -7,6 +7,9 @@ django-solo
django-countries django-countries
django-health-check django-health-check
django-admin-sortable2 django-admin-sortable2
django-ckeditor django-tinymce
# psycopg2-binary # Optional: # psycopg2-binary
pySerial # Optional: # pySerial
# Required by django-countries and not always installed
# by default on modern venvs (like Python 3.12 on Fedora 39)
setuptools

BIN
sample_data/background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB