Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/cd_prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ jobs:
sudo git fetch origin -v --tags;
if [ \"\$VERSION\" != 'main' ]; then
if ! sudo git rev-parse --verify --quiet \"\$VERSION^{commit}\" >/dev/null; then
echo "::error::Invalid deploy version: \$VERSION";
echo \"::error::Invalid deploy version: \$VERSION\";
exit 1;
fi;
fi"
Expand Down
7 changes: 3 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,11 @@ web-app/frontend/node_modules/
web-app/frontend/assets/css/

# Playwright test artifacts
web-app/frontend/tests/test-results/
web-app/frontend/tests/playwright-report/
web-app/frontend/tests/.auth/*
web-app/frontend/tests/.auth/
web-app/frontend/tests/test-results/
web-app/frontend/tests/playwright-report/

# Cursor
# LLM
.cursor/
.claude/
CLAUDE.md
4 changes: 3 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ repos:
require_serial: false

- repo: https://github.com/djlint/djLint
rev: v1.34.1
rev: v1.36.4
hooks:
- id: djlint-reformat-django
args: [--configuration=.djlintrc]
- id: djlint-django
args: [--configuration=.djlintrc]
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Frontend tests (Vitest)
test-frontend:
docker-compose exec app npm test --prefix /virtual-instrument-museum/frontend
docker compose exec app npm test --prefix /virtual-instrument-museum/frontend

.PHONY: test-frontend

3 changes: 3 additions & 0 deletions docker-compose-deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ services:
volumes:
- ./web-app/django:/virtual-instrument-museum/vim-app
- vim-static:/virtual-instrument-museum/static
- vim-media:/virtual-instrument-museum/media
depends_on:
postgres:
condition: service_healthy
Expand Down Expand Up @@ -53,6 +54,7 @@ services:
volumes:
- ./web-app/frontend/assets/:/virtual-instrument-museum/frontend/assets/
- vim-static:/virtual-instrument-museum/static
- vim-media:/virtual-instrument-museum/media
depends_on:
- app

Expand All @@ -69,4 +71,5 @@ networks:

volumes:
vim-static:
vim-media:
umil-solr:
3 changes: 3 additions & 0 deletions docker-compose-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ services:
volumes:
- ./web-app/django:/virtual-instrument-museum/vim-app
- vim-static:/virtual-instrument-museum/static
- vim-media:/virtual-instrument-museum/media
depends_on:
postgres-test:
condition: service_healthy
Expand Down Expand Up @@ -64,6 +65,7 @@ services:
volumes:
- ./web-app/frontend/assets/:/virtual-instrument-museum/frontend/assets/
- vim-static:/virtual-instrument-museum/static
- vim-media:/virtual-instrument-museum/media
depends_on:
app:
condition: service_healthy
Expand All @@ -89,4 +91,5 @@ networks:

volumes:
vim-static:
vim-media:
umil-solr:
3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ services:
- ./web-app/frontend/:/virtual-instrument-museum/frontend
- /virtual-instrument-museum/frontend/node_modules
- vim-static:/virtual-instrument-museum/static
- vim-media:/virtual-instrument-museum/media
depends_on:
postgres:
condition: service_healthy
Expand Down Expand Up @@ -58,6 +59,7 @@ services:
volumes:
- ./web-app/frontend/assets/:/virtual-instrument-museum/frontend/assets/
- vim-static:/virtual-instrument-museum/static
- vim-media:/virtual-instrument-museum/media
depends_on:
- app

Expand All @@ -76,4 +78,5 @@ networks:

volumes:
vim-static:
vim-media:
umil-solr:
4 changes: 4 additions & 0 deletions nginx/vim.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ server {
root /virtual-instrument-museum/;
}

location /media/ {
alias /virtual-instrument-museum/media/;
}

location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # pass client address to upstream server
proxy_set_header X-Forwarded-Proto $scheme; # pass scheme to upstream server
Expand Down
2,816 changes: 1,538 additions & 1,278 deletions poetry.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ gunicorn = "21.2.0"
pillow = "^10.4.0"
django-vite = "^3.1.0"
pysolr = "^3.10.0"
django-cleanup = "^8.0.0"
django-ratelimit = "^4.1.0"


[tool.poetry.group.dev.dependencies]
Expand Down
88 changes: 88 additions & 0 deletions web-app/django/VIM/apps/instruments/error_codes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Centralized error codes and messages for instrument operations."""

from typing import Dict


class ErrorCode:
"""
Error codes for instrument-related operations.

These codes provide consistent identifiers for different error scenarios
and are included in API responses for client-side error handling.
"""

# Validation errors (400)
VALIDATION_ERROR = "VALIDATION_ERROR"
MISSING_REQUIRED_DATA = "MISSING_REQUIRED_DATA"
INVALID_LANGUAGE_CODE = "INVALID_LANGUAGE_CODE"
INVALID_HBS_CLASSIFICATION = "INVALID_HBS_CLASSIFICATION"
INVALID_IMAGE_TYPE = "INVALID_IMAGE_TYPE"
INVALID_IMAGE_SIZE = "INVALID_IMAGE_SIZE"
FIELD_TOO_LONG = "FIELD_TOO_LONG"
INVALID_JSON_FORMAT = "INVALID_JSON_FORMAT"

# Duplicate detection errors (400)
DUPLICATE_NAME_IN_REQUEST = "DUPLICATE_NAME_IN_REQUEST"
DUPLICATE_NAME_IN_DATABASE = "DUPLICATE_NAME_IN_DATABASE"

# Server errors (500)
DATABASE_ERROR = "DATABASE_ERROR"
INDEXING_ERROR = "INDEXING_ERROR"
IMAGE_PROCESSING_ERROR = "IMAGE_PROCESSING_ERROR"
INTERNAL_ERROR = "INTERNAL_ERROR"

# Rate limiting (429)
RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED"

# Not found (404)
INSTRUMENT_NOT_FOUND = "INSTRUMENT_NOT_FOUND"
NAME_NOT_FOUND = "NAME_NOT_FOUND"

# Permission errors (403)
PERMISSION_DENIED = "PERMISSION_DENIED"


# User-facing error messages (safe to show to clients)
ERROR_MESSAGES: Dict[str, str] = {
ErrorCode.VALIDATION_ERROR: "The submitted data is invalid. Please check your inputs and try again.",
ErrorCode.MISSING_REQUIRED_DATA: "Required data is missing. Please fill in all required fields.",
ErrorCode.INVALID_LANGUAGE_CODE: "One or more language codes are invalid.",
ErrorCode.INVALID_HBS_CLASSIFICATION: "Valid Hornbostel-Sachs classification (at least 2 digits) is required.",
ErrorCode.INVALID_IMAGE_TYPE: "Invalid image type. Allowed types: JPEG, PNG, GIF, WebP.",
ErrorCode.INVALID_IMAGE_SIZE: "Image file size must be less than 5MB.",
ErrorCode.FIELD_TOO_LONG: "One or more fields exceed the maximum allowed length.",
ErrorCode.INVALID_JSON_FORMAT: "Invalid data format. Please check your request and try again.",
ErrorCode.DUPLICATE_NAME_IN_REQUEST: "Duplicate entries detected in your submission.",
ErrorCode.DUPLICATE_NAME_IN_DATABASE: "An instrument with this name already exists.",
ErrorCode.DATABASE_ERROR: "A database error occurred. Please try again later.",
ErrorCode.INDEXING_ERROR: "The instrument was created but search indexing failed. It will be indexed automatically.",
ErrorCode.IMAGE_PROCESSING_ERROR: "An error occurred while processing the image.",
ErrorCode.INTERNAL_ERROR: "An internal server error occurred. Please try again later.",
ErrorCode.RATE_LIMIT_EXCEEDED: "Rate limit exceeded. You can create up to 10 instruments per hour. Please try again later.",
ErrorCode.INSTRUMENT_NOT_FOUND: "The requested instrument does not exist.",
ErrorCode.NAME_NOT_FOUND: "The requested instrument name does not exist.",
ErrorCode.PERMISSION_DENIED: "You do not have permission to perform this action.",
}


def get_error_message(error_code: str, **kwargs) -> str:
"""
Get user-friendly error message for a given error code.

Args:
error_code: Error code from ErrorCode class
**kwargs: Optional parameters for message formatting

Returns:
User-friendly error message
"""
message = ERROR_MESSAGES.get(error_code, ERROR_MESSAGES[ErrorCode.INTERNAL_ERROR])

# Allow dynamic message formatting
if kwargs:
try:
return message.format(**kwargs)
except KeyError:
return message

return message
73 changes: 73 additions & 0 deletions web-app/django/VIM/apps/instruments/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Custom exceptions for instrument operations."""

from typing import Any, Dict, Optional


class InstrumentException(Exception):
"""
Base exception for all instrument-related errors.

Attributes:
error_code: Machine-readable error code
message: User-friendly error message
status_code: HTTP status code to return
details: Additional error details (only logged, never sent to client)
"""

def __init__(
self,
error_code: str,
message: str,
status_code: int = 500,
details: Optional[Dict[str, Any]] = None,
):
self.error_code = error_code
self.message = message
self.status_code = status_code
self.details = details or {}
super().__init__(message)


class ValidationException(InstrumentException):
"""Raised when validation fails (HTTP 400)."""

def __init__(
self, error_code: str, message: str, details: Optional[Dict[str, Any]] = None
):
super().__init__(error_code, message, status_code=400, details=details)


class DuplicateException(InstrumentException):
"""Raised when duplicate data is detected (HTTP 400)."""

def __init__(
self, error_code: str, message: str, details: Optional[Dict[str, Any]] = None
):
super().__init__(error_code, message, status_code=400, details=details)


class NotFoundException(InstrumentException):
"""Raised when a resource is not found (HTTP 404)."""

def __init__(
self, error_code: str, message: str, details: Optional[Dict[str, Any]] = None
):
super().__init__(error_code, message, status_code=404, details=details)


class PermissionException(InstrumentException):
"""Raised when user lacks permission (HTTP 403)."""

def __init__(
self, error_code: str, message: str, details: Optional[Dict[str, Any]] = None
):
super().__init__(error_code, message, status_code=403, details=details)


class DatabaseException(InstrumentException):
"""Raised when database operations fail (HTTP 500)."""

def __init__(
self, error_code: str, message: str, details: Optional[Dict[str, Any]] = None
):
super().__init__(error_code, message, status_code=500, details=details)
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from PIL import Image
from django.conf import settings
from django.core.management.base import BaseCommand
from VIM.apps.instruments.utils.image_processor import create_thumbnail_image


class Command(BaseCommand):
Expand Down Expand Up @@ -46,50 +47,12 @@ def _save_image_as_png(self, img_content, url, save_path):
except IOError as e:
self.stderr.write(f"Failed to save image from {url}: {e}")

def calculate_compression_ratio(self, original_width, original_height):
"""
Calculate a flexible compression ratio based on the original dimensions of an image.

Parameters:
original_width (int): The width of the original image.
original_height (int): The height of the original image.

Returns:
float: The compression ratio.
"""
# Determine the larger dimension to base compression on (could be width or height)
max_dimension = max(original_width, original_height)

# Set a target size for compression based on original dimensions
if max_dimension > 4000:
# Large images (e.g., 4K or higher): compress significantly
compression_ratio = 0.2 # 20% of the original size
elif max_dimension > 2000:
# Medium-large images: moderate compression
compression_ratio = 0.5 # 50% of the original size
elif max_dimension > 1000:
# Medium images: light compression
compression_ratio = 0.75 # 75% of the original size
else:
# Small images: minimal compression
compression_ratio = 0.9 # 90% of the original size

return compression_ratio

def create_thumbnail(self, image_path, thumbnail_path):
"""Create a thumbnail of an image."""
"""Create a thumbnail of an image using shared utility."""
try:
with Image.open(image_path) as original_img:
original_width, original_height = original_img.size
compression_ratio = self.calculate_compression_ratio(
original_width, original_height
)
new_size = (
int(original_width * compression_ratio),
int(original_height * compression_ratio),
)
original_img.thumbnail(new_size)
original_img.save(thumbnail_path, "PNG")
thumbnail = create_thumbnail_image(original_img)
thumbnail.save(thumbnail_path, "PNG")
self.stdout.write(f"Created thumbnail at {thumbnail_path}")
except IOError as e:
self.stderr.write(f"Failed to create thumbnail for {image_path}: {e}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,14 +121,25 @@ def create_database_objects(
ins_aliases = instrument_attrs.pop("ins_aliases")

# Create instrument using only the remaining valid fields
instrument, _ = Instrument.objects.update_or_create(
# Generate UMIL ID for new instruments
instrument, created = Instrument.objects.update_or_create(
wikidata_id=instrument_attrs["wikidata_id"],
defaults={
"hornbostel_sachs_class": instrument_attrs["hornbostel_sachs_class"],
"mimo_class": instrument_attrs["mimo_class"],
},
)

# If newly created and no umil_id, generate one.
# Wrap in its own atomic block so the select_for_update() lock inside
# generate_umil_id() is scoped to this savepoint and released as soon
# as the save completes, rather than being held for the entire outer
# transaction (the full import loop).
if created and not instrument.umil_id:
with transaction.atomic():
instrument.umil_id = Instrument.generate_umil_id()
instrument.save(update_fields=["umil_id"])

# Create or update instrument labels in the database (umil_label=True)
for lang, name in ins_names.items():
# Skip if the language code is not found in the database.
Expand Down Expand Up @@ -184,13 +195,15 @@ def create_database_objects(
type="image",
format=original_img_path.split(".")[-1],
url=original_img_path,
source_name="Wikidata",
)
instrument.default_image = img_obj
thumbnail_obj = AVResource.objects.create(
instrument=instrument,
type="image",
format=thumbnail_img_path.split(".")[-1],
url=thumbnail_img_path,
source_name="Wikidata",
)
instrument.thumbnail = thumbnail_obj
instrument.save()
Expand Down
Loading