diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 00000000..863c0643 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,116 @@ +name: CI + +on: + pull_request: + branches: + - develop + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: ${{ vars.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ vars.POSTGRES_DB }} + ports: + - ${{ vars.POSTGRES_PORT}}:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + #redis: + # image: redis:7 + # ports: + # - 6379:6379 + # options: >- + # --health-cmd "redis-cli ping" + # --health-interval=10s + # --health-timeout=5s + # --health-retries=5 + + env: + RUNNER_TOOL_CACHE: /toolcache + EP_DATA_DIR: /opt/enviPy/ + ALLOWED_HOSTS: 127.0.0.1,localhost + DEBUG: True + LOG_LEVEL: DEBUG + MODEL_BUILDING_ENABLED: True + APPLICABILITY_DOMAIN_ENABLED: True + ENVIFORMER_PRESENT: True + ENVIFORMER_DEVICE: cpu + FLAG_CELERY_PRESENT: False + PLUGINS_ENABLED: True + SERVER_URL: http://localhost:8000 + ADMIN_APPROVAL_REQUIRED: True + REGISTRATION_MANDATORY: True + LOG_DIR: '' + # DB + POSTGRES_SERVICE_NAME: postgres + POSTGRES_DB: ${{ vars.POSTGRES_DB }} + POSTGRES_USER: ${{ vars.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_PORT: 5432 + # SENTRY + SENTRY_ENABLED: False + # MS ENTRA + MS_ENTRA_ENABLED: False + + steps: + + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install system tools via apt + run: | + sudo apt-get update + sudo apt-get install -y postgresql-client redis-tools openjdk-11-jre-headless + + - name: Setup ssh + run: | + echo "${{ secrets.ENVIPY_CI_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + ssh-keyscan git.envipath.com >> ~/.ssh/known_hosts + eval $(ssh-agent -s) + ssh-add ~/.ssh/id_ed25519 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Setup venv + run: | + uv sync --locked --all-extras --dev + + - name: Wait for services + run: | + until pg_isready -h postgres -U postgres; do sleep 2; done + # until redis-cli -h redis ping; do sleep 2; done + + - name: Run Django migrations + run: | + source .venv/bin/activate + python manage.py migrate --noinput + + - name: Run Django tests + run: | + source .venv/bin/activate + python manage.py test tests --exclude-tag slow diff --git a/.gitignore b/.gitignore index a1bce921..06916665 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,8 @@ scratches/ data/ .DS_Store + +node_modules/ +static/css/output.css + +*.code-workspace diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ee857556..1c918614 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,6 +8,7 @@ repos: - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files + exclude: ^static/images/ - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.13.3 @@ -20,6 +21,15 @@ repos: - id: ruff-format types_or: [python, pyi] + - repo: local + hooks: + - id: prettier-jinja-templates + name: Format Jinja templates with Prettier + entry: pnpm exec prettier --plugin=prettier-plugin-jinja-template --parser=jinja-template --write + language: system + types: [file] + files: ^templates/.*\.html$ + # - repo: local # hooks: # - id: django-check diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..6e465c62 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "plugins": ["prettier-plugin-jinja-template", "prettier-plugin-tailwindcss"], + "overrides": [ + { + "files": "templates/**/*.html", + "options": { + "parser": "jinja-template" + } + } + ] +} diff --git a/README.md b/README.md index 41287e9a..07842b93 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,13 @@ These instructions will guide you through setting up the project for local devel ### Prerequisites - Python 3.11 or later -- [uv](https://github.com/astral-sh/uv) - A fast Python package installer and resolver. -- **Docker and Docker Compose** - Required for running the PostgreSQL database. +- [uv](https://github.com/astral-sh/uv) - Python package manager +- **Docker and Docker Compose** - Required for running PostgreSQL database - Git +- Make + +> **Note:** This application requires PostgreSQL (uses `ArrayField`). Docker is the easiest way to run PostgreSQL locally. -> **Note:** This application requires PostgreSQL, which uses `ArrayField`. Docker is the recommended way to run PostgreSQL locally. ### 1. Install Dependencies @@ -23,7 +25,12 @@ Then, sync the project dependencies. This will create a virtual environment in ` uv sync --dev ``` -> **Note on RDkit:** If you have a different version of rdkit installed globally, the dependency installation may fail. If this happens, please uninstall the global version and run `uv sync` again. +Note on RDkit installation: if you have rdkit installed on your system globally with a different version of python, the installation will try to link against that and subsequent calls fail. Only option remove global rdkit and rerun sync. + +--- + +The frontend requires `pnpm` to correctly display in development. +[Install it here](https://pnpm.io/installation). ### 2. Set Up Environment File @@ -44,6 +51,7 @@ uv run poe setup ``` This single command will: + 1. Start the PostgreSQL database using Docker Compose. 2. Run database migrations. 3. Bootstrap initial data (anonymous user, default packages, models). @@ -54,9 +62,12 @@ After setup, start the development server: uv run poe dev ``` +This will start the css-watcher as well as the django-development server, The application will be available at `http://localhost:8000`. -#### Other useful Poe commands: +**Note:** The development server automatically starts a CSS watcher (`pnpm run dev`) alongside the Django server to rebuild CSS files when changes are detected. This ensures your styles are always up-to-date during development. + +#### Other useful Poe commands You can list all available commands by running `uv run poe --help`. @@ -66,6 +77,7 @@ uv run poe db-down # Stop PostgreSQL uv run poe migrate # Run migrations only uv run poe bootstrap # Bootstrap data only uv run poe shell # Open the Django shell +uv run poe build # Build frontend assets and collect static files uv run poe clean # Remove database volumes (WARNING: destroys all data) ``` diff --git a/envipath/settings.py b/envipath/settings.py index 6fdac345..095105a3 100644 --- a/envipath/settings.py +++ b/envipath/settings.py @@ -87,11 +87,14 @@ TEMPLATES = [ "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", + "epdb.context_processors.package_context", ], }, }, ] +ALLOWED_HTML_TAGS = {"b", "i", "u", "br", "em", "mark", "p", "s", "strong"} + WSGI_APPLICATION = "envipath.wsgi.application" # Database @@ -243,6 +246,7 @@ LOGGING = { ENVIFORMER_PRESENT = os.environ.get("ENVIFORMER_PRESENT", "False") == "True" ENVIFORMER_DEVICE = os.environ.get("ENVIFORMER_DEVICE", "cpu") + # If celery is not present set always eager to true which will cause delayed tasks to block until finished FLAG_CELERY_PRESENT = os.environ.get("FLAG_CELERY_PRESENT", "False") == "True" if not FLAG_CELERY_PRESENT: @@ -343,6 +347,14 @@ LOGIN_EXEMPT_URLS = [ "/password_reset/", "/reset/", "/microsoft/", + "/terms", + "/privacy", + "/cookie-policy", + "/about", + "/contact", + "/jobs", + "/cite", + "/legal", ] # MS AD/Entra diff --git a/epdb/context_processors.py b/epdb/context_processors.py new file mode 100644 index 00000000..77a971b3 --- /dev/null +++ b/epdb/context_processors.py @@ -0,0 +1,32 @@ +""" +Context processors for enviPy application. + +Context processors automatically make variables available to all templates. +""" + +from .logic import PackageManager +from .models import Package + + +def package_context(request): + """ + Provides package data for the search modal which is included globally + in framework_modern.html. + + Returns: + dict: Context dictionary with reviewed and unreviewed packages + """ + current_user = request.user + + reviewed_package_qs = PackageManager.get_reviewed_packages() + + unreviewed_package_qs = Package.objects.none() + + # Only get user-specific packages if user is authenticated + if current_user.is_authenticated: + unreviewed_package_qs = PackageManager.get_all_readable_packages(current_user) + + return { + "reviewed_packages": reviewed_package_qs, + "unreviewed_packages": unreviewed_package_qs, + } diff --git a/epdb/logic.py b/epdb/logic.py index 0aaebf32..f9e1192a 100644 --- a/epdb/logic.py +++ b/epdb/logic.py @@ -4,6 +4,7 @@ import json from typing import Union, List, Optional, Set, Dict, Any from uuid import UUID +import nh3 from django.contrib.auth import get_user_model from django.db import transaction from django.conf import settings as s @@ -185,6 +186,12 @@ class UserManager(object): def create_user( username, email, password, set_setting=True, add_to_group=True, *args, **kwargs ): + # Clean for potential XSS + clean_username = nh3.clean(username).strip() + clean_email = nh3.clean(email).strip() + if clean_username != username or clean_email != email: + # This will be caught by the try in view.py/register + raise ValueError("Invalid username or password") # avoid circular import :S from .tasks import send_registration_mail @@ -262,8 +269,9 @@ class GroupManager(object): @staticmethod def create_group(current_user, name, description): g = Group() - g.name = name - g.description = description + # Clean for potential XSS + g.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + g.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() g.owner = current_user g.save() @@ -518,8 +526,13 @@ class PackageManager(object): @transaction.atomic def create_package(current_user, name: str, description: str = None): p = Package() - p.name = name - p.description = description + + # Clean for potential XSS + p.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + + if description is not None and description.strip() != "": + p.description = nh3.clean(description.strip(), tags=s.ALLOWED_HTML_TAGS).strip() + p.save() up = UserPackagePermission() @@ -1094,28 +1107,29 @@ class SettingManager(object): model: EPModel = None, model_threshold: float = None, ): - s = Setting() - s.name = name - s.description = description - s.max_nodes = max_nodes - s.max_depth = max_depth - s.model = model - s.model_threshold = model_threshold + new_s = Setting() + # Clean for potential XSS + new_s.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + new_s.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() + new_s.max_nodes = max_nodes + new_s.max_depth = max_depth + new_s.model = model + new_s.model_threshold = model_threshold - s.save() + new_s.save() if rule_packages is not None: for r in rule_packages: - s.rule_packages.add(r) - s.save() + new_s.rule_packages.add(r) + new_s.save() usp = UserSettingPermission() usp.user = user - usp.setting = s + usp.setting = new_s usp.permission = Permission.ALL[0] usp.save() - return s + return new_s @staticmethod def get_default_setting(user: User): @@ -1542,7 +1556,9 @@ class SPathway(object): if sub.app_domain_assessment is None: if self.prediction_setting.model: if self.prediction_setting.model.app_domain: - app_domain_assessment = self.prediction_setting.model.app_domain.assess(sub.smiles) + app_domain_assessment = self.prediction_setting.model.app_domain.assess( + sub.smiles + ) if self.persist is not None: n = self.snode_persist_lookup[sub] @@ -1574,7 +1590,9 @@ class SPathway(object): app_domain_assessment = None if self.prediction_setting.model: if self.prediction_setting.model.app_domain: - app_domain_assessment = (self.prediction_setting.model.app_domain.assess(c)) + app_domain_assessment = ( + self.prediction_setting.model.app_domain.assess(c) + ) self.smiles_to_node[c] = SNode( c, sub.depth + 1, app_domain_assessment diff --git a/epdb/models.py b/epdb/models.py index 3e273d1e..eb2ef4cc 100644 --- a/epdb/models.py +++ b/epdb/models.py @@ -11,6 +11,7 @@ from typing import Union, List, Optional, Dict, Tuple, Set, Any from uuid import uuid4 import math import joblib +import nh3 import numpy as np from django.conf import settings as s from django.contrib.auth.models import AbstractUser @@ -28,8 +29,14 @@ from sklearn.metrics import precision_score, recall_score, jaccard_score from sklearn.model_selection import ShuffleSplit from utilities.chem import FormatConverter, ProductSet, PredictionResult, IndigoUtils -from utilities.ml import RuleBasedDataset, ApplicabilityDomainPCA, EnsembleClassifierChain, RelativeReasoning, \ - EnviFormerDataset, Dataset +from utilities.ml import ( + RuleBasedDataset, + ApplicabilityDomainPCA, + EnsembleClassifierChain, + RelativeReasoning, + EnviFormerDataset, + Dataset, +) logger = logging.getLogger(__name__) @@ -804,14 +811,16 @@ class Compound(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdentifierMixin c = Compound() c.package = package - if name is None or name.strip() == "": + if name is not None: + # Clean for potential XSS + name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + if name is None or name == "": name = f"Compound {Compound.objects.filter(package=package).count() + 1}" - c.name = name # We have a default here only set the value if it carries some payload if description is not None and description.strip() != "": - c.description = description.strip() + c.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() c.save() @@ -983,11 +992,11 @@ class CompoundStructure(EnviPathModel, AliasMixin, ScenarioMixin, ChemicalIdenti raise ValueError("Unpersisted Compound! Persist compound first!") cs = CompoundStructure() + # Clean for potential XSS if name is not None: - cs.name = name - + cs.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() if description is not None: - cs.description = description + cs.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() cs.smiles = smiles cs.compound = compound @@ -1189,21 +1198,29 @@ class SimpleAmbitRule(SimpleRule): r = SimpleAmbitRule() r.package = package - if name is None or name.strip() == "": + if name is not None: + name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + + if name is None or name == "": name = f"Rule {Rule.objects.filter(package=package).count() + 1}" r.name = name - if description is not None and description.strip() != "": - r.description = description + r.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() r.smirks = smirks if reactant_filter_smarts is not None and reactant_filter_smarts.strip() != "": - r.reactant_filter_smarts = reactant_filter_smarts + if not FormatConverter.is_valid_smarts(reactant_filter_smarts.strip()): + raise ValueError(f'Reactant Filter SMARTS "{reactant_filter_smarts}" is invalid!') + else: + r.reactant_filter_smarts = reactant_filter_smarts.strip() if product_filter_smarts is not None and product_filter_smarts.strip() != "": - r.product_filter_smarts = product_filter_smarts + if not FormatConverter.is_valid_smarts(product_filter_smarts.strip()): + raise ValueError(f'Product Filter SMARTS "{product_filter_smarts}" is invalid!') + else: + r.product_filter_smarts = product_filter_smarts.strip() r.save() return r @@ -1404,12 +1421,11 @@ class Reaction(EnviPathModel, AliasMixin, ScenarioMixin, ReactionIdentifierMixin r = Reaction() r.package = package - + # Clean for potential XSS if name is not None and name.strip() != "": - r.name = name - + r.name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() if description is not None and name.strip() != "": - r.description = description + r.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() r.multi_step = multi_step @@ -1717,14 +1733,15 @@ class Pathway(EnviPathModel, AliasMixin, ScenarioMixin): ): pw = Pathway() pw.package = package - - if name is None or name.strip() == "": + if name is not None: + # Clean for potential XSS + name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + if name is None or name == "": name = f"Pathway {Pathway.objects.filter(package=package).count() + 1}" pw.name = name - if description is not None and description.strip() != "": - pw.description = description + pw.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() pw.save() try: @@ -2019,11 +2036,16 @@ class Edge(EnviPathModel, AliasMixin, ScenarioMixin): for node in end_nodes: e.end_nodes.add(node) - if name is None: + # Clean for potential XSS + # Cleaning technically not needed as it is also done in Reaction.create, including it here for consistency + if name is not None: + name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + if name is None or name == "": name = f"Reaction {pathway.package.reactions.count() + 1}" if description is None: description = s.DEFAULT_VALUES["description"] + description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() r = Reaction.create( pathway.package, @@ -2345,7 +2367,9 @@ class PackageBasedModel(EPModel): eval_reactions = list( Reaction.objects.filter(package__in=self.eval_packages.all()).distinct() ) - ds = RuleBasedDataset.generate_dataset(eval_reactions, self.applicable_rules, educts_only=True) + ds = RuleBasedDataset.generate_dataset( + eval_reactions, self.applicable_rules, educts_only=True + ) if isinstance(self, RuleBasedRelativeReasoning): X = ds.X(exclude_id_col=False, na_replacement=None).to_numpy() y = ds.y(na_replacement=np.nan).to_numpy() @@ -2543,14 +2567,15 @@ class RuleBasedRelativeReasoning(PackageBasedModel): ): rbrr = RuleBasedRelativeReasoning() rbrr.package = package - - if name is None or name.strip() == "": + if name is not None: + # Clean for potential XSS + name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + if name is None or name == "": name = f"RuleBasedRelativeReasoning {RuleBasedRelativeReasoning.objects.filter(package=package).count() + 1}" rbrr.name = name - if description is not None and description.strip() != "": - rbrr.description = description + rbrr.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() if threshold is None or (threshold <= 0 or 1 <= threshold): raise ValueError("Threshold must be a float between 0 and 1.") @@ -2647,14 +2672,15 @@ class MLRelativeReasoning(PackageBasedModel): ): mlrr = MLRelativeReasoning() mlrr.package = package - - if name is None or name.strip() == "": + if name is not None: + # Clean for potential XSS + name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + if name is None or name == "": name = f"MLRelativeReasoning {MLRelativeReasoning.objects.filter(package=package).count() + 1}" mlrr.name = name - if description is not None and description.strip() != "": - mlrr.description = description + mlrr.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() if threshold is None or (threshold <= 0 or 1 <= threshold): raise ValueError("Threshold must be a float between 0 and 1.") @@ -2808,7 +2834,9 @@ class ApplicabilityDomain(EnviPathModel): else: smiles.append(structures) - assessment_ds, assessment_prods = ds.classification_dataset(structures, self.model.applicable_rules) + assessment_ds, assessment_prods = ds.classification_dataset( + structures, self.model.applicable_rules + ) # qualified_neighbours_per_rule is a nested dictionary structured as: # { @@ -2824,12 +2852,16 @@ class ApplicabilityDomain(EnviPathModel): qualified_neighbours_per_rule: Dict = {} import polars as pl + # Select only the triggered columns for i, row in enumerate(assessment_ds[:, assessment_ds.triggered()].iter_rows(named=True)): # Find the rules the structure triggers. For each rule, filter the training dataset to rows that also # trigger that rule. - train_trig = {trig_uuid.split("_")[-1]: ds.filter(pl.col(trig_uuid).eq(1)) - for trig_uuid, value in row.items() if value == 1} + train_trig = { + trig_uuid.split("_")[-1]: ds.filter(pl.col(trig_uuid).eq(1)) + for trig_uuid, value in row.items() + if value == 1 + } qualified_neighbours_per_rule[i] = train_trig rule_to_i = {str(r.uuid): i for i, r in enumerate(self.model.applicable_rules)} preds = self.model.combine_products_and_probs( @@ -2849,18 +2881,28 @@ class ApplicabilityDomain(EnviPathModel): # loop through rule indices together with the collected neighbours indices from train dataset for rule_uuid, train_instances in qualified_neighbours_per_rule[i].items(): # compute tanimoto distance for all neighbours and add to dataset - dists = self._compute_distances(assessment_ds[i, assessment_ds.struct_features()].to_numpy()[0], - train_instances[:, train_instances.struct_features()].to_numpy()) + dists = self._compute_distances( + assessment_ds[i, assessment_ds.struct_features()].to_numpy()[0], + train_instances[:, train_instances.struct_features()].to_numpy(), + ) train_instances = train_instances.with_columns(dist=pl.Series(dists)) # sort them in a descending way and take at most `self.num_neighbours` # TODO: Should this be descending? If we want the most similar then we want values close to zero (ascending) - train_instances = train_instances.sort("dist", descending=True)[:self.num_neighbours] + train_instances = train_instances.sort("dist", descending=True)[ + : self.num_neighbours + ] # compute average distance - rule_reliabilities[rule_uuid] = train_instances.select(pl.mean("dist")).fill_nan(0.0).item() + rule_reliabilities[rule_uuid] = ( + train_instances.select(pl.mean("dist")).fill_nan(0.0).item() + ) # for local_compatibility we'll need the datasets for the indices having the highest similarity - local_compatibilities[rule_uuid] = self._compute_compatibility(rule_uuid, train_instances) - neighbours_per_rule[rule_uuid] = list(CompoundStructure.objects.filter(uuid__in=train_instances["structure_id"])) + local_compatibilities[rule_uuid] = self._compute_compatibility( + rule_uuid, train_instances + ) + neighbours_per_rule[rule_uuid] = list( + CompoundStructure.objects.filter(uuid__in=train_instances["structure_id"]) + ) neighbor_probs_per_rule[rule_uuid] = train_instances[f"prob_{rule_uuid}"].to_list() ad_res = { @@ -2934,8 +2976,11 @@ class ApplicabilityDomain(EnviPathModel): def _compute_compatibility(self, rule_idx: int, neighbours: "RuleBasedDataset"): accuracy = 0.0 import polars as pl - obs_pred = neighbours.select(obs=pl.col(f"obs_{rule_idx}").cast(pl.Boolean), - pred=pl.col(f"prob_{rule_idx}") >= self.model.threshold) + + obs_pred = neighbours.select( + obs=pl.col(f"obs_{rule_idx}").cast(pl.Boolean), + pred=pl.col(f"prob_{rule_idx}") >= self.model.threshold, + ) # Compute tp, tn, fp, fn using polars expressions tp = obs_pred.filter((pl.col("obs")) & (pl.col("pred"))).height tn = obs_pred.filter((~pl.col("obs")) & (~pl.col("pred"))).height @@ -2962,14 +3007,15 @@ class EnviFormer(PackageBasedModel): ): mod = EnviFormer() mod.package = package - - if name is None or name.strip() == "": + if name is not None: + # Clean for potential XSS + name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + if name is None or name == "": name = f"EnviFormer {EnviFormer.objects.filter(package=package).count() + 1}" mod.name = name - if description is not None and description.strip() != "": - mod.description = description + mod.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() if threshold is None or (threshold <= 0 or 1 <= threshold): raise ValueError("Threshold must be a float between 0 and 1.") @@ -3104,7 +3150,7 @@ class EnviFormer(PackageBasedModel): pred_dict = {} for k, pred in enumerate(predictions): pred_smiles, pred_proba = zip(*pred.items()) - reactant, true_product = test_ds[k, "educts"], test_ds[k, "products"] + reactant, _ = test_ds[k, "educts"], test_ds[k, "products"] pred_dict.setdefault(reactant, {"predict": [], "scores": []}) for smiles, proba in zip(pred_smiles, pred_proba): smiles = set(smiles.split(".")) @@ -3218,8 +3264,9 @@ class EnviFormer(PackageBasedModel): # If there are eval packages perform single generation evaluation on them instead of random splits if self.eval_packages.count() > 0: - ds = EnviFormerDataset.generate_dataset(Reaction.objects.filter( - package__in=self.eval_packages.all()).distinct()) + ds = EnviFormerDataset.generate_dataset( + Reaction.objects.filter(package__in=self.eval_packages.all()).distinct() + ) test_result = self.model.predict_batch(ds.X()) single_gen_result = evaluate_sg(ds, test_result, self.threshold) self.eval_results = self.compute_averages([single_gen_result]) @@ -3237,7 +3284,9 @@ class EnviFormer(PackageBasedModel): train = ds[train_index] test = ds[test_index] start = datetime.now() - model = fine_tune(train.X(), train.y(), s.MODEL_DIR, str(split_id), device=s.ENVIFORMER_DEVICE) + model = fine_tune( + train.X(), train.y(), s.MODEL_DIR, str(split_id), device=s.ENVIFORMER_DEVICE + ) end = datetime.now() logger.debug( f"EnviFormer finetuning took {(end - start).total_seconds():.2f} seconds" @@ -3314,7 +3363,12 @@ class EnviFormer(PackageBasedModel): for pathway in train_pathways: for reaction in pathway.edges: reaction = reaction.edge_label - if any([educt in test_educts for educt in reaction_to_educts[str(reaction.uuid)]]): + if any( + [ + educt in test_educts + for educt in reaction_to_educts[str(reaction.uuid)] + ] + ): overlap += 1 continue train_reactions.append(reaction) @@ -3371,41 +3425,44 @@ class Scenario(EnviPathModel): scenario_type: str, additional_information: List["EnviPyModel"], ): - s = Scenario() - s.package = package - - if name is None or name.strip() == "": + new_s = Scenario() + new_s.package = package + if name is not None: + # Clean for potential XSS + name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + if name is None or name == "": name = f"Scenario {Scenario.objects.filter(package=package).count() + 1}" - - s.name = name + new_s.name = name if description is not None and description.strip() != "": - s.description = description + new_s.description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() if scenario_date is not None and scenario_date.strip() != "": - s.scenario_date = scenario_date + new_s.scenario_date = nh3.clean(scenario_date).strip() if scenario_type is not None and scenario_type.strip() != "": - s.scenario_type = scenario_type + new_s.scenario_type = scenario_type add_inf = defaultdict(list) for info in additional_information: cls_name = info.__class__.__name__ - ai_data = json.loads(info.model_dump_json()) + # Clean for potential XSS hidden in the additional information fields. + ai_data = json.loads(nh3.clean(info.model_dump_json()).strip()) ai_data["uuid"] = f"{uuid4()}" add_inf[cls_name].append(ai_data) - s.additional_information = add_inf + new_s.additional_information = add_inf - s.save() + new_s.save() - return s + return new_s @transaction.atomic def add_additional_information(self, data: "EnviPyModel"): cls_name = data.__class__.__name__ - ai_data = json.loads(data.model_dump_json()) + # Clean for potential XSS hidden in the additional information fields. + ai_data = json.loads(nh3.clean(data.model_dump_json()).strip()) ai_data["uuid"] = f"{uuid4()}" if cls_name not in self.additional_information: @@ -3440,7 +3497,8 @@ class Scenario(EnviPathModel): new_ais = defaultdict(list) for k, vals in data.items(): for v in vals: - ai_data = json.loads(v.model_dump_json()) + # Clean for potential XSS hidden in the additional information fields. + ai_data = json.loads(nh3.clean(v.model_dump_json()).strip()) if hasattr(v, "uuid"): ai_data["uuid"] = str(v.uuid) else: diff --git a/epdb/urls.py b/epdb/urls.py index 25e18680..b4bdb9f2 100644 --- a/epdb/urls.py +++ b/epdb/urls.py @@ -193,4 +193,13 @@ urlpatterns = [ re_path(r"^jobs", v.jobs, name="jobs"), # OAuth Stuff path("o/userinfo/", v.userinfo, name="oauth_userinfo"), + # Static Pages + re_path(r"^terms$", v.static_terms_of_use, name="terms_of_use"), + re_path(r"^privacy$", v.static_privacy_policy, name="privacy_policy"), + re_path(r"^cookie-policy$", v.static_cookie_policy, name="cookie_policy"), + re_path(r"^about$", v.static_about_us, name="about_us"), + re_path(r"^contact$", v.static_contact_support, name="contact_support"), + re_path(r"^careers$", v.static_careers, name="careers"), + re_path(r"^cite$", v.static_cite, name="cite"), + re_path(r"^legal$", v.static_legal, name="legal"), ] diff --git a/epdb/views.py b/epdb/views.py index bd183666..5d6e0bda 100644 --- a/epdb/views.py +++ b/epdb/views.py @@ -10,6 +10,7 @@ from django.urls import reverse from django.views.decorators.csrf import csrf_exempt from envipy_additional_information import NAME_MAPPING from oauth2_provider.decorators import protected_resource +import nh3 from utilities.chem import FormatConverter, IndigoUtils from utilities.decorators import package_permission_required @@ -85,7 +86,11 @@ def login(request): from django.contrib.auth import authenticate from django.contrib.auth import login - username = request.POST.get("username") + username = request.POST.get("username").strip() + if username != request.POST.get("username"): + context["message"] = "Login failed!" + return render(request, "static/login.html", context) + password = request.POST.get("password") # Get email for username and check if the account is active @@ -100,6 +105,7 @@ def login(request): except get_user_model().DoesNotExist: context["message"] = "Login failed!" return render(request, "static/login.html", context) + try: user = authenticate(username=email, password=password) except Exception: @@ -137,9 +143,14 @@ def register(request): context = get_base_context(request) if request.method == "GET": - context["title"] = "enviPath" - context["next"] = request.GET.get("next", "") - return render(request, "static/register.html", context) + # Redirect to unified login page with signup tab + next_url = request.GET.get("next", "") + redirect_url = reverse("login") + "#signup" + + if next_url: + redirect_url += f"?next={next_url}" + + return redirect(redirect_url) elif request.method == "POST": context["title"] = "enviPath" if next := request.POST.get("next"): @@ -152,18 +163,18 @@ def register(request): if not (username and email and password): context["message"] = "Invalid username/email/password" - return render(request, "static/register.html", context) + return render(request, "static/login.html", context) if password != rpassword or password == "": context["message"] = "Registration failed, provided passwords differ!" - return render(request, "static/register.html", context) + return render(request, "static/login.html", context) try: u = UserManager.create_user(username, email, password) logger.info(f"Created user {u.username} ({u.pk})") except Exception: context["message"] = "Registration failed! Couldn't create User Account." - return render(request, "static/register.html", context) + return render(request, "static/login.html", context) if s.ADMIN_APPROVAL_REQUIRED: context["success_message"] = ( @@ -670,7 +681,7 @@ def search(request): if request.method == "GET": package_urls = request.GET.getlist("packages") - searchterm = request.GET.get("search") + searchterm = request.GET.get("search", "").strip() mode = request.GET.get("mode") # add HTTP_ACCEPT check to differentiate between index and ajax call @@ -936,8 +947,14 @@ def package_model(request, package_uuid, model_uuid): else: return HttpResponseBadRequest() else: - name = request.POST.get("model-name", "").strip() - description = request.POST.get("model-description", "").strip() + # TODO: Move cleaning to property updater + name = request.POST.get("model-name") + if name is not None: + name = nh3.clean(name, tags=s.ALLOWED_HTML_TAGS).strip() + + description = request.POST.get("model-description") + if description is not None: + description = nh3.clean(description, tags=s.ALLOWED_HTML_TAGS).strip() if any([name, description]): if name: @@ -1039,8 +1056,16 @@ def package(request, package_uuid): else: return HttpResponseBadRequest() + # TODO: Move cleaning to property updater new_package_name = request.POST.get("package-name") + if new_package_name is not None: + new_package_name = nh3.clean(new_package_name, tags=s.ALLOWED_HTML_TAGS).strip() + new_package_description = request.POST.get("package-description") + if new_package_description is not None: + new_package_description = nh3.clean( + new_package_description, tags=s.ALLOWED_HTML_TAGS + ).strip() grantee_url = request.POST.get("grantee") read = request.POST.get("read") == "on" @@ -1189,8 +1214,16 @@ def package_compound(request, package_uuid, compound_uuid): return JsonResponse({"success": current_compound.url}) - new_compound_name = request.POST.get("compound-name", "").strip() - new_compound_description = request.POST.get("compound-description", "").strip() + # TODO: Move cleaning to property updater + new_compound_name = request.POST.get("compound-name") + if new_compound_name is not None: + new_compound_name = nh3.clean(new_compound_name, tags=s.ALLOWED_HTML_TAGS).strip() + + new_compound_description = request.POST.get("compound-description") + if new_compound_description is not None: + new_compound_description = nh3.clean( + new_compound_description, tags=s.ALLOWED_HTML_TAGS + ).strip() if new_compound_name: current_compound.name = new_compound_name @@ -1255,7 +1288,7 @@ def package_compound_structures(request, package_uuid, compound_uuid): elif request.method == "POST": structure_name = request.POST.get("structure-name") - structure_smiles = request.POST.get("structure-smiles") + structure_smiles = request.POST.get("structure-smiles").strip() structure_description = request.POST.get("structure-description") try: @@ -1326,8 +1359,16 @@ def package_compound_structure(request, package_uuid, compound_uuid, structure_u else: return HttpResponseBadRequest() - new_structure_name = request.POST.get("compound-structure-name", "").strip() - new_structure_description = request.POST.get("compound-structure-description", "").strip() + # TODO: Move cleaning to property updater + new_structure_name = request.POST.get("compound-structure-name") + if new_structure_name is not None: + new_structure_name = nh3.clean(new_structure_name, tags=s.ALLOWED_HTML_TAGS).strip() + + new_structure_description = request.POST.get("compound-structure-description") + if new_structure_description is not None: + new_structure_description = nh3.clean( + new_structure_description, tags=s.ALLOWED_HTML_TAGS + ).strip() if new_structure_name: current_structure.name = new_structure_name @@ -1429,11 +1470,11 @@ def package_rules(request, package_uuid): # Obtain parameters as required by rule type if rule_type == "SimpleAmbitRule": - params["smirks"] = request.POST.get("rule-smirks") + params["smirks"] = request.POST.get("rule-smirks").strip() params["reactant_filter_smarts"] = request.POST.get("rule-reactant-smarts") params["product_filter_smarts"] = request.POST.get("rule-product-smarts") elif rule_type == "SimpleRDKitRule": - params["reaction_smarts"] = request.POST.get("rule-reaction-smarts") + params["reaction_smarts"] = request.POST.get("rule-reaction-smarts").strip() elif rule_type == "ParallelRule": pass elif rule_type == "SequentialRule": @@ -1534,8 +1575,14 @@ def package_rule(request, package_uuid, rule_uuid): return JsonResponse({"success": current_rule.url}) - rule_name = request.POST.get("rule-name", "").strip() - rule_description = request.POST.get("rule-description", "").strip() + # TODO: Move cleaning to property updater + rule_name = request.POST.get("rule-name") + if rule_name is not None: + rule_name = nh3.clean(rule_name, tags=s.ALLOWED_HTML_TAGS).strip() + + rule_description = request.POST.get("rule-description") + if rule_description is not None: + rule_description = nh3.clean(rule_description, tags=s.ALLOWED_HTML_TAGS).strip() if rule_name: current_rule.name = rule_name @@ -1624,8 +1671,7 @@ def package_reactions(request, package_uuid): elif request.method == "POST": reaction_name = request.POST.get("reaction-name") reaction_description = request.POST.get("reaction-description") - reactions_smirks = request.POST.get("reaction-smirks") - + reactions_smirks = request.POST.get("reaction-smirks").strip() educts = reactions_smirks.split(">>")[0].split(".") products = reactions_smirks.split(">>")[1].split(".") @@ -1686,8 +1732,16 @@ def package_reaction(request, package_uuid, reaction_uuid): return JsonResponse({"success": current_reaction.url}) - new_reaction_name = request.POST.get("reaction-name", "").strip() - new_reaction_description = request.POST.get("reaction-description", "").strip() + # TODO: Move cleaning to property updater + new_reaction_name = request.POST.get("reaction-name") + if new_reaction_name is not None: + new_reaction_name = nh3.clean(new_reaction_name, tags=s.ALLOWED_HTML_TAGS).strip() + + new_reaction_description = request.POST.get("reaction-description") + if new_reaction_description is not None: + new_reaction_description = nh3.clean( + new_reaction_description, tags=s.ALLOWED_HTML_TAGS + ).strip() if new_reaction_name: current_reaction.name = new_reaction_name @@ -1764,8 +1818,9 @@ def package_pathways(request, package_uuid): name = request.POST.get("name") description = request.POST.get("description") - pw_mode = request.POST.get("predict", "predict").strip() + smiles = request.POST.get("smiles", "").strip() + pw_mode = request.POST.get("predict", "predict").strip() if "smiles" in request.POST and smiles == "": return error( @@ -1774,8 +1829,6 @@ def package_pathways(request, package_uuid): "Pathway prediction failed due to missing or empty SMILES", ) - smiles = smiles.strip() - try: stand_smiles = FormatConverter.standardize(smiles) except ValueError: @@ -1934,8 +1987,14 @@ def package_pathway(request, package_uuid, pathway_uuid): return JsonResponse({"success": current_pathway.url}) + # TODO: Move cleaning to property updater pathway_name = request.POST.get("pathway-name") + if pathway_name is not None: + pathway_name = nh3.clean(pathway_name, tags=s.ALLOWED_HTML_TAGS).strip() + pathway_description = request.POST.get("pathway-description") + if pathway_description is not None: + pathway_description = nh3.clean(pathway_description, tags=s.ALLOWED_HTML_TAGS).strip() if any([pathway_name, pathway_description]): if pathway_name is not None and pathway_name.strip() != "": @@ -2023,8 +2082,8 @@ def package_pathway_nodes(request, package_uuid, pathway_uuid): elif request.method == "POST": node_name = request.POST.get("node-name") node_description = request.POST.get("node-description") - node_smiles = request.POST.get("node-smiles") + node_smiles = request.POST.get("node-smiles").strip() current_pathway.add_node(node_smiles, name=node_name, description=node_description) return redirect(current_pathway.url) @@ -2189,6 +2248,7 @@ def package_pathway_edges(request, package_uuid, pathway_uuid): edge_name = request.POST.get("edge-name") edge_description = request.POST.get("edge-description") + edge_substrates = request.POST.getlist("edge-substrates") edge_products = request.POST.getlist("edge-products") @@ -2275,7 +2335,7 @@ def package_scenarios(request, package_uuid): "all", False ): scens = Scenario.objects.filter(package=current_package).order_by("name") - res = [{"name": s.name, "url": s.url, "uuid": s.uuid} for s in scens] + res = [{"name": s_.name, "url": s_.url, "uuid": s_.uuid} for s_ in scens] return JsonResponse(res, safe=False) context = get_base_context(request) @@ -2323,21 +2383,21 @@ def package_scenarios(request, package_uuid): "name": "soil", "widgets": [ HTMLGenerator.generate_html(ai, prefix=f"soil_{0}") - for ai in [x for s in SOIL_ADDITIONAL_INFORMATION.values() for x in s] + for ai in [x for sv in SOIL_ADDITIONAL_INFORMATION.values() for x in sv] ], }, "Sludge Data": { "name": "sludge", "widgets": [ HTMLGenerator.generate_html(ai, prefix=f"sludge_{0}") - for ai in [x for s in SLUDGE_ADDITIONAL_INFORMATION.values() for x in s] + for ai in [x for sv in SLUDGE_ADDITIONAL_INFORMATION.values() for x in sv] ], }, "Water-Sediment System Data": { "name": "sediment", "widgets": [ HTMLGenerator.generate_html(ai, prefix=f"sediment_{0}") - for ai in [x for s in SEDIMENT_ADDITIONAL_INFORMATION.values() for x in s] + for ai in [x for sv in SEDIMENT_ADDITIONAL_INFORMATION.values() for x in sv] ], }, } @@ -2352,6 +2412,7 @@ def package_scenarios(request, package_uuid): scenario_name = request.POST.get("scenario-name") scenario_description = request.POST.get("scenario-description") + scenario_date_year = request.POST.get("scenario-date-year") scenario_date_month = request.POST.get("scenario-date-month") scenario_date_day = request.POST.get("scenario-date-day") @@ -2365,9 +2426,9 @@ def package_scenarios(request, package_uuid): scenario_type = request.POST.get("scenario-type") additional_information = HTMLGenerator.build_models(request.POST.dict()) - additional_information = [x for s in additional_information.values() for x in s] + additional_information = [x for sv in additional_information.values() for x in sv] - s = Scenario.create( + new_scen = Scenario.create( current_package, name=scenario_name, description=scenario_description, @@ -2376,7 +2437,7 @@ def package_scenarios(request, package_uuid): additional_information=additional_information, ) - return redirect(s.url) + return redirect(new_scen.url) else: return HttpResponseNotAllowed( [ @@ -2676,6 +2737,7 @@ def settings(request): name = request.POST.get("prediction-setting-name") description = request.POST.get("prediction-setting-description") + new_default = request.POST.get("prediction-setting-new-default", "off") == "on" max_nodes = min( @@ -2826,3 +2888,60 @@ def userinfo(request): "email_verified": user.is_active, } return JsonResponse(res) + + +# Static Pages +def static_terms_of_use(request): + context = get_base_context(request) + context["title"] = "enviPath - Terms of Use" + context["public_mode"] = True + return render(request, "static/terms_of_use.html", context) + + +def static_privacy_policy(request): + context = get_base_context(request) + context["title"] = "enviPath - Privacy Policy" + context["public_mode"] = True + return render(request, "static/privacy_policy.html", context) + + +def static_cookie_policy(request): + context = get_base_context(request) + context["title"] = "enviPath - Cookie Policy" + context["public_mode"] = True + return render(request, "static/cookie_policy.html", context) + + +def static_about_us(request): + context = get_base_context(request) + context["title"] = "enviPath - About Us" + context["public_mode"] = True + return render(request, "static/about_us.html", context) + + +def static_contact_support(request): + context = get_base_context(request) + context["title"] = "enviPath - Contact & Support" + context["public_mode"] = True + return render(request, "static/contact.html", context) + + +def static_careers(request): + context = get_base_context(request) + context["title"] = "enviPath - Careers" + context["public_mode"] = True + return render(request, "static/careers.html", context) + + +def static_cite(request): + context = get_base_context(request) + context["title"] = "enviPath - How to Cite" + context["public_mode"] = True + return render(request, "static/cite.html", context) + + +def static_legal(request): + context = get_base_context(request) + context["title"] = "enviPath - Legal Information" + context["public_mode"] = True + return render(request, "static/legal.html", context) diff --git a/fixtures/models/2750eaca-bc13-4018-81c2-f7f9d94bc435_ds.pkl b/fixtures/models/2750eaca-bc13-4018-81c2-f7f9d94bc435_ds.pkl index 21cf28d0..b8a95a07 100644 Binary files a/fixtures/models/2750eaca-bc13-4018-81c2-f7f9d94bc435_ds.pkl and b/fixtures/models/2750eaca-bc13-4018-81c2-f7f9d94bc435_ds.pkl differ diff --git a/fixtures/models/2750eaca-bc13-4018-81c2-f7f9d94bc435_mod.pkl b/fixtures/models/2750eaca-bc13-4018-81c2-f7f9d94bc435_mod.pkl index 1ad990e1..f3147246 100644 Binary files a/fixtures/models/2750eaca-bc13-4018-81c2-f7f9d94bc435_mod.pkl and b/fixtures/models/2750eaca-bc13-4018-81c2-f7f9d94bc435_mod.pkl differ diff --git a/package.json b/package.json new file mode 100644 index 00000000..ad9c079e --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "envipy", + "version": "1.0.0", + "private": true, + "description": "enviPath UI - Tailwind CSS + DaisyUI", + "scripts": { + "dev": "tailwindcss -i static/css/input.css -o static/css/output.css --watch=always", + "build": "tailwindcss -i static/css/input.css -o static/css/output.css --minify" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.1.16", + "@tailwindcss/postcss": "^4.1.16", + "daisyui": "^5.4.3", + "postcss": "^8.5.6", + "prettier": "^3.6.2", + "prettier-plugin-jinja-template": "^2.1.0", + "prettier-plugin-tailwindcss": "^0.7.1", + "tailwindcss": "^4.1.16" + }, + "keywords": [ + "django", + "tailwindcss", + "daisyui" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..883a5034 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,740 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@tailwindcss/cli': + specifier: ^4.1.16 + version: 4.1.16 + '@tailwindcss/postcss': + specifier: ^4.1.16 + version: 4.1.16 + daisyui: + specifier: ^5.4.3 + version: 5.4.3 + postcss: + specifier: ^8.5.6 + version: 8.5.6 + prettier: + specifier: ^3.6.2 + version: 3.6.2 + prettier-plugin-jinja-template: + specifier: ^2.1.0 + version: 2.1.0(prettier@3.6.2) + prettier-plugin-tailwindcss: + specifier: ^0.7.1 + version: 0.7.1(prettier@3.6.2) + tailwindcss: + specifier: ^4.1.16 + version: 4.1.16 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@parcel/watcher-android-arm64@2.5.1': + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.1': + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.1': + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.1': + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.1': + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.5.1': + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.5.1': + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.1': + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.1': + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.1': + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} + engines: {node: '>= 10.0.0'} + + '@tailwindcss/cli@4.1.16': + resolution: {integrity: sha512-dsnANPrh2ZooHyZ/8uJhc9ecpcYtufToc21NY09NS9vF16rxPCjJ8dP7TUAtPqlUJTHSmRkN2hCdoYQIlgh4fw==} + hasBin: true + + '@tailwindcss/node@4.1.16': + resolution: {integrity: sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==} + + '@tailwindcss/oxide-android-arm64@4.1.16': + resolution: {integrity: sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.16': + resolution: {integrity: sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.16': + resolution: {integrity: sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.16': + resolution: {integrity: sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16': + resolution: {integrity: sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.16': + resolution: {integrity: sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.16': + resolution: {integrity: sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.16': + resolution: {integrity: sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.1.16': + resolution: {integrity: sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.1.16': + resolution: {integrity: sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.16': + resolution: {integrity: sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.16': + resolution: {integrity: sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.16': + resolution: {integrity: sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==} + engines: {node: '>= 10'} + + '@tailwindcss/postcss@4.1.16': + resolution: {integrity: sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + daisyui@5.4.3: + resolution: {integrity: sha512-dfDCJnN4utErGoWfElgdEE252FtfHV9Mxj5Dq1+JzUq3nVkluWdF3JYykP0Xy/y/yArnPXYztO1tLNCow3kjmg==} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + enhanced-resolve@5.18.3: + resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} + engines: {node: '>=10.13.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prettier-plugin-jinja-template@2.1.0: + resolution: {integrity: sha512-mzoCp2Oy9BDSug80fw3B3J4n4KQj1hRvoQOL1akqcDKBb5nvYxrik9zUEDs4AEJ6nK7QDTGoH0y9rx7AlnQ78Q==} + peerDependencies: + prettier: ^3.0.0 + + prettier-plugin-tailwindcss@0.7.1: + resolution: {integrity: sha512-Bzv1LZcuiR1Sk02iJTS1QzlFNp/o5l2p3xkopwOrbPmtMeh3fK9rVW5M3neBQzHq+kGKj/4LGQMTNcTH4NGPtQ==} + engines: {node: '>=20.19'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.6.2: + resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} + engines: {node: '>=14'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tailwindcss@4.1.16: + resolution: {integrity: sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@parcel/watcher-android-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-x64@2.5.1': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.1': + optional: true + + '@parcel/watcher-win32-arm64@2.5.1': + optional: true + + '@parcel/watcher-win32-ia32@2.5.1': + optional: true + + '@parcel/watcher-win32-x64@2.5.1': + optional: true + + '@parcel/watcher@2.5.1': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.8 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.1 + '@parcel/watcher-darwin-arm64': 2.5.1 + '@parcel/watcher-darwin-x64': 2.5.1 + '@parcel/watcher-freebsd-x64': 2.5.1 + '@parcel/watcher-linux-arm-glibc': 2.5.1 + '@parcel/watcher-linux-arm-musl': 2.5.1 + '@parcel/watcher-linux-arm64-glibc': 2.5.1 + '@parcel/watcher-linux-arm64-musl': 2.5.1 + '@parcel/watcher-linux-x64-glibc': 2.5.1 + '@parcel/watcher-linux-x64-musl': 2.5.1 + '@parcel/watcher-win32-arm64': 2.5.1 + '@parcel/watcher-win32-ia32': 2.5.1 + '@parcel/watcher-win32-x64': 2.5.1 + + '@tailwindcss/cli@4.1.16': + dependencies: + '@parcel/watcher': 2.5.1 + '@tailwindcss/node': 4.1.16 + '@tailwindcss/oxide': 4.1.16 + enhanced-resolve: 5.18.3 + mri: 1.2.0 + picocolors: 1.1.1 + tailwindcss: 4.1.16 + + '@tailwindcss/node@4.1.16': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.18.3 + jiti: 2.6.1 + lightningcss: 1.30.2 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.1.16 + + '@tailwindcss/oxide-android-arm64@4.1.16': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.16': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.16': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.16': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.16': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.16': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.16': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.16': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.16': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.16': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.16': + optional: true + + '@tailwindcss/oxide@4.1.16': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.16 + '@tailwindcss/oxide-darwin-arm64': 4.1.16 + '@tailwindcss/oxide-darwin-x64': 4.1.16 + '@tailwindcss/oxide-freebsd-x64': 4.1.16 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.16 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.16 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.16 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.16 + '@tailwindcss/oxide-linux-x64-musl': 4.1.16 + '@tailwindcss/oxide-wasm32-wasi': 4.1.16 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.16 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.16 + + '@tailwindcss/postcss@4.1.16': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.1.16 + '@tailwindcss/oxide': 4.1.16 + postcss: 8.5.6 + tailwindcss: 4.1.16 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + daisyui@5.4.3: {} + + detect-libc@1.0.3: {} + + detect-libc@2.1.2: {} + + enhanced-resolve@5.18.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + graceful-fs@4.2.11: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + jiti@2.6.1: {} + + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mri@1.2.0: {} + + nanoid@3.3.11: {} + + node-addon-api@7.1.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier-plugin-jinja-template@2.1.0(prettier@3.6.2): + dependencies: + prettier: 3.6.2 + + prettier-plugin-tailwindcss@0.7.1(prettier@3.6.2): + dependencies: + prettier: 3.6.2 + + prettier@3.6.2: {} + + source-map-js@1.2.1: {} + + tailwindcss@4.1.16: {} + + tapable@2.3.0: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 diff --git a/pyproject.toml b/pyproject.toml index 26371296..767d3dd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "scikit-learn>=1.6.1", "sentry-sdk[django]>=2.32.0", "setuptools>=80.8.0", + "nh3==0.3.2", "polars==1.35.1", ] @@ -66,22 +67,54 @@ 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 = { cmd = "python manage.py runserver", help = "Start the development server", deps = ["db-up"] } +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"], 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" } + # Full cleanup tasks 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 -migrate = { cmd = "python manage.py migrate", help = "Run database migrations" } +migrate = { cmd = "uv run python manage.py migrate", help = "Run database migrations" } bootstrap = { shell = """ echo "Bootstrapping initial data..." echo "This will take a bit ⏱️. Get yourself some coffee..." -python manage.py bootstrap +uv run python manage.py bootstrap echo "✓ Bootstrap complete" echo "" echo "Default admin credentials:" @@ -89,4 +122,8 @@ echo " Username: admin" echo " Email: admin@envipath.com" echo " Password: SuperSafe" """, help = "Bootstrap initial data (anonymous user, packages, models)" } -shell = { cmd = "python manage.py shell", help = "Open Django shell" } +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"] } diff --git a/static/css/daisyui-theme.css b/static/css/daisyui-theme.css new file mode 100644 index 00000000..3e85ddc4 --- /dev/null +++ b/static/css/daisyui-theme.css @@ -0,0 +1,84 @@ +/** + * DaisyUI Themes - Generated by Style Dictionary + * Theme mappings defined in tokens/daisyui-themes.json + */ + +/* Light theme (default) */ +@plugin "daisyui/theme" { + name: "envipath"; + default: true; + color-scheme: light; + + --color-base-100: var(--color-neutral-50); + --color-base-200: var(--color-neutral-100); + --color-base-300: var(--color-neutral-200); + --color-base-content: var(--color-neutral-900); + --color-primary: var(--color-primary-500); + --color-primary-content: var(--color-primary-50); + --color-secondary: var(--color-secondary-500); + --color-secondary-content: var(--color-secondary-50); + --color-accent: var(--color-accent-500); + --color-accent-content: var(--color-accent-50); + --color-neutral: var(--color-neutral-950); + --color-neutral-content: var(--color-neutral-100); + --color-info: var(--color-info-500); + --color-info-content: var(--color-info-950); + --color-success: var(--color-success-500); + --color-success-content: var(--color-success-950); + --color-warning: var(--color-warning-500); + --color-warning-content: var(--color-warning-950); + --color-error: var(--color-error-500); + --color-error-content: var(--color-error-950); + + /* border radius */ + --radius-selector: 1rem; + --radius-field: 0.25rem; + --radius-box: 0.5rem; + + /* base sizes */ + --size-selector: 0.25rem; + --size-field: 0.25rem; + + /* border size */ + --border: 1px; + + /* effects */ + --depth: 1; + --noise: 0; +} + +/* Dark theme (prefers-color-scheme: dark) */ +@plugin "daisyui/theme" { + name: "envipath-dark"; + prefersdark: true; + color-scheme: dark; + + --color-primary: var(--color-primary-400); + --color-primary-content: var(--color-neutral-950); + --color-secondary: var(--color-secondary-400); + --color-secondary-content: var(--color-neutral-950); + --color-accent: var(--color-primary-500); + --color-accent-content: var(--color-neutral-950); + --color-neutral: var(--color-neutral-300); + --color-neutral-content: var(--color-neutral-900); + --color-base-100: var(--color-neutral-900); + --color-base-200: var(--color-neutral-800); + --color-base-300: var(--color-neutral-700); + --color-base-content: var(--color-neutral-50); + --color-info: var(--color-primary-400); + --color-info-content: var(--color-neutral-950); + --color-success: var(--color-success-400); + --color-success-content: var(--color-neutral-950); + --color-warning: var(--color-warning-400); + --color-warning-content: var(--color-neutral-950); + --color-error: var(--color-error-400); + --color-error-content: var(--color-neutral-950); + --radius-selector: 1rem; + --radius-field: 0.25rem; + --radius-box: 0.5rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 1; + --noise: 0; +} diff --git a/static/css/input.css b/static/css/input.css new file mode 100644 index 00000000..98ad1678 --- /dev/null +++ b/static/css/input.css @@ -0,0 +1,36 @@ +@import "tailwindcss"; + +/* fira-code-latin-wght-normal */ +@font-face { + font-family: 'Fira Code Variable'; + font-style: normal; + font-display: swap; + font-weight: 300 700; + src: url(https://cdn.jsdelivr.net/fontsource/fonts/fira-code:vf@latest/latin-wght-normal.woff2) format('woff2-variations'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +/* inter-latin-wght-normal */ +@font-face { + font-family: 'Inter Variable'; + font-style: normal; + font-display: swap; + font-weight: 100 900; + src: url(https://cdn.jsdelivr.net/fontsource/fonts/inter:vf@latest/latin-wght-normal.woff2) format('woff2-variations'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + + +/* Tell Tailwind where to find Django templates and Python files */ +@source "../../templates"; + +/* Custom theme configuration - must come before plugins */ +@import "./theme.css"; + +/* Import DaisyUI plugin */ +@plugin "daisyui" { + logs: true; + exclude: rootscrollgutter; +} + +@import "./daisyui-theme.css"; diff --git a/static/css/theme.css b/static/css/theme.css new file mode 100644 index 00000000..0d830c5e --- /dev/null +++ b/static/css/theme.css @@ -0,0 +1,111 @@ +/** + * Tailwind v4 Theme - Generated by Style Dictionary + * This creates Tailwind utility classes from design tokens + */ + +@theme { + /* Colors */ + --color-primary-50: oklch(0.98 0.02 201); + --color-primary-100: oklch(0.96 0.04 203); + --color-primary-200: oklch(0.92 0.08 205); + --color-primary-300: oklch(0.87 0.12 207); + --color-primary-400: oklch(0.80 0.13 212); + --color-primary-500: oklch(0.71 0.13 215); + --color-primary-600: oklch(0.61 0.11 222); + --color-primary-700: oklch(0.52 0.09 223); + --color-primary-800: oklch(0.45 0.08 224); + --color-primary-900: oklch(0.40 0.07 227); + --color-primary-950: oklch(0.30 0.05 230); + --color-secondary-50: oklch(0.98 0.02 166); + --color-secondary-100: oklch(0.95 0.05 163); + --color-secondary-200: oklch(0.90 0.09 164); + --color-secondary-300: oklch(0.85 0.13 165); + --color-secondary-400: oklch(0.77 0.15 163); + --color-secondary-500: oklch(0.70 0.15 162); + --color-secondary-600: oklch(0.60 0.13 163); + --color-secondary-700: oklch(0.51 0.10 166); + --color-secondary-800: oklch(0.43 0.09 167); + --color-secondary-900: oklch(0.38 0.07 169); + --color-secondary-950: oklch(0.26 0.05 173); + --color-success-50: oklch(0.98 0.02 156); + --color-success-100: oklch(0.96 0.04 157); + --color-success-200: oklch(0.93 0.08 156); + --color-success-300: oklch(0.87 0.14 154); + --color-success-400: oklch(0.80 0.18 152); + --color-success-500: oklch(0.72 0.19 150); + --color-success-600: oklch(0.63 0.17 149); + --color-success-700: oklch(0.53 0.14 150); + --color-success-800: oklch(0.45 0.11 151); + --color-success-900: oklch(0.39 0.09 153); + --color-success-950: oklch(0.27 0.06 153); + --color-warning-50: oklch(0.99 0.03 102); + --color-warning-100: oklch(0.97 0.07 103); + --color-warning-200: oklch(0.95 0.12 102); + --color-warning-300: oklch(0.91 0.17 98); + --color-warning-400: oklch(0.86 0.17 92); + --color-warning-500: oklch(0.80 0.16 86); + --color-warning-600: oklch(0.68 0.14 76); + --color-warning-700: oklch(0.55 0.12 66); + --color-warning-800: oklch(0.48 0.10 62); + --color-warning-900: oklch(0.42 0.09 58); + --color-warning-950: oklch(0.29 0.06 54); + --color-error-50: oklch(0.97 0.01 17); + --color-error-100: oklch(0.94 0.03 18); + --color-error-200: oklch(0.88 0.06 18); + --color-error-300: oklch(0.81 0.10 20); + --color-error-400: oklch(0.71 0.17 22); + --color-error-500: oklch(0.64 0.21 25); + --color-error-600: oklch(0.58 0.22 27); + --color-error-700: oklch(0.51 0.19 28); + --color-error-800: oklch(0.44 0.16 27); + --color-error-900: oklch(0.40 0.13 26); + --color-error-950: oklch(0.26 0.09 26); + --color-neutral-50: oklch(0.98 0.00 248); + --color-neutral-100: oklch(0.97 0.01 248); + --color-neutral-200: oklch(0.93 0.01 256); + --color-neutral-300: oklch(0.87 0.02 253); + --color-neutral-400: oklch(0.71 0.04 257); + --color-neutral-500: oklch(0.55 0.04 257); + --color-neutral-600: oklch(0.45 0.04 257); + --color-neutral-700: oklch(0.37 0.04 257); + --color-neutral-800: oklch(0.28 0.04 260); + --color-neutral-900: oklch(0.28 0.04 260); + --color-neutral-950: oklch(0.28 0.04 260); + + /* Spacing */ + --spacing-0: 0; + --spacing-1: 0.25rem; + --spacing-2: 0.5rem; + --spacing-3: 0.75rem; + --spacing-4: 1rem; + --spacing-5: 1.25rem; + --spacing-6: 1.5rem; + --spacing-7: 1.75rem; + --spacing-8: 2rem; + --spacing-10: 2.5rem; + --spacing-12: 3rem; + --spacing-16: 4rem; + --spacing-20: 5rem; + --spacing-24: 6rem; + --spacing-32: 8rem; + --spacing-40: 10rem; + --spacing-48: 12rem; + --spacing-56: 14rem; + --spacing-64: 16rem; + + /* Typography */ + --font-family-sans: 'Inter Variable', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-family-mono: 'Fira Code Variable', 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace; + --font-family-base: 'Inter Variable', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --font-size-xs: 0.75rem; + --font-size-sm: 0.875rem; + --font-size-base: 1rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.25rem; + --font-size-2xl: 1.5rem; + --font-size-3xl: 1.875rem; + --font-size-4xl: 2.25rem; + --font-size-5xl: 3rem; + --font-size-6xl: 3.75rem; + --font-size-7xl: 4.5rem; +} diff --git a/static/images/ep-rule-artwork.png b/static/images/ep-rule-artwork.png new file mode 100644 index 00000000..5cd34ca6 Binary files /dev/null and b/static/images/ep-rule-artwork.png differ diff --git a/static/images/hero.png b/static/images/hero.png new file mode 100644 index 00000000..863d00c5 Binary files /dev/null and b/static/images/hero.png differ diff --git a/static/images/linkedin.png b/static/images/linkedin.png new file mode 100644 index 00000000..be244b05 Binary files /dev/null and b/static/images/linkedin.png differ diff --git a/static/images/logo-eawag.svg b/static/images/logo-eawag.svg new file mode 100644 index 00000000..4dc93042 --- /dev/null +++ b/static/images/logo-eawag.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/images/logo-long.svg b/static/images/logo-long.svg deleted file mode 100644 index 3a13bac9..00000000 --- a/static/images/logo-long.svg +++ /dev/null @@ -1,225 +0,0 @@ - - - -image/svg+xml diff --git a/static/images/logo-mission.svg b/static/images/logo-mission.svg new file mode 100644 index 00000000..d9a90002 --- /dev/null +++ b/static/images/logo-mission.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/static/images/logo-name.svg b/static/images/logo-name.svg new file mode 100644 index 00000000..8d6adb6d --- /dev/null +++ b/static/images/logo-name.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/images/logo-square.svg b/static/images/logo-square.svg new file mode 100644 index 00000000..5502b571 --- /dev/null +++ b/static/images/logo-square.svg @@ -0,0 +1,3 @@ + + + diff --git a/static/images/uoa-logo-small.png b/static/images/uoa-logo-small.png new file mode 100755 index 00000000..8d9ff890 Binary files /dev/null and b/static/images/uoa-logo-small.png differ diff --git a/static/images/uoa.png b/static/images/uoa.png deleted file mode 100644 index 8fbbaf3c..00000000 Binary files a/static/images/uoa.png and /dev/null differ diff --git a/static/js/discourse-api.js b/static/js/discourse-api.js new file mode 100644 index 00000000..469b73c5 --- /dev/null +++ b/static/js/discourse-api.js @@ -0,0 +1,170 @@ +/** + * Discourse API Integration for enviPath Community + * Handles fetching topics from the Discourse forum API + */ + +class DiscourseAPI { + constructor() { + this.baseUrl = 'https://community.envipath.org'; + this.categoryId = 10; // Announcements category + this.limit = 3; // Number of topics to fetch + } + + /** + * Fetch topics from Discourse API + * @param {number} limit - Number of topics to fetch + * @returns {Promise} Array of topic objects + */ + async fetchTopics(limit = this.limit) { + try { + const url = `${this.baseUrl}/c/announcements/${this.categoryId}.json`; + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return this.processTopics(data.topic_list.topics, limit); + } catch (error) { + console.error('Error fetching Discourse topics:', error); + return this.getFallbackTopics(); + } + } + + /** + * Process raw Discourse topics into standardized format + * @param {Array} topics - Raw topics from Discourse API + * @param {number} limit - Number of topics to return + * @returns {Array} Processed topics + */ + processTopics(topics, limit) { + return topics + .slice(0, limit) + .map(topic => ({ + id: topic.id, + title: topic.title, + excerpt: this.extractExcerpt(topic.excerpt), + url: `${this.baseUrl}/t/${topic.slug}/${topic.id}`, + replies: topic.reply_count, + views: topic.views, + created_at: topic.created_at, + category: 'Announcements', + category_id: this.categoryId, + author: topic.last_poster_username, + author_avatar: this.getAvatarUrl(topic.last_poster_username) + })) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); // Latest first + } + + /** + * Extract excerpt from topic content + * @param {string} excerpt - Raw excerpt from Discourse + * @returns {string} Cleaned excerpt + */ + extractExcerpt(excerpt) { + if (!excerpt) return 'Click to read more'; + + // Remove HTML tags and clean up; collapse whitespace; do not add manual ellipsis + return excerpt + .replace(/<[^>]*>/g, '') // Remove HTML tags + .replace(/ /g, ' ') // Replace   with spaces + .replace(/&/g, '&') // Replace & with & + .replace(/</g, '<') // Replace < with < + .replace(/>/g, '>') // Replace > with > + .replace(/\s+/g, ' ') // Collapse all whitespace/newlines + .trim() + } + + /** + * Get avatar URL for user + * @param {string} username - Username + * @returns {string} Avatar URL + */ + getAvatarUrl(username) { + if (!username) return `${this.baseUrl}/letter_avatar_proxy/v4/letter/u/1.png`; + return `${this.baseUrl}/user_avatar/${this.baseUrl.replace('https://', '')}/${username}/40/1_1.png`; + } + + /** + * Get fallback topics when API fails + * @returns {Array} Fallback topics + */ + getFallbackTopics() { + return [ + { + id: 110, + title: "enviPath Beta Update: Major Improvements to Prediction, Analysis & Collaboration!", + excerpt: "We're excited to announce major updates to the enviPath beta platform! This release includes significant improvements to our prediction algorithms, enhanced analysis tools, and new collaboration features that will make environmental biotransformation research more accessible and efficient.", + url: "https://community.envipath.org/t/envipath-beta-update-major-improvements-to-prediction-analysis-collaboration/110", + replies: 0, + views: 16, + created_at: "2025-09-23T00:00:00Z", + category: "Announcements", + category_id: 10, + author: "wicker", + author_avatar: "https://community.envipath.org/user_avatar/community.envipath.org/wicker/40/1_1.png" + } + ]; + } + + /** + * Format date for display + * @param {string} dateString - ISO date string + * @returns {string} Formatted date + */ + formatDate(dateString) { + const date = new Date(dateString); + return date.toLocaleDateString(); + } + + + /** + * Load topics and call render function + * @param {string} containerId - ID of container element + * @param {Function} renderCallback - Function to render topics + */ + async loadTopics(containerId = 'community-news-container', renderCallback = null) { + const container = document.getElementById(containerId); + if (!container) { + console.error(`Container with ID '${containerId}' not found`); + return; + } + + // Hide loading spinner + const loading = document.getElementById('loading'); + if (loading) { + loading.style.display = 'none'; + } + + try { + const topics = await this.fetchTopics(); + + if (renderCallback && typeof renderCallback === 'function') { + renderCallback(topics); + } else { + // Default rendering - just log topics + console.log('Topics loaded:', topics); + } + } catch (error) { + console.error('Error loading topics:', error); + container.innerHTML = '

