diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 4db0bfa5..7de35661 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -103,6 +103,16 @@ jobs: source .venv/bin/activate playwright install --with-deps + - name: Run PNPM Commands + run: | + uv run python scripts/pnpm_wrapper.py install + cat << 'EOF' > pnpm-workspace.yaml + onlyBuiltDependencies: + - '@parcel/watcher' + - '@tailwindcss/oxide' + EOF + uv run python scripts/pnpm_wrapper.py run build + - name: Wait for services run: | until pg_isready -h postgres -U postgres; do sleep 2; done diff --git a/envipath/urls.py b/envipath/urls.py index efdf92f5..799d89bd 100644 --- a/envipath/urls.py +++ b/envipath/urls.py @@ -34,3 +34,9 @@ if "migration" in s.INSTALLED_APPS: if s.MS_ENTRA_ENABLED: urlpatterns.append(path("", include("epauth.urls"))) + +# Custom error handlers +handler400 = "epdb.views.handler400" +handler403 = "epdb.views.handler403" +handler404 = "epdb.views.handler404" +handler500 = "epdb.views.handler500" diff --git a/epdb/views.py b/epdb/views.py index 7b8caf6d..57ab0a43 100644 --- a/epdb/views.py +++ b/epdb/views.py @@ -62,6 +62,26 @@ def log_post_params(request): logger.debug(f"{k}\t{v}") +def get_error_handler_context(request, for_user=None) -> Dict[str, Any]: + current_user = _anonymous_or_real(request) + + if for_user: + current_user = for_user + + ctx = { + "title": "enviPath", + "meta": { + "site_id": s.MATOMO_SITE_ID, + "version": "0.0.1", + "server_url": s.SERVER_URL, + "user": current_user, + "enabled_features": s.FLAGS, + "debug": s.DEBUG, + }, + } + return ctx + + def error(request, message: str, detail: str, code: int = 400): context = get_base_context(request) error_context = { @@ -76,6 +96,48 @@ def error(request, message: str, detail: str, code: int = 400): return render(request, "errors/error.html", context, status=code) +def handler400(request, exception): + """Custom 400 Bad Request error handler""" + context = get_error_handler_context(request) + context["public_mode"] = True + return render(request, "errors/400_bad_request.html", context, status=400) + + +def handler403(request, exception): + """Custom 403 Forbidden error handler""" + context = get_error_handler_context(request) + context["public_mode"] = True + return render(request, "errors/403_access_denied.html", context, status=403) + + +def handler404(request, exception): + """Custom 404 Not Found error handler""" + context = get_error_handler_context(request) + context["public_mode"] = True + return render(request, "errors/404_not_found.html", context, status=404) + + +def handler500(request): + """Custom 500 Internal Server Error handler""" + context = get_error_handler_context(request) + + error_context = {} + error_context["error_message"] = "Internal Server Error" + error_context["error_detail"] = "An unexpected error occurred. Please try again later." + + if request.headers.get("Accept") == "application/json": + return JsonResponse(error_context, status=500) + + context["public_mode"] = True + context["error_code"] = 500 + context["error_description"] = ( + "We encountered an unexpected error while processing your request. Our team has been notified and is working to resolve the issue." + ) + context.update(**error_context) + + return render(request, "errors/error.html", context, status=500) + + def login(request): context = get_base_context(request) @@ -192,8 +254,8 @@ def register(request): def editable(request, user): - if user.is_superuser: - return True + # if user.is_superuser: + # return True url = request.build_absolute_uri(request.path) if PackageManager.is_package_url(url): diff --git a/pyproject.toml b/pyproject.toml index afa77b85..3a0879dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ [tool.uv.sources] enviformer = { git = "ssh://git@git.envipath.com/enviPath/enviformer.git", rev = "v0.1.4" } envipy-plugins = { git = "ssh://git@git.envipath.com/enviPath/enviPy-plugins.git", rev = "v0.1.0" } -envipy-additional-information = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git", rev = "v0.1.7"} +envipy-additional-information = { git = "ssh://git@git.envipath.com/enviPath/enviPy-additional-information.git", rev = "v0.1.7" } envipy-ambit = { git = "ssh://git@git.envipath.com/enviPath/enviPy-ambit.git" } [project.optional-dependencies] @@ -46,7 +46,7 @@ dev = [ "pre-commit>=4.3.0", "ruff>=0.13.3", "pytest-playwright>=0.7.1", - "pytest-django>=4.11.1" + "pytest-django>=4.11.1", ] [tool.ruff] @@ -68,47 +68,31 @@ docstring-code-format = true [tool.poe.tasks] # Main tasks -setup = { sequence = ["db-up", "migrate", "bootstrap"], help = "Complete setup: start database, run migrations, and bootstrap data" } -dev = { shell = """ -# Start pnpm CSS watcher in background -pnpm run dev & -PNPM_PID=$! -echo "Started CSS watcher (PID: $PNPM_PID)" - -# Cleanup function -cleanup() { - echo "\nShutting down..." - if kill -0 $PNPM_PID 2>/dev/null; then - kill $PNPM_PID - echo "✓ CSS watcher stopped" - fi - if [ ! -z "${DJ_PID:-}" ] && kill -0 $DJ_PID 2>/dev/null; then - kill $DJ_PID - echo "✓ Django server stopped" - fi -} - -# Set trap for cleanup -trap cleanup EXIT INT TERM - -# Start Django dev server in background -uv run python manage.py runserver & -DJ_PID=$! - -# Wait for Django to finish -wait $DJ_PID -""", help = "Start the development server with CSS watcher", deps = ["db-up", "js-deps"] } -build = { sequence = ["build-frontend", "collectstatic", "frontend-test-setup"], help = "Build frontend assets and collect static files" } +setup = { sequence = [ + "db-up", + "migrate", + "bootstrap", +], help = "Complete setup: start database, run migrations, and bootstrap data" } +dev = { cmd = "uv run python scripts/dev_server.py", help = "Start the development server with CSS watcher", deps = [ + "db-up", + "js-deps", +] } +build = { sequence = [ + "build-frontend", + "collectstatic", +], help = "Build frontend assets and collect static files" } # Database tasks db-up = { cmd = "docker compose -f docker-compose.dev.yml up -d", help = "Start PostgreSQL database using Docker Compose" } db-down = { cmd = "docker compose -f docker-compose.dev.yml down", help = "Stop PostgreSQL database" } # Frontend tasks -js-deps = { cmd = "pnpm install", help = "Install frontend dependencies" } +js-deps = { cmd = "uv run python scripts/pnpm_wrapper.py install", help = "Install frontend dependencies" } # Full cleanup tasks -clean = { sequence = ["clean-db"], help = "Remove model files and database volumes (WARNING: destroys all data!)" } +clean = { sequence = [ + "clean-db", +], help = "Remove model files and database volumes (WARNING: destroys all data!)" } clean-db = { cmd = "docker compose -f docker-compose.dev.yml down -v", help = "Removes the database container and volume." } # Django tasks @@ -123,10 +107,17 @@ echo "Default admin credentials:" echo " Username: admin" echo " Email: admin@envipath.com" echo " Password: SuperSafe" -""", help = "Bootstrap initial data (anonymous user, packages, models)" } +""", help = "Bootstrap initial data (anonymous user, packages, models)" } shell = { cmd = "uv run python manage.py shell", help = "Open Django shell" } -# Build tasks -build-frontend = { cmd = "pnpm run build", help = "Build frontend assets using pnpm", deps = ["js-deps"] } -collectstatic = { cmd = "uv run python manage.py collectstatic --noinput", help = "Collect static files for production", deps = ["build-frontend"] } -frontend-test-setup = {cmd = "playwright install", help = "Install the browsers required for frontend testing"} + +build-frontend = { cmd = "uv run python scripts/pnpm_wrapper.py run build", help = "Build frontend assets using pnpm", deps = [ + "js-deps", +] } # Build tasks + + +collectstatic = { cmd = "uv run python manage.py collectstatic --noinput", help = "Collect static files for production", deps = [ + "build-frontend", +] } + +frontend-test-setup = { cmd = "playwright install", help = "Install the browsers required for frontend testing" } diff --git a/scripts/dev_server.py b/scripts/dev_server.py new file mode 100755 index 00000000..fc853151 --- /dev/null +++ b/scripts/dev_server.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +Cross-platform development server script. +Starts pnpm CSS watcher and Django dev server, handling cleanup on exit. +Works on both Windows and Unix systems. +""" + +import atexit +import shutil +import signal +import subprocess +import sys +import time + + +def find_pnpm(): + """ + Find pnpm executable on the system. + Returns the path to pnpm or None if not found. + """ + # Try to find pnpm using shutil.which + # On Windows, this will find pnpm.cmd if it's in PATH + pnpm_path = shutil.which("pnpm") + + if pnpm_path: + return pnpm_path + + # On Windows, also try pnpm.cmd explicitly + if sys.platform == "win32": + pnpm_cmd = shutil.which("pnpm.cmd") + if pnpm_cmd: + return pnpm_cmd + + return None + + +class DevServerManager: + """Manages background processes for development server.""" + + def __init__(self): + self.processes = [] + self._cleanup_registered = False + + def start_process(self, command, description, shell=False): + """Start a background process and return the process object.""" + print(f"Starting {description}...") + try: + if shell: + # Use shell=True for commands that need shell interpretation + process = subprocess.Popen( + command, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + else: + # Split command into list for subprocess + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + self.processes.append((process, description)) + print(f"✓ Started {description} (PID: {process.pid})") + return process + except Exception as e: + print(f"✗ Failed to start {description}: {e}", file=sys.stderr) + self.cleanup() + sys.exit(1) + + def cleanup(self): + """Terminate all running processes.""" + if not self.processes: + return + + print("\nShutting down...") + for process, description in self.processes: + if process.poll() is None: # Process is still running + try: + # Try graceful termination first + if sys.platform == "win32": + process.terminate() + else: + process.send_signal(signal.SIGTERM) + + # Wait up to 5 seconds for graceful shutdown + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + # Force kill if graceful shutdown failed + if sys.platform == "win32": + process.kill() + else: + process.send_signal(signal.SIGKILL) + process.wait() + + print(f"✓ {description} stopped") + except Exception as e: + print(f"✗ Error stopping {description}: {e}", file=sys.stderr) + + self.processes.clear() + + def register_cleanup(self): + """Register cleanup handlers for various exit scenarios.""" + if self._cleanup_registered: + return + + self._cleanup_registered = True + + # Register atexit handler (works on all platforms) + atexit.register(self.cleanup) + + # Register signal handlers (Unix only) + if sys.platform != "win32": + signal.signal(signal.SIGINT, self._signal_handler) + signal.signal(signal.SIGTERM, self._signal_handler) + + def _signal_handler(self, signum, frame): + """Handle Unix signals.""" + self.cleanup() + sys.exit(0) + + def wait_for_process(self, process, description): + """Wait for a process to finish and handle its output.""" + try: + # Stream output from the process + for line in iter(process.stdout.readline, ""): + if line: + print(f"[{description}] {line.rstrip()}") + + process.wait() + return process.returncode + except KeyboardInterrupt: + # Handle Ctrl+C + self.cleanup() + sys.exit(0) + except Exception as e: + print(f"Error waiting for {description}: {e}", file=sys.stderr) + self.cleanup() + sys.exit(1) + + +def main(): + """Main entry point.""" + manager = DevServerManager() + manager.register_cleanup() + + # Find pnpm executable + pnpm_path = find_pnpm() + if not pnpm_path: + print("Error: pnpm not found in PATH.", file=sys.stderr) + print("\nPlease install pnpm:", file=sys.stderr) + print(" Windows: https://pnpm.io/installation#on-windows", file=sys.stderr) + print(" Unix: https://pnpm.io/installation#on-posix-systems", file=sys.stderr) + sys.exit(1) + + # Determine shell usage based on platform + use_shell = sys.platform == "win32" + + # Start pnpm CSS watcher + # Use the found pnpm path to ensure it works on Windows + pnpm_command = f'"{pnpm_path}" run dev' if use_shell else [pnpm_path, "run", "dev"] + manager.start_process( + pnpm_command, + "CSS watcher", + shell=use_shell, + ) + + # Give pnpm a moment to start + time.sleep(1) + + # Start Django dev server + django_process = manager.start_process( + ["uv", "run", "python", "manage.py", "runserver"], + "Django server", + shell=False, + ) + + print("\nDevelopment servers are running. Press Ctrl+C to stop.\n") + + try: + # Wait for Django server (main process) + # If Django exits, we should clean up everything + return_code = manager.wait_for_process(django_process, "Django") + + # If Django exited unexpectedly, clean up and exit + if return_code != 0: + manager.cleanup() + sys.exit(return_code) + except KeyboardInterrupt: + # Ctrl+C was pressed + manager.cleanup() + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/scripts/pnpm_wrapper.py b/scripts/pnpm_wrapper.py new file mode 100755 index 00000000..6bade5d8 --- /dev/null +++ b/scripts/pnpm_wrapper.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +""" +Cross-platform pnpm command wrapper. +Finds pnpm correctly on Windows (handles pnpm.cmd) and Unix systems. +""" + +import shutil +import subprocess +import sys + + +def find_pnpm(): + """ + Find pnpm executable on the system. + Returns the path to pnpm or None if not found. + """ + # Try to find pnpm using shutil.which + # On Windows, this will find pnpm.cmd if it's in PATH + pnpm_path = shutil.which("pnpm") + + if pnpm_path: + return pnpm_path + + # On Windows, also try pnpm.cmd explicitly + if sys.platform == "win32": + pnpm_cmd = shutil.which("pnpm.cmd") + if pnpm_cmd: + return pnpm_cmd + + return None + + +def main(): + """Main entry point - execute pnpm with provided arguments.""" + pnpm_path = find_pnpm() + + if not pnpm_path: + print("Error: pnpm not found in PATH.", file=sys.stderr) + print("\nPlease install pnpm:", file=sys.stderr) + print(" Windows: https://pnpm.io/installation#on-windows", file=sys.stderr) + print(" Unix: https://pnpm.io/installation#on-posix-systems", file=sys.stderr) + sys.exit(1) + + # Get all arguments passed to this script + args = sys.argv[1:] + + # Execute pnpm with the provided arguments + try: + sys.exit(subprocess.call([pnpm_path] + args)) + except KeyboardInterrupt: + # Handle Ctrl+C gracefully + sys.exit(130) + except Exception as e: + print(f"Error executing pnpm: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/static/css/input.css b/static/css/input.css index 98ad1678..00694158 100644 --- a/static/css/input.css +++ b/static/css/input.css @@ -34,3 +34,30 @@ } @import "./daisyui-theme.css"; + +/* Loading Spinner - Benzene Ring */ +.loading-spinner { + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; +} + +.loading-spinner svg { + width: 48px; + height: 48px; + animation: spin 2s linear infinite; +} + +.loading-spinner .hexagon, +.loading-spinner .double-bonds { + fill: none; + stroke: currentColor; + stroke-width: 2; +} + +@keyframes spin { + 100% { + transform: rotate(360deg); + } +} diff --git a/static/images/wait.gif b/static/images/wait.gif deleted file mode 100644 index dd7eab8b..00000000 Binary files a/static/images/wait.gif and /dev/null differ diff --git a/static/js/alpine/index.js b/static/js/alpine/index.js new file mode 100644 index 00000000..494d0880 --- /dev/null +++ b/static/js/alpine/index.js @@ -0,0 +1,265 @@ +/** + * Alpine.js Components for enviPath + * + * This module provides reusable Alpine.js data components for modals, + * form validation, and form submission. + */ + +document.addEventListener('alpine:init', () => { + /** + * Modal Form Component + * + * Provides form validation using HTML5 Constraint Validation API, + * loading states for submission, and error message management. + * + * Basic Usage: + * + *
+ * + *
+ * + *
+ * + * With Custom State: + * + * +
Recent Jobs
+
+
+ + + + + + + + + + + {% for job in jobs %} @@ -58,7 +42,11 @@ {% if job.task_result and job.task_result|is_url == True %} - + {% elif job.task_result %} {% else %} @@ -70,19 +58,31 @@
IDNameStatusQueuedDoneResult
{{ job.created }} {{ job.done_at }}Result + Result + {{ job.task_result|slice:"40" }}...
- - - + + {% if objects %} + +
+
+ +
+
+ {% endif %} {% endblock content %} diff --git a/templates/collections/objects_list.html b/templates/collections/objects_list.html index bd214465..e574d862 100644 --- a/templates/collections/objects_list.html +++ b/templates/collections/objects_list.html @@ -1,28 +1,32 @@ -{% extends "framework.html" %} +{% extends "framework_modern.html" %} {% load static %} {% block content %} - {% if object_type != 'package' %} -
- + {# Serialize objects data for Alpine pagination #} + {# prettier-ignore-start #} + {# FIXME: This is a hack to get the objects data into the JavaScript code. #} + + {# prettier-ignore-end #} + {% if object_type != 'package' %} +
-

{% endif %} @@ -56,423 +60,474 @@ {% endif %} {% endblock action_modals %} -
-
-
- {% if object_type == 'package' %} - Packages - {% elif object_type == 'compound' %} - Compounds - {% elif object_type == 'structure' %} - Compound structures - {% elif object_type == 'rule' %} - Rules - {% elif object_type == 'reaction' %} - Reactions - {% elif object_type == 'pathway' %} - Pathways - {% elif object_type == 'node' %} - Nodes - {% elif object_type == 'edge' %} - Edges - {% elif object_type == 'scenario' %} - Scenarios - {% elif object_type == 'model' %} - Model - {% elif object_type == 'setting' %} - Settings - {% elif object_type == 'user' %} - Users - {% elif object_type == 'group' %} - Groups - {% endif %} - - - - + {% if objects %} + +
+
+ +
+
+ {% endif %}
- {# prettier-ignore-start #} - - {# prettier-ignore-end #} + } + + // Delete form submit handler + const deleteSubmit = document.getElementById("modal-form-delete-submit"); + const deleteForm = document.getElementById("modal-form-delete"); + if (deleteSubmit && deleteForm) { + deleteSubmit.addEventListener("click", function (e) { + e.preventDefault(); + deleteForm.submit(); + }); + } + }); + {% endblock content %} diff --git a/templates/errors/400_bad_request.html b/templates/errors/400_bad_request.html index 0848847d..7f59b2cd 100644 --- a/templates/errors/400_bad_request.html +++ b/templates/errors/400_bad_request.html @@ -1,18 +1,77 @@ -{% extends "framework.html" %} +{% extends "framework_modern.html" %} {% load static %} {% block content %} -
diff --git a/templates/modals/collections/import_package_modal.html b/templates/modals/collections/import_package_modal.html index 2d39489b..87262db9 100644 --- a/templates/modals/collections/import_package_modal.html +++ b/templates/modals/collections/import_package_modal.html @@ -1,70 +1,83 @@ -