No updates found. Head over to the community to see the latest discussions.

'; + } + } +} + +// Export for use in other scripts +window.DiscourseAPI = DiscourseAPI; + +// Auto-initialize if container exists +document.addEventListener('DOMContentLoaded', function() { + if (document.getElementById('community-news-container')) { + const discourseAPI = new DiscourseAPI(); + discourseAPI.loadTopics('community-news-container', function(topics) { + // This will be handled by the template's render function + if (window.renderDiscourseTopics) { + window.renderDiscourseTopics(topics); + } + }); + } +}); diff --git a/templates/collections/joblog.html b/templates/collections/joblog.html index 7075e08e..07e15e71 100644 --- a/templates/collections/joblog.html +++ b/templates/collections/joblog.html @@ -1,6 +1,5 @@ {% extends "framework.html" %} {% load static %} -{% load envipytags %} {% block content %}
diff --git a/templates/collections/objects_list.html b/templates/collections/objects_list.html index bfe98d63..34519ab4 100644 --- a/templates/collections/objects_list.html +++ b/templates/collections/objects_list.html @@ -192,7 +192,7 @@
{% if object_type == 'package' %} {% for obj in reviewed_objects %} - {{ obj.name }} + {{ obj.name|safe }} {{ obj.name }}{# ({{ obj.package.name }}) #} + {{ obj.name|safe }}{# ({{ obj.package.name }}) #}
{% if object_type == 'package' %} {% for obj in unreviewed_objects %} - {{ obj.name }} + {{ obj.name|safe }} {% endfor %} {% else %} {% for obj in unreviewed_objects|slice:":50" %} - {{ obj.name }} + {{ obj.name|safe }} {% endfor %} {% endif %}
@@ -236,9 +236,9 @@ diff --git a/templates/framework.html b/templates/framework.html index 80c7a6d5..516d8194 100644 --- a/templates/framework.html +++ b/templates/framework.html @@ -1,15 +1,17 @@ - + {% load static %} {{ title }} - - {# TODO use bundles from bootstrap 3.3.7 #} + + {# Favicon #} + + + {# Tailwind CSS disabled for legacy Bootstrap framework #} + {# Pages using this framework will be migrated to framework_modern.html incrementally #} + {# #} + + {# Legacy Bootstrap 3.3.7 - scoped to .legacy-bootstrap #} @@ -20,7 +22,16 @@ - + + {# Bootstrap compatibility styles #} + + - {# Favicon #} - + @@ -68,6 +78,8 @@ + +
+
+ +
{% if breadcrumbs %}
@@ -221,7 +235,8 @@ {% endif %}
- + +

@@ -258,6 +273,9 @@
+
+ + {% block modals %} {% include "modals/cite_modal.html" %} - {% include "modals/signup_modal.html" %} {% include "modals/predict_modal.html" %} {% include "modals/batch_predict_modal.html" %} {% endblock %} diff --git a/templates/framework_modern.html b/templates/framework_modern.html new file mode 100644 index 00000000..bd7ffce2 --- /dev/null +++ b/templates/framework_modern.html @@ -0,0 +1,206 @@ + + + {% load static %} + + {{ title }} + + + + + {# Favicon #} + + + {# Tailwind CSS + DaisyUI Output #} + + + {# jQuery - Keep for compatibility with existing JS #} + + + {# Font Awesome #} + + + {# Discourse embed for community #} + + + + + {# General EP JS #} + + {# Modal Steps for Stepwise Modal Wizards #} + + + {% if not debug %} + + + + {% endif %} + + + {% include "includes/navbar.html" %} + + {# Main Content Area #} +
+ {% block main_content %} + {# Breadcrumbs - outside main content, optional #} + {% if breadcrumbs %} +
+ +
+ {% endif %} + + {# Main content container - paper effect on medium+ screens #} +
+ {# Messages - inside paper #} + {% if message %} +
{{ message }}
+ {% endif %} + + {# Page content - no enforced styles #} + {% block content %} + {% endblock content %} + + {# License - inside paper if present #} + {% if meta.url_contains_package and meta.current_package.license %} +
+ +
License
+
+ + License + +
+
+ {% endif %} +
+ {% endblock main_content %} +
+ + {% include "includes/footer.html" %} + + {# Floating Help Tab #} + {% if not public_mode %} + + {% endif %} + + {% block modals %} + {% include "modals/search_modal.html" %} + {% endblock %} + + + + diff --git a/templates/includes/footer.html b/templates/includes/footer.html new file mode 100644 index 00000000..7408d29d --- /dev/null +++ b/templates/includes/footer.html @@ -0,0 +1,69 @@ +{% load static %} +
+ + + +
diff --git a/templates/includes/navbar.html b/templates/includes/navbar.html new file mode 100644 index 00000000..b2d59a88 --- /dev/null +++ b/templates/includes/navbar.html @@ -0,0 +1,147 @@ +{% load static %} +{# Modern DaisyUI Navbar #} + + + diff --git a/templates/index/index.html b/templates/index/index.html index fe83f845..1ca5394d 100644 --- a/templates/index/index.html +++ b/templates/index/index.html @@ -1,186 +1,360 @@ -{% extends "framework.html" %} +{% extends "framework_modern.html" %} {% load static %} -{% block content %} - -