diff --git a/.env b/.env deleted file mode 100644 index 1d44286..0000000 --- a/.env +++ /dev/null @@ -1,45 +0,0 @@ -# Domain -# This would be set to the production domain with an env var on deployment -# used by Traefik to transmit traffic and aqcuire TLS certificates -DOMAIN=localhost -# To test the local Traefik config -# DOMAIN=localhost.tiangolo.com - -# Used by the backend to generate links in emails to the frontend -FRONTEND_HOST=http://localhost:5173 -# In staging and production, set this env var to the frontend host, e.g. -# FRONTEND_HOST=https://dashboard.example.com - -# Environment: local, staging, production -ENVIRONMENT=local - -PROJECT_NAME="Full Stack FastAPI Project" -STACK_NAME=full-stack-fastapi-project - -# Backend -BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com" -SECRET_KEY=changethis -FIRST_SUPERUSER=admin@example.com -FIRST_SUPERUSER_PASSWORD=changethis - -# Emails -SMTP_HOST= -SMTP_USER= -SMTP_PASSWORD= -EMAILS_FROM_EMAIL=info@example.com -SMTP_TLS=True -SMTP_SSL=False -SMTP_PORT=587 - -# Postgres -POSTGRES_SERVER=localhost -POSTGRES_PORT=5432 -POSTGRES_DB=app -POSTGRES_USER=postgres -POSTGRES_PASSWORD=changethis - -SENTRY_DSN= - -# Configure these with your own Docker registry images -DOCKER_IMAGE_BACKEND=backend -DOCKER_IMAGE_FRONTEND=frontend diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9a0b2af --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# Template for the app stack's .env. The workspace root's scripts/setup.sh copies +# this file to .env on first run and then keeps the shared keys in sync with the +# root .env. Secrets stay as `changethis` here and are generated locally. + +# Domain Traefik routes on and acquires TLS certificates for. +DOMAIN=localhost + +# Used by the backend to build links in outgoing emails. +FRONTEND_HOST=http://localhost:5173 + +# local, staging, production +ENVIRONMENT=local + +PROJECT_NAME="Fluksio" +STACK_NAME=fluksio-app + +# Backend +BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,http://app.localhost,https://localhost,https://localhost:5173" +SECRET_KEY=changethis +FIRST_SUPERUSER=admin@fluksio.com +FIRST_SUPERUSER_PASSWORD=changethis + +# Emails +SMTP_HOST= +SMTP_USER= +SMTP_PASSWORD= +EMAILS_FROM_EMAIL=noreply@fluksio.com +SMTP_TLS=True +SMTP_SSL=False +SMTP_PORT=587 + +# Postgres +POSTGRES_SERVER=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=app +POSTGRES_USER=postgres +POSTGRES_PASSWORD=changethis + +SENTRY_DSN= + +# Docker registry images +DOCKER_IMAGE_BACKEND=fluksio-backend +DOCKER_IMAGE_FRONTEND=fluksio-frontend diff --git a/.gitignore b/.gitignore index 8032175..4fa5595 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -*.png .vscode/* !.vscode/extensions.json node_modules/ @@ -6,3 +5,13 @@ node_modules/ /playwright-report/ /blob-report/ /playwright/.cache/ + +# Generated locally by the workspace root's scripts/setup.sh; never committed. +.env +secrets/ + +__pycache__/ +*.py[cod] + +# Rendered pipeline graphs (matplotlib output from backend/app/flow). +pipeline.png diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index aaf085e..0000000 --- a/.gitmodules +++ /dev/null @@ -1,6 +0,0 @@ -[submodule "backend"] - path = backend - url = ssh://git@codeberg.org/fluksio/backend.git -[submodule "frontend"] - path = frontend - url = ssh://git@codeberg.org/fluksio/frontend.git \ No newline at end of file diff --git a/NOTEPAD.md b/NOTEPAD.md new file mode 100644 index 0000000..ad6a3c9 --- /dev/null +++ b/NOTEPAD.md @@ -0,0 +1,7 @@ +This file captures tasks which derive from roadmap tasks (unfinished, deferred), bugs encountered during usage and feature requests/improvements which are not fitting directly in the roadmap. +Always sort by priority and put tasks blocked by other tasks/features at the dedicated section. +When working on a task, check for other, similar tasks that could be resolved on the way. +Use following pattern to classify tasks: TYPE/SCOPE +Where TYPE could be BUG, FEAT, PERF, CHORE and SCOPE could be UX, UI, AI, SIM, CAD appended by MOBILE if only for mobile use case. +Don't write temporary reasons for deferring a task in the task description (only strategical reasons should be noted). +Deferring because out of scope is fine, but don't mention deferring than. \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..53947db --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,11 @@ +# Roadmap + +Implementation strategy and record of existing/planned features. Completed items are +terse checklists - technical detail lives in the architecture docs (`docs-private/architecture/`). Remaining tasks keep +enough scope to be actionable. + +Legend: `[x]` done · `[ ]` planned · sub-lists split done vs. remaining for partial items. + +Within each phase, remaining `[ ]` items are listed in rough priority order: correctness +and robustness of the existing modeling workflow (especially after edits/imports) precede +new feature breadth. \ No newline at end of file diff --git a/backend b/backend deleted file mode 160000 index d6ac71f..0000000 --- a/backend +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d6ac71f1b1c85be4ed0dc0ee31bf77d497eefb94 diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..c0de4ab --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,8 @@ +# Python +__pycache__ +app.egg-info +*.pyc +.mypy_cache +.coverage +htmlcov +.venv diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..adc0f1a --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,9 @@ +*.png +__pycache__ +app.egg-info +*.pyc +.mypy_cache +.coverage +htmlcov +.cache +.venv diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..9f31dcd --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,45 @@ +FROM python:3.10 + +ENV PYTHONUNBUFFERED=1 + +# Install uv +# Ref: https://docs.astral.sh/uv/guides/integration/docker/#installing-uv +COPY --from=ghcr.io/astral-sh/uv:0.9.26 /uv /uvx /bin/ + +# Compile bytecode +# Ref: https://docs.astral.sh/uv/guides/integration/docker/#compiling-bytecode +ENV UV_COMPILE_BYTECODE=1 + +# uv Cache +# Ref: https://docs.astral.sh/uv/guides/integration/docker/#caching +ENV UV_LINK_MODE=copy + +WORKDIR /app/ + +# Place executables in the environment at the front of the path +# Ref: https://docs.astral.sh/uv/guides/integration/docker/#using-the-environment +ENV PATH="/app/.venv/bin:$PATH" + +# Install dependencies +# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --frozen --no-install-workspace --package app + +COPY ./backend/scripts /app/backend/scripts + +COPY ./backend/pyproject.toml ./backend/alembic.ini /app/backend/ + +COPY ./backend/app /app/backend/app + +# Sync the project +# Ref: https://docs.astral.sh/uv/guides/integration/docker/#intermediate-layers +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --frozen --package app + +WORKDIR /app/backend/ + +CMD ["fastapi", "run", "--workers", "4", "app/main.py"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100755 index 0000000..24841c2 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,71 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = app/alembic + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# timezone to use when rendering the date +# within the migration file as well as the filename. +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +#truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; this defaults +# to alembic/versions. When using multiple version +# directories, initial revisions must be specified with --version-path +# version_locations = %(here)s/bar %(here)s/bat alembic/versions + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/alembic/README b/backend/app/alembic/README new file mode 100755 index 0000000..2500aa1 --- /dev/null +++ b/backend/app/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. diff --git a/backend/app/alembic/env.py b/backend/app/alembic/env.py new file mode 100755 index 0000000..7f29c04 --- /dev/null +++ b/backend/app/alembic/env.py @@ -0,0 +1,84 @@ +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +# target_metadata = None + +from app.models import SQLModel # noqa +from app.core.config import settings # noqa + +target_metadata = SQLModel.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_url(): + return str(settings.SQLALCHEMY_DATABASE_URI) + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = get_url() + context.configure( + url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + configuration = config.get_section(config.config_ini_section) + configuration["sqlalchemy.url"] = get_url() + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata, compare_type=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/app/alembic/script.py.mako b/backend/app/alembic/script.py.mako new file mode 100755 index 0000000..217a9a8 --- /dev/null +++ b/backend/app/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/backend/app/alembic/versions/.keep b/backend/app/alembic/versions/.keep new file mode 100755 index 0000000..e69de29 diff --git a/backend/app/alembic/versions/1a31ce608336_add_cascade_delete_relationships.py b/backend/app/alembic/versions/1a31ce608336_add_cascade_delete_relationships.py new file mode 100644 index 0000000..10e47a1 --- /dev/null +++ b/backend/app/alembic/versions/1a31ce608336_add_cascade_delete_relationships.py @@ -0,0 +1,37 @@ +"""Add cascade delete relationships + +Revision ID: 1a31ce608336 +Revises: d98dd8ec85a3 +Create Date: 2024-07-31 22:24:34.447891 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '1a31ce608336' +down_revision = 'd98dd8ec85a3' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('item', 'owner_id', + existing_type=sa.UUID(), + nullable=False) + op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey') + op.create_foreign_key(None, 'item', 'user', ['owner_id'], ['id'], ondelete='CASCADE') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'item', type_='foreignkey') + op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id']) + op.alter_column('item', 'owner_id', + existing_type=sa.UUID(), + nullable=True) + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/9c0a54914c78_add_max_length_for_string_varchar_.py b/backend/app/alembic/versions/9c0a54914c78_add_max_length_for_string_varchar_.py new file mode 100755 index 0000000..78a4177 --- /dev/null +++ b/backend/app/alembic/versions/9c0a54914c78_add_max_length_for_string_varchar_.py @@ -0,0 +1,69 @@ +"""Add max length for string(varchar) fields in User and Items models + +Revision ID: 9c0a54914c78 +Revises: e2412789c190 +Create Date: 2024-06-17 14:42:44.639457 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = '9c0a54914c78' +down_revision = 'e2412789c190' +branch_labels = None +depends_on = None + + +def upgrade(): + # Adjust the length of the email field in the User table + op.alter_column('user', 'email', + existing_type=sa.String(), + type_=sa.String(length=255), + existing_nullable=False) + + # Adjust the length of the full_name field in the User table + op.alter_column('user', 'full_name', + existing_type=sa.String(), + type_=sa.String(length=255), + existing_nullable=True) + + # Adjust the length of the title field in the Item table + op.alter_column('item', 'title', + existing_type=sa.String(), + type_=sa.String(length=255), + existing_nullable=False) + + # Adjust the length of the description field in the Item table + op.alter_column('item', 'description', + existing_type=sa.String(), + type_=sa.String(length=255), + existing_nullable=True) + + +def downgrade(): + # Revert the length of the email field in the User table + op.alter_column('user', 'email', + existing_type=sa.String(length=255), + type_=sa.String(), + existing_nullable=False) + + # Revert the length of the full_name field in the User table + op.alter_column('user', 'full_name', + existing_type=sa.String(length=255), + type_=sa.String(), + existing_nullable=True) + + # Revert the length of the title field in the Item table + op.alter_column('item', 'title', + existing_type=sa.String(length=255), + type_=sa.String(), + existing_nullable=False) + + # Revert the length of the description field in the Item table + op.alter_column('item', 'description', + existing_type=sa.String(length=255), + type_=sa.String(), + existing_nullable=True) diff --git a/backend/app/alembic/versions/d98dd8ec85a3_edit_replace_id_integers_in_all_models_.py b/backend/app/alembic/versions/d98dd8ec85a3_edit_replace_id_integers_in_all_models_.py new file mode 100755 index 0000000..37af1fa --- /dev/null +++ b/backend/app/alembic/versions/d98dd8ec85a3_edit_replace_id_integers_in_all_models_.py @@ -0,0 +1,90 @@ +"""Edit replace id integers in all models to use UUID instead + +Revision ID: d98dd8ec85a3 +Revises: 9c0a54914c78 +Create Date: 2024-07-19 04:08:04.000976 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision = 'd98dd8ec85a3' +down_revision = '9c0a54914c78' +branch_labels = None +depends_on = None + + +def upgrade(): + # Ensure uuid-ossp extension is available + op.execute('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"') + + # Create a new UUID column with a default UUID value + op.add_column('user', sa.Column('new_id', postgresql.UUID(as_uuid=True), default=sa.text('uuid_generate_v4()'))) + op.add_column('item', sa.Column('new_id', postgresql.UUID(as_uuid=True), default=sa.text('uuid_generate_v4()'))) + op.add_column('item', sa.Column('new_owner_id', postgresql.UUID(as_uuid=True), nullable=True)) + + # Populate the new columns with UUIDs + op.execute('UPDATE "user" SET new_id = uuid_generate_v4()') + op.execute('UPDATE item SET new_id = uuid_generate_v4()') + op.execute('UPDATE item SET new_owner_id = (SELECT new_id FROM "user" WHERE "user".id = item.owner_id)') + + # Set the new_id as not nullable + op.alter_column('user', 'new_id', nullable=False) + op.alter_column('item', 'new_id', nullable=False) + + # Drop old columns and rename new columns + op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey') + op.drop_column('item', 'owner_id') + op.alter_column('item', 'new_owner_id', new_column_name='owner_id') + + op.drop_column('user', 'id') + op.alter_column('user', 'new_id', new_column_name='id') + + op.drop_column('item', 'id') + op.alter_column('item', 'new_id', new_column_name='id') + + # Create primary key constraint + op.create_primary_key('user_pkey', 'user', ['id']) + op.create_primary_key('item_pkey', 'item', ['id']) + + # Recreate foreign key constraint + op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id']) + +def downgrade(): + # Reverse the upgrade process + op.add_column('user', sa.Column('old_id', sa.Integer, autoincrement=True)) + op.add_column('item', sa.Column('old_id', sa.Integer, autoincrement=True)) + op.add_column('item', sa.Column('old_owner_id', sa.Integer, nullable=True)) + + # Populate the old columns with default values + # Generate sequences for the integer IDs if not exist + op.execute('CREATE SEQUENCE IF NOT EXISTS user_id_seq AS INTEGER OWNED BY "user".old_id') + op.execute('CREATE SEQUENCE IF NOT EXISTS item_id_seq AS INTEGER OWNED BY item.old_id') + + op.execute('SELECT setval(\'user_id_seq\', COALESCE((SELECT MAX(old_id) + 1 FROM "user"), 1), false)') + op.execute('SELECT setval(\'item_id_seq\', COALESCE((SELECT MAX(old_id) + 1 FROM item), 1), false)') + + op.execute('UPDATE "user" SET old_id = nextval(\'user_id_seq\')') + op.execute('UPDATE item SET old_id = nextval(\'item_id_seq\'), old_owner_id = (SELECT old_id FROM "user" WHERE "user".id = item.owner_id)') + + # Drop new columns and rename old columns back + op.drop_constraint('item_owner_id_fkey', 'item', type_='foreignkey') + op.drop_column('item', 'owner_id') + op.alter_column('item', 'old_owner_id', new_column_name='owner_id') + + op.drop_column('user', 'id') + op.alter_column('user', 'old_id', new_column_name='id') + + op.drop_column('item', 'id') + op.alter_column('item', 'old_id', new_column_name='id') + + # Create primary key constraint + op.create_primary_key('user_pkey', 'user', ['id']) + op.create_primary_key('item_pkey', 'item', ['id']) + + # Recreate foreign key constraint + op.create_foreign_key('item_owner_id_fkey', 'item', 'user', ['owner_id'], ['id']) diff --git a/backend/app/alembic/versions/e2412789c190_initialize_models.py b/backend/app/alembic/versions/e2412789c190_initialize_models.py new file mode 100644 index 0000000..7529ea9 --- /dev/null +++ b/backend/app/alembic/versions/e2412789c190_initialize_models.py @@ -0,0 +1,54 @@ +"""Initialize models + +Revision ID: e2412789c190 +Revises: +Create Date: 2023-11-24 22:55:43.195942 + +""" +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from alembic import op + +# revision identifiers, used by Alembic. +revision = "e2412789c190" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "user", + sa.Column("email", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("is_superuser", sa.Boolean(), nullable=False), + sa.Column("full_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column( + "hashed_password", sqlmodel.sql.sqltypes.AutoString(), nullable=False + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_user_email"), "user", ["email"], unique=True) + op.create_table( + "item", + sa.Column("description", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("title", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("owner_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["owner_id"], + ["user.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("item") + op.drop_index(op.f("ix_user_email"), table_name="user") + op.drop_table("user") + # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/fe56fa70289e_add_created_at_to_user_and_item.py b/backend/app/alembic/versions/fe56fa70289e_add_created_at_to_user_and_item.py new file mode 100644 index 0000000..3e15754 --- /dev/null +++ b/backend/app/alembic/versions/fe56fa70289e_add_created_at_to_user_and_item.py @@ -0,0 +1,31 @@ +"""Add created_at to User and Item + +Revision ID: fe56fa70289e +Revises: 1a31ce608336 +Create Date: 2026-01-23 15:50:37.171462 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + + +# revision identifiers, used by Alembic. +revision = 'fe56fa70289e' +down_revision = '1a31ce608336' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('item', sa.Column('created_at', sa.DateTime(timezone=True), nullable=True)) + op.add_column('user', sa.Column('created_at', sa.DateTime(timezone=True), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'created_at') + op.drop_column('item', 'created_at') + # ### end Alembic commands ### diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..c2b83c8 --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,57 @@ +from collections.abc import Generator +from typing import Annotated + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jwt.exceptions import InvalidTokenError +from pydantic import ValidationError +from sqlmodel import Session + +from app.core import security +from app.core.config import settings +from app.core.db import engine +from app.models import TokenPayload, User + +reusable_oauth2 = OAuth2PasswordBearer( + tokenUrl=f"{settings.API_V1_STR}/login/access-token" +) + + +def get_db() -> Generator[Session, None, None]: + with Session(engine) as session: + yield session + + +SessionDep = Annotated[Session, Depends(get_db)] +TokenDep = Annotated[str, Depends(reusable_oauth2)] + + +def get_current_user(session: SessionDep, token: TokenDep) -> User: + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] + ) + token_data = TokenPayload(**payload) + except (InvalidTokenError, ValidationError): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Could not validate credentials", + ) + user = session.get(User, token_data.sub) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + return user + + +CurrentUser = Annotated[User, Depends(get_current_user)] + + +def get_current_active_superuser(current_user: CurrentUser) -> User: + if not current_user.is_superuser: + raise HTTPException( + status_code=403, detail="The user doesn't have enough privileges" + ) + return current_user diff --git a/backend/app/api/main.py b/backend/app/api/main.py new file mode 100644 index 0000000..eac18c8 --- /dev/null +++ b/backend/app/api/main.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter + +from app.api.routes import items, login, private, users, utils +from app.core.config import settings + +api_router = APIRouter() +api_router.include_router(login.router) +api_router.include_router(users.router) +api_router.include_router(utils.router) +api_router.include_router(items.router) + + +if settings.ENVIRONMENT == "local": + api_router.include_router(private.router) diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py new file mode 100644 index 0000000..2b2ce57 --- /dev/null +++ b/backend/app/api/routes/items.py @@ -0,0 +1,112 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException +from sqlmodel import func, select + +from app.api.deps import CurrentUser, SessionDep +from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message + +router = APIRouter(prefix="/items", tags=["items"]) + + +@router.get("/", response_model=ItemsPublic) +def read_items( + session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 +) -> Any: + """ + Retrieve items. + """ + + if current_user.is_superuser: + count_statement = select(func.count()).select_from(Item) + count = session.exec(count_statement).one() + statement = ( + select(Item).order_by(Item.created_at.desc()).offset(skip).limit(limit) + ) + items = session.exec(statement).all() + else: + count_statement = ( + select(func.count()) + .select_from(Item) + .where(Item.owner_id == current_user.id) + ) + count = session.exec(count_statement).one() + statement = ( + select(Item) + .where(Item.owner_id == current_user.id) + .order_by(Item.created_at.desc()) + .offset(skip) + .limit(limit) + ) + items = session.exec(statement).all() + + return ItemsPublic(data=items, count=count) + + +@router.get("/{id}", response_model=ItemPublic) +def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: + """ + Get item by ID. + """ + item = session.get(Item, id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + if not current_user.is_superuser and (item.owner_id != current_user.id): + raise HTTPException(status_code=403, detail="Not enough permissions") + return item + + +@router.post("/", response_model=ItemPublic) +def create_item( + *, session: SessionDep, current_user: CurrentUser, item_in: ItemCreate +) -> Any: + """ + Create new item. + """ + item = Item.model_validate(item_in, update={"owner_id": current_user.id}) + session.add(item) + session.commit() + session.refresh(item) + return item + + +@router.put("/{id}", response_model=ItemPublic) +def update_item( + *, + session: SessionDep, + current_user: CurrentUser, + id: uuid.UUID, + item_in: ItemUpdate, +) -> Any: + """ + Update an item. + """ + item = session.get(Item, id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + if not current_user.is_superuser and (item.owner_id != current_user.id): + raise HTTPException(status_code=403, detail="Not enough permissions") + update_dict = item_in.model_dump(exclude_unset=True) + item.sqlmodel_update(update_dict) + session.add(item) + session.commit() + session.refresh(item) + return item + + +@router.delete("/{id}") +def delete_item( + session: SessionDep, current_user: CurrentUser, id: uuid.UUID +) -> Message: + """ + Delete an item. + """ + item = session.get(Item, id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + if not current_user.is_superuser and (item.owner_id != current_user.id): + raise HTTPException(status_code=403, detail="Not enough permissions") + session.delete(item) + session.commit() + return Message(message="Item deleted successfully") diff --git a/backend/app/api/routes/login.py b/backend/app/api/routes/login.py new file mode 100644 index 0000000..58441e3 --- /dev/null +++ b/backend/app/api/routes/login.py @@ -0,0 +1,123 @@ +from datetime import timedelta +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import HTMLResponse +from fastapi.security import OAuth2PasswordRequestForm + +from app import crud +from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser +from app.core import security +from app.core.config import settings +from app.models import Message, NewPassword, Token, UserPublic, UserUpdate +from app.utils import ( + generate_password_reset_token, + generate_reset_password_email, + send_email, + verify_password_reset_token, +) + +router = APIRouter(tags=["login"]) + + +@router.post("/login/access-token") +def login_access_token( + session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] +) -> Token: + """ + OAuth2 compatible token login, get an access token for future requests + """ + user = crud.authenticate( + session=session, email=form_data.username, password=form_data.password + ) + if not user: + raise HTTPException(status_code=400, detail="Incorrect email or password") + elif not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return Token( + access_token=security.create_access_token( + user.id, expires_delta=access_token_expires + ) + ) + + +@router.post("/login/test-token", response_model=UserPublic) +def test_token(current_user: CurrentUser) -> Any: + """ + Test access token + """ + return current_user + + +@router.post("/password-recovery/{email}") +def recover_password(email: str, session: SessionDep) -> Message: + """ + Password Recovery + """ + user = crud.get_user_by_email(session=session, email=email) + + # Always return the same response to prevent email enumeration attacks + # Only send email if user actually exists + if user: + password_reset_token = generate_password_reset_token(email=email) + email_data = generate_reset_password_email( + email_to=user.email, email=email, token=password_reset_token + ) + send_email( + email_to=user.email, + subject=email_data.subject, + html_content=email_data.html_content, + ) + return Message( + message="If that email is registered, we sent a password recovery link" + ) + + +@router.post("/reset-password/") +def reset_password(session: SessionDep, body: NewPassword) -> Message: + """ + Reset password + """ + email = verify_password_reset_token(token=body.token) + if not email: + raise HTTPException(status_code=400, detail="Invalid token") + user = crud.get_user_by_email(session=session, email=email) + if not user: + # Don't reveal that the user doesn't exist - use same error as invalid token + raise HTTPException(status_code=400, detail="Invalid token") + elif not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + user_in_update = UserUpdate(password=body.new_password) + crud.update_user( + session=session, + db_user=user, + user_in=user_in_update, + ) + return Message(message="Password updated successfully") + + +@router.post( + "/password-recovery-html-content/{email}", + dependencies=[Depends(get_current_active_superuser)], + response_class=HTMLResponse, +) +def recover_password_html_content(email: str, session: SessionDep) -> Any: + """ + HTML Content for Password Recovery + """ + user = crud.get_user_by_email(session=session, email=email) + + if not user: + raise HTTPException( + status_code=404, + detail="The user with this username does not exist in the system.", + ) + password_reset_token = generate_password_reset_token(email=email) + email_data = generate_reset_password_email( + email_to=user.email, email=email, token=password_reset_token + ) + + return HTMLResponse( + content=email_data.html_content, headers={"subject:": email_data.subject} + ) diff --git a/backend/app/api/routes/private.py b/backend/app/api/routes/private.py new file mode 100644 index 0000000..9f33ef1 --- /dev/null +++ b/backend/app/api/routes/private.py @@ -0,0 +1,38 @@ +from typing import Any + +from fastapi import APIRouter +from pydantic import BaseModel + +from app.api.deps import SessionDep +from app.core.security import get_password_hash +from app.models import ( + User, + UserPublic, +) + +router = APIRouter(tags=["private"], prefix="/private") + + +class PrivateUserCreate(BaseModel): + email: str + password: str + full_name: str + is_verified: bool = False + + +@router.post("/users/", response_model=UserPublic) +def create_user(user_in: PrivateUserCreate, session: SessionDep) -> Any: + """ + Create a new user. + """ + + user = User( + email=user_in.email, + full_name=user_in.full_name, + hashed_password=get_password_hash(user_in.password), + ) + + session.add(user) + session.commit() + + return user diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py new file mode 100644 index 0000000..6172794 --- /dev/null +++ b/backend/app/api/routes/users.py @@ -0,0 +1,229 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import col, delete, func, select + +from app import crud +from app.api.deps import ( + CurrentUser, + SessionDep, + get_current_active_superuser, +) +from app.core.config import settings +from app.core.security import get_password_hash, verify_password +from app.models import ( + Item, + Message, + UpdatePassword, + User, + UserCreate, + UserPublic, + UserRegister, + UsersPublic, + UserUpdate, + UserUpdateMe, +) +from app.utils import generate_new_account_email, send_email + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get( + "/", + dependencies=[Depends(get_current_active_superuser)], + response_model=UsersPublic, +) +def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: + """ + Retrieve users. + """ + + count_statement = select(func.count()).select_from(User) + count = session.exec(count_statement).one() + + statement = select(User).order_by(User.created_at.desc()).offset(skip).limit(limit) + users = session.exec(statement).all() + + return UsersPublic(data=users, count=count) + + +@router.post( + "/", dependencies=[Depends(get_current_active_superuser)], response_model=UserPublic +) +def create_user(*, session: SessionDep, user_in: UserCreate) -> Any: + """ + Create new user. + """ + user = crud.get_user_by_email(session=session, email=user_in.email) + if user: + raise HTTPException( + status_code=400, + detail="The user with this email already exists in the system.", + ) + + user = crud.create_user(session=session, user_create=user_in) + if settings.emails_enabled and user_in.email: + email_data = generate_new_account_email( + email_to=user_in.email, username=user_in.email, password=user_in.password + ) + send_email( + email_to=user_in.email, + subject=email_data.subject, + html_content=email_data.html_content, + ) + return user + + +@router.patch("/me", response_model=UserPublic) +def update_user_me( + *, session: SessionDep, user_in: UserUpdateMe, current_user: CurrentUser +) -> Any: + """ + Update own user. + """ + + if user_in.email: + existing_user = crud.get_user_by_email(session=session, email=user_in.email) + if existing_user and existing_user.id != current_user.id: + raise HTTPException( + status_code=409, detail="User with this email already exists" + ) + user_data = user_in.model_dump(exclude_unset=True) + current_user.sqlmodel_update(user_data) + session.add(current_user) + session.commit() + session.refresh(current_user) + return current_user + + +@router.patch("/me/password", response_model=Message) +def update_password_me( + *, session: SessionDep, body: UpdatePassword, current_user: CurrentUser +) -> Any: + """ + Update own password. + """ + verified, _ = verify_password(body.current_password, current_user.hashed_password) + if not verified: + raise HTTPException(status_code=400, detail="Incorrect password") + if body.current_password == body.new_password: + raise HTTPException( + status_code=400, detail="New password cannot be the same as the current one" + ) + hashed_password = get_password_hash(body.new_password) + current_user.hashed_password = hashed_password + session.add(current_user) + session.commit() + return Message(message="Password updated successfully") + + +@router.get("/me", response_model=UserPublic) +def read_user_me(current_user: CurrentUser) -> Any: + """ + Get current user. + """ + return current_user + + +@router.delete("/me", response_model=Message) +def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any: + """ + Delete own user. + """ + if current_user.is_superuser: + raise HTTPException( + status_code=403, detail="Super users are not allowed to delete themselves" + ) + session.delete(current_user) + session.commit() + return Message(message="User deleted successfully") + + +@router.post("/signup", response_model=UserPublic) +def register_user(session: SessionDep, user_in: UserRegister) -> Any: + """ + Create new user without the need to be logged in. + """ + user = crud.get_user_by_email(session=session, email=user_in.email) + if user: + raise HTTPException( + status_code=400, + detail="The user with this email already exists in the system", + ) + user_create = UserCreate.model_validate(user_in) + user = crud.create_user(session=session, user_create=user_create) + return user + + +@router.get("/{user_id}", response_model=UserPublic) +def read_user_by_id( + user_id: uuid.UUID, session: SessionDep, current_user: CurrentUser +) -> Any: + """ + Get a specific user by id. + """ + user = session.get(User, user_id) + if user == current_user: + return user + if not current_user.is_superuser: + raise HTTPException( + status_code=403, + detail="The user doesn't have enough privileges", + ) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.patch( + "/{user_id}", + dependencies=[Depends(get_current_active_superuser)], + response_model=UserPublic, +) +def update_user( + *, + session: SessionDep, + user_id: uuid.UUID, + user_in: UserUpdate, +) -> Any: + """ + Update a user. + """ + + db_user = session.get(User, user_id) + if not db_user: + raise HTTPException( + status_code=404, + detail="The user with this id does not exist in the system", + ) + if user_in.email: + existing_user = crud.get_user_by_email(session=session, email=user_in.email) + if existing_user and existing_user.id != user_id: + raise HTTPException( + status_code=409, detail="User with this email already exists" + ) + + db_user = crud.update_user(session=session, db_user=db_user, user_in=user_in) + return db_user + + +@router.delete("/{user_id}", dependencies=[Depends(get_current_active_superuser)]) +def delete_user( + session: SessionDep, current_user: CurrentUser, user_id: uuid.UUID +) -> Message: + """ + Delete a user. + """ + user = session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if user == current_user: + raise HTTPException( + status_code=403, detail="Super users are not allowed to delete themselves" + ) + statement = delete(Item).where(col(Item.owner_id) == user_id) + session.exec(statement) # type: ignore + session.delete(user) + session.commit() + return Message(message="User deleted successfully") diff --git a/backend/app/api/routes/utils.py b/backend/app/api/routes/utils.py new file mode 100644 index 0000000..fc09341 --- /dev/null +++ b/backend/app/api/routes/utils.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, Depends +from pydantic.networks import EmailStr + +from app.api.deps import get_current_active_superuser +from app.models import Message +from app.utils import generate_test_email, send_email + +router = APIRouter(prefix="/utils", tags=["utils"]) + + +@router.post( + "/test-email/", + dependencies=[Depends(get_current_active_superuser)], + status_code=201, +) +def test_email(email_to: EmailStr) -> Message: + """ + Test emails. + """ + email_data = generate_test_email(email_to=email_to) + send_email( + email_to=email_to, + subject=email_data.subject, + html_content=email_data.html_content, + ) + return Message(message="Test email sent") + + +@router.get("/health-check/") +async def health_check() -> bool: + return True diff --git a/backend/app/backend_pre_start.py b/backend/app/backend_pre_start.py new file mode 100644 index 0000000..c2f8e29 --- /dev/null +++ b/backend/app/backend_pre_start.py @@ -0,0 +1,39 @@ +import logging + +from sqlalchemy import Engine +from sqlmodel import Session, select +from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed + +from app.core.db import engine + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +max_tries = 60 * 5 # 5 minutes +wait_seconds = 1 + + +@retry( + stop=stop_after_attempt(max_tries), + wait=wait_fixed(wait_seconds), + before=before_log(logger, logging.INFO), + after=after_log(logger, logging.WARN), +) +def init(db_engine: Engine) -> None: + try: + with Session(db_engine) as session: + # Try to create session to check if DB is awake + session.exec(select(1)) + except Exception as e: + logger.error(e) + raise e + + +def main() -> None: + logger.info("Initializing service") + init(engine) + logger.info("Service finished initializing") + + +if __name__ == "__main__": + main() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..650b9f7 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,119 @@ +import secrets +import warnings +from typing import Annotated, Any, Literal + +from pydantic import ( + AnyUrl, + BeforeValidator, + EmailStr, + HttpUrl, + PostgresDsn, + computed_field, + model_validator, +) +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing_extensions import Self + + +def parse_cors(v: Any) -> list[str] | str: + if isinstance(v, str) and not v.startswith("["): + return [i.strip() for i in v.split(",") if i.strip()] + elif isinstance(v, list | str): + return v + raise ValueError(v) + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + # Use top level .env file (one level above ./backend/) + env_file="../.env", + env_ignore_empty=True, + extra="ignore", + ) + API_V1_STR: str = "/api/v1" + SECRET_KEY: str = secrets.token_urlsafe(32) + # 60 minutes * 24 hours * 8 days = 8 days + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 + FRONTEND_HOST: str = "http://localhost:5173" + ENVIRONMENT: Literal["local", "staging", "production"] = "local" + + BACKEND_CORS_ORIGINS: Annotated[ + list[AnyUrl] | str, BeforeValidator(parse_cors) + ] = [] + + @computed_field # type: ignore[prop-decorator] + @property + def all_cors_origins(self) -> list[str]: + return [str(origin).rstrip("/") for origin in self.BACKEND_CORS_ORIGINS] + [ + self.FRONTEND_HOST + ] + + PROJECT_NAME: str + SENTRY_DSN: HttpUrl | None = None + POSTGRES_SERVER: str + POSTGRES_PORT: int = 5432 + POSTGRES_USER: str + POSTGRES_PASSWORD: str = "" + POSTGRES_DB: str = "" + + @computed_field # type: ignore[prop-decorator] + @property + def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn: + return PostgresDsn.build( + scheme="postgresql+psycopg", + username=self.POSTGRES_USER, + password=self.POSTGRES_PASSWORD, + host=self.POSTGRES_SERVER, + port=self.POSTGRES_PORT, + path=self.POSTGRES_DB, + ) + + SMTP_TLS: bool = True + SMTP_SSL: bool = False + SMTP_PORT: int = 587 + SMTP_HOST: str | None = None + SMTP_USER: str | None = None + SMTP_PASSWORD: str | None = None + EMAILS_FROM_EMAIL: EmailStr | None = None + EMAILS_FROM_NAME: str | None = None + + @model_validator(mode="after") + def _set_default_emails_from(self) -> Self: + if not self.EMAILS_FROM_NAME: + self.EMAILS_FROM_NAME = self.PROJECT_NAME + return self + + EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48 + + @computed_field # type: ignore[prop-decorator] + @property + def emails_enabled(self) -> bool: + return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL) + + EMAIL_TEST_USER: EmailStr = "test@example.com" + FIRST_SUPERUSER: EmailStr + FIRST_SUPERUSER_PASSWORD: str + + def _check_default_secret(self, var_name: str, value: str | None) -> None: + if value == "changethis": + message = ( + f'The value of {var_name} is "changethis", ' + "for security, please change it, at least for deployments." + ) + if self.ENVIRONMENT == "local": + warnings.warn(message, stacklevel=1) + else: + raise ValueError(message) + + @model_validator(mode="after") + def _enforce_non_default_secrets(self) -> Self: + self._check_default_secret("SECRET_KEY", self.SECRET_KEY) + self._check_default_secret("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD) + self._check_default_secret( + "FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD + ) + + return self + + +settings = Settings() # type: ignore diff --git a/backend/app/core/db.py b/backend/app/core/db.py new file mode 100644 index 0000000..ba991fb --- /dev/null +++ b/backend/app/core/db.py @@ -0,0 +1,33 @@ +from sqlmodel import Session, create_engine, select + +from app import crud +from app.core.config import settings +from app.models import User, UserCreate + +engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) + + +# make sure all SQLModel models are imported (app.models) before initializing DB +# otherwise, SQLModel might fail to initialize relationships properly +# for more details: https://github.com/fastapi/full-stack-fastapi-template/issues/28 + + +def init_db(session: Session) -> None: + # Tables should be created with Alembic migrations + # But if you don't want to use migrations, create + # the tables un-commenting the next lines + # from sqlmodel import SQLModel + + # This works because the models are already imported and registered from app.models + # SQLModel.metadata.create_all(engine) + + user = session.exec( + select(User).where(User.email == settings.FIRST_SUPERUSER) + ).first() + if not user: + user_in = UserCreate( + email=settings.FIRST_SUPERUSER, + password=settings.FIRST_SUPERUSER_PASSWORD, + is_superuser=True, + ) + user = crud.create_user(session=session, user_create=user_in) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..1e49ebc --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,36 @@ +from datetime import datetime, timedelta, timezone +from typing import Any + +import jwt +from pwdlib import PasswordHash +from pwdlib.hashers.argon2 import Argon2Hasher +from pwdlib.hashers.bcrypt import BcryptHasher + +from app.core.config import settings + +password_hash = PasswordHash( + ( + Argon2Hasher(), + BcryptHasher(), + ) +) + + +ALGORITHM = "HS256" + + +def create_access_token(subject: str | Any, expires_delta: timedelta) -> str: + expire = datetime.now(timezone.utc) + expires_delta + to_encode = {"exp": expire, "sub": str(subject)} + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def verify_password( + plain_password: str, hashed_password: str +) -> tuple[bool, str | None]: + return password_hash.verify_and_update(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return password_hash.hash(password) diff --git a/backend/app/crud.py b/backend/app/crud.py new file mode 100644 index 0000000..a8ceba6 --- /dev/null +++ b/backend/app/crud.py @@ -0,0 +1,68 @@ +import uuid +from typing import Any + +from sqlmodel import Session, select + +from app.core.security import get_password_hash, verify_password +from app.models import Item, ItemCreate, User, UserCreate, UserUpdate + + +def create_user(*, session: Session, user_create: UserCreate) -> User: + db_obj = User.model_validate( + user_create, update={"hashed_password": get_password_hash(user_create.password)} + ) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + + +def update_user(*, session: Session, db_user: User, user_in: UserUpdate) -> Any: + user_data = user_in.model_dump(exclude_unset=True) + extra_data = {} + if "password" in user_data: + password = user_data["password"] + hashed_password = get_password_hash(password) + extra_data["hashed_password"] = hashed_password + db_user.sqlmodel_update(user_data, update=extra_data) + session.add(db_user) + session.commit() + session.refresh(db_user) + return db_user + + +def get_user_by_email(*, session: Session, email: str) -> User | None: + statement = select(User).where(User.email == email) + session_user = session.exec(statement).first() + return session_user + + +# Dummy hash to use for timing attack prevention when user is not found +# This is an Argon2 hash of a random password, used to ensure constant-time comparison +DUMMY_HASH = "$argon2id$v=19$m=65536,t=3,p=4$MjQyZWE1MzBjYjJlZTI0Yw$YTU4NGM5ZTZmYjE2NzZlZjY0ZWY3ZGRkY2U2OWFjNjk" + + +def authenticate(*, session: Session, email: str, password: str) -> User | None: + db_user = get_user_by_email(session=session, email=email) + if not db_user: + # Prevent timing attacks by running password verification even when user doesn't exist + # This ensures the response time is similar whether or not the email exists + verify_password(password, DUMMY_HASH) + return None + verified, updated_password_hash = verify_password(password, db_user.hashed_password) + if not verified: + return None + if updated_password_hash: + db_user.hashed_password = updated_password_hash + session.add(db_user) + session.commit() + session.refresh(db_user) + return db_user + + +def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item: + db_item = Item.model_validate(item_in, update={"owner_id": owner_id}) + session.add(db_item) + session.commit() + session.refresh(db_item) + return db_item diff --git a/backend/app/email-templates/build/new_account.html b/backend/app/email-templates/build/new_account.html new file mode 100644 index 0000000..3445050 --- /dev/null +++ b/backend/app/email-templates/build/new_account.html @@ -0,0 +1,25 @@ +
{{ project_name }} - New Account
Welcome to your new account!
Here are your account details:
Username: {{ username }}
Password: {{ password }}
Go to Dashboard

\ No newline at end of file diff --git a/backend/app/email-templates/build/reset_password.html b/backend/app/email-templates/build/reset_password.html new file mode 100644 index 0000000..4148a5b --- /dev/null +++ b/backend/app/email-templates/build/reset_password.html @@ -0,0 +1,25 @@ +
{{ project_name }} - Password Recovery
Hello {{ username }}
We've received a request to reset your password. You can do it by clicking the button below:
Reset password
Or copy and paste the following link into your browser:
This password will expire in {{ valid_hours }} hours.

If you didn't request a password recovery you can disregard this email.
\ No newline at end of file diff --git a/backend/app/email-templates/build/test_email.html b/backend/app/email-templates/build/test_email.html new file mode 100644 index 0000000..04d0d85 --- /dev/null +++ b/backend/app/email-templates/build/test_email.html @@ -0,0 +1,25 @@ +
{{ project_name }}
Test email for: {{ email }}

\ No newline at end of file diff --git a/backend/app/email-templates/src/new_account.mjml b/backend/app/email-templates/src/new_account.mjml new file mode 100644 index 0000000..f41a3e3 --- /dev/null +++ b/backend/app/email-templates/src/new_account.mjml @@ -0,0 +1,15 @@ + + + + + {{ project_name }} - New Account + Welcome to your new account! + Here are your account details: + Username: {{ username }} + Password: {{ password }} + Go to Dashboard + + + + + diff --git a/backend/app/email-templates/src/reset_password.mjml b/backend/app/email-templates/src/reset_password.mjml new file mode 100644 index 0000000..743f5d7 --- /dev/null +++ b/backend/app/email-templates/src/reset_password.mjml @@ -0,0 +1,17 @@ + + + + + {{ project_name }} - Password Recovery + Hello {{ username }} + We've received a request to reset your password. You can do it by clicking the button below: + Reset password + Or copy and paste the following link into your browser: + {{ link }} + This password will expire in {{ valid_hours }} hours. + + If you didn't request a password recovery you can disregard this email. + + + + diff --git a/backend/app/email-templates/src/test_email.mjml b/backend/app/email-templates/src/test_email.mjml new file mode 100644 index 0000000..45d58d6 --- /dev/null +++ b/backend/app/email-templates/src/test_email.mjml @@ -0,0 +1,11 @@ + + + + + {{ project_name }} + Test email for: {{ email }} + + + + + diff --git a/backend/app/flow/controller.py b/backend/app/flow/controller.py new file mode 100644 index 0000000..88b25fe --- /dev/null +++ b/backend/app/flow/controller.py @@ -0,0 +1,813 @@ +""" +Pipeline Controller with file watching for dynamic node updates. + +This module provides a PipelineController class that manages a pipeline, +watches for changes in node files, and dynamically updates the graph. +Handles errors gracefully to keep the pipeline intact when nodes have issues. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import importlib.util +import logging +import sys +import traceback +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any, Callable, TYPE_CHECKING +from watchfiles import awatch, Change + +from nodes import Node, HttpNode, MqttNode, InfluxDbNode, DelayNode +from pipeline import Pipeline +from state import StateBackend, MemoryState, RedisState +from util import Message + +if TYPE_CHECKING: + from fastapi import FastAPI + + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +class NodeStatus(Enum): + """Status of a loaded node module.""" + + ACTIVE = "active" + ERROR = "error" + DISABLED = "disabled" + + +@dataclass +class LoadedNode: + """Represents a loaded node with metadata.""" + + node: Node | None + file_path: Path + file_hash: str + status: NodeStatus + error_message: str | None = None + last_modified: float = 0.0 + + +@dataclass +class PipelineState: + """Holds the current state of the pipeline controller.""" + + nodes: dict[str, LoadedNode] = field(default_factory=dict) + pipeline: Pipeline | None = None + is_running: bool = False + + +class PipelineController: + """ + Controller for managing a dynamic pipeline with file watching. + + Watches a directory for node files, dynamically loads/unloads nodes, + and rebuilds the pipeline graph when changes are detected. Errors in + individual nodes don't affect the rest of the pipeline. + + :param nodes_dir: Directory containing node files. + :type nodes_dir: str | Path + :param state_backend: State backend for the pipeline. + :type state_backend: StateBackend | None + :param max_workers: Maximum workers for parallel execution. + :type max_workers: int + :param node_params: Default parameters passed to nodes. + :type node_params: dict | None + + :example: + >>> controller = PipelineController( + ... nodes_dir="./nodes", + ... state_backend=RedisState(host="localhost"), + ... max_workers=4, + ... ) + >>> await controller.start() + """ + + def __init__( + self, + nodes_dir: str | Path, + state_backend: StateBackend | None = None, + max_workers: int = 4, + node_params: dict | None = None, + fastapi_app: "FastAPI | None" = None, + ) -> None: + self.nodes_dir = Path(nodes_dir) + self.state_backend = state_backend or MemoryState() + self.max_workers = max_workers + self.node_params = node_params or {} + self._fastapi_app: "FastAPI | None" = fastapi_app + + self._state = PipelineState() + self._watch_task: asyncio.Task | None = None + self._rebuild_lock = asyncio.Lock() + self._shutdown_event = asyncio.Event() + + # Callbacks for external integrations (e.g., frontend notifications) + self._on_node_loaded: list[Callable[[str, Node], None]] = [] + self._on_node_error: list[Callable[[str, str], None]] = [] + self._on_node_removed: list[Callable[[str], None]] = [] + self._on_pipeline_rebuilt: list[Callable[[Pipeline], None]] = [] + + # ------------------------------------------------------------------------- + # Event Callbacks + # ------------------------------------------------------------------------- + + def on_node_loaded(self, callback: Callable[[str, Node], None]) -> None: + """Register a callback for when a node is successfully loaded.""" + self._on_node_loaded.append(callback) + + def on_node_error(self, callback: Callable[[str, str], None]) -> None: + """Register a callback for when a node fails to load.""" + self._on_node_error.append(callback) + + def on_node_removed(self, callback: Callable[[str], None]) -> None: + """Register a callback for when a node is removed.""" + self._on_node_removed.append(callback) + + def on_pipeline_rebuilt(self, callback: Callable[[Pipeline], None]) -> None: + """Register a callback for when the pipeline is rebuilt.""" + self._on_pipeline_rebuilt.append(callback) + + # ------------------------------------------------------------------------- + # Node Loading + # ------------------------------------------------------------------------- + + def _compute_file_hash(self, file_path: Path) -> str: + """Compute MD5 hash of file contents for change detection.""" + content = file_path.read_bytes() + return hashlib.md5(content).hexdigest() + + def _load_node_from_file(self, file_path: Path) -> LoadedNode: + """ + Load a node from a Python file. + + The file should define either: + - A `create_node(params: dict) -> Node` function + - A `node` variable of type Node + - A class that inherits from Node + + :param file_path: Path to the Python file. + :type file_path: Path + :returns: LoadedNode with the loaded node or error info. + :rtype: LoadedNode + """ + file_hash = self._compute_file_hash(file_path) + module_name = f"_dynamic_node_{file_path.stem}_{file_hash[:8]}" + + try: + # Load the module dynamically + spec = importlib.util.spec_from_file_location(module_name, file_path) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot load spec for {file_path}") + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + + # Try different ways to get the node + node: Node | None = None + + # Option 1: create_node function + if hasattr(module, "create_node"): + node = module.create_node(self.node_params) + if not isinstance(node, Node): + raise TypeError( + f"create_node() must return a Node, got {type(node)}" + ) + + # Option 2: node variable + elif hasattr(module, "node"): + node = module.node + if not isinstance(node, Node): + raise TypeError(f"'node' must be a Node instance, got {type(node)}") + + # Option 3: Node subclass + else: + node_classes = [ + obj + for name, obj in vars(module).items() + if isinstance(obj, type) + and issubclass(obj, Node) + and obj is not Node + and not name.startswith("_") + ] + if node_classes: + # Use the first non-private Node subclass found + node = node_classes[0]( + f=lambda params, **kwargs: kwargs, # Default passthrough + requires=[], + provides=[], + params=self.node_params, + name=file_path.stem, + ) + + if node is None: + raise ValueError( + f"No node found in {file_path}. " + "Define create_node(params), a 'node' variable, or a Node subclass." + ) + + # Register HTTP routes for HttpNode trigger nodes + if isinstance(node, HttpNode) and node.mode == HttpNode.Mode.TRIGGER: + if self._fastapi_app is not None: + node.register_route(self._fastapi_app) + else: + logger.warning( + f"HttpNode '{node.name}' is a trigger but no FastAPI app provided" + ) + + # Track MQTT subscriber nodes for later subscription start + # (subscriptions are started in _rebuild_pipeline after pipeline is built) + if isinstance(node, MqttNode) and node.mode == MqttNode.Mode.SUBSCRIBER: + logger.info( + f"MqttNode '{node.name}' is a subscriber, will start after pipeline rebuild" + ) + + # Track DelayNode cron jobs for later start + # (cron jobs are started in _rebuild_pipeline after pipeline is built) + if isinstance(node, DelayNode) and node.cron_expr: + logger.info( + f"DelayNode '{node.name}' has cron '{node.cron_expr}', will start after pipeline rebuild" + ) + + logger.info(f"Loaded node '{node.name}' from {file_path.name}") + + return LoadedNode( + node=node, + file_path=file_path, + file_hash=file_hash, + status=NodeStatus.ACTIVE, + last_modified=file_path.stat().st_mtime, + ) + + except Exception as e: + error_msg = f"{type(e).__name__}: {e}\n{traceback.format_exc()}" + logger.error(f"Failed to load node from {file_path.name}: {e}") + + return LoadedNode( + node=None, + file_path=file_path, + file_hash=file_hash, + status=NodeStatus.ERROR, + error_message=error_msg, + last_modified=file_path.stat().st_mtime if file_path.exists() else 0, + ) + finally: + # Clean up the module from sys.modules to allow reloading + if module_name in sys.modules: + del sys.modules[module_name] + + def _unload_node(self, node_id: str) -> None: + """ + Remove a node from the controller state (sync version). + + :param node_id: The ID of the node to unload. + :type node_id: str + """ + if node_id in self._state.nodes: + loaded_node = self._state.nodes.pop(node_id) + + # Unregister HTTP routes for HttpNode trigger nodes + if ( + loaded_node.node is not None + and isinstance(loaded_node.node, HttpNode) + and loaded_node.node.mode == HttpNode.Mode.TRIGGER + and self._fastapi_app is not None + ): + loaded_node.node.unregister_route(self._fastapi_app) + + # Note: MQTT subscriptions need async cleanup, handled in _unload_node_async + + logger.info(f"Unloaded node '{node_id}'") + for callback in self._on_node_removed: + try: + callback(node_id) + except Exception as e: + logger.error(f"Error in on_node_removed callback: {e}") + + async def _unload_node_async(self, node_id: str) -> None: + """ + Remove a node from the controller state (async version). + + This version properly handles async cleanup like stopping MQTT subscriptions. + + :param node_id: The ID of the node to unload. + :type node_id: str + """ + if node_id in self._state.nodes: + loaded_node = self._state.nodes.pop(node_id) + + # Unregister HTTP routes for HttpNode trigger nodes + if ( + loaded_node.node is not None + and isinstance(loaded_node.node, HttpNode) + and loaded_node.node.mode == HttpNode.Mode.TRIGGER + and self._fastapi_app is not None + ): + loaded_node.node.unregister_route(self._fastapi_app) + + # Stop MQTT subscriptions for MqttNode subscriber nodes + if ( + loaded_node.node is not None + and isinstance(loaded_node.node, MqttNode) + and loaded_node.node.mode == MqttNode.Mode.SUBSCRIBER + ): + try: + await loaded_node.node.stop_subscription() + except Exception as e: + logger.error( + f"Error stopping MQTT subscription for '{node_id}': {e}" + ) + + # Stop cron jobs for DelayNode nodes + if ( + loaded_node.node is not None + and isinstance(loaded_node.node, DelayNode) + and loaded_node.node.cron_expr + ): + try: + await loaded_node.node.stop_cron() + except Exception as e: + logger.error(f"Error stopping cron for '{node_id}': {e}") + + logger.info(f"Unloaded node '{node_id}'") + for callback in self._on_node_removed: + try: + callback(node_id) + except Exception as e: + logger.error(f"Error in on_node_removed callback: {e}") + + # ------------------------------------------------------------------------- + # Pipeline Building + # ------------------------------------------------------------------------- + + async def _rebuild_pipeline(self) -> None: + """ + Rebuild the pipeline from currently loaded nodes. + + Only includes nodes with ACTIVE status. The pipeline is rebuilt + atomically to avoid inconsistent states. Also starts MQTT subscriptions + for subscriber nodes. + """ + async with self._rebuild_lock: + active_nodes = [ + ln.node + for ln in self._state.nodes.values() + if ln.status == NodeStatus.ACTIVE and ln.node is not None + ] + + if not active_nodes: + logger.warning("No active nodes to build pipeline") + self._state.pipeline = None + return + + try: + # Build new pipeline + new_pipeline = Pipeline( + nodes=active_nodes, + max_workers=self.max_workers, + state=self.state_backend, + ) + + self._state.pipeline = new_pipeline + logger.info(f"Pipeline rebuilt with {len(active_nodes)} nodes") + + # Start MQTT subscriptions for subscriber nodes + for node in active_nodes: + if ( + isinstance(node, MqttNode) + and node.mode == MqttNode.Mode.SUBSCRIBER + ): + if not node.is_subscribed: + try: + await node.start_subscription() + except Exception as e: + logger.error( + f"Failed to start MQTT subscription for '{node.name}': {e}" + ) + + # Start cron jobs for DelayNode nodes + for node in active_nodes: + if isinstance(node, DelayNode) and node.cron_expr: + if node._cron_task is None: + try: + await node.start_cron() + except Exception as e: + logger.error( + f"Failed to start cron for '{node.name}': {e}" + ) + + # Notify callbacks + for callback in self._on_pipeline_rebuilt: + try: + callback(new_pipeline) + except Exception as e: + logger.error(f"Error in on_pipeline_rebuilt callback: {e}") + + except Exception as e: + logger.error(f"Failed to rebuild pipeline: {e}") + # Keep the old pipeline intact + + # ------------------------------------------------------------------------- + # File Watching + # ------------------------------------------------------------------------- + + async def _scan_nodes_directory(self) -> None: + """Scan the nodes directory and load all Python files.""" + if not self.nodes_dir.exists(): + logger.warning(f"Nodes directory does not exist: {self.nodes_dir}") + self.nodes_dir.mkdir(parents=True, exist_ok=True) + return + + python_files = list(self.nodes_dir.glob("*.py")) + logger.info(f"Found {len(python_files)} Python files in {self.nodes_dir}") + + for file_path in python_files: + if file_path.name.startswith("_"): + continue # Skip private/internal files + + node_id = file_path.stem + loaded_node = self._load_node_from_file(file_path) + self._state.nodes[node_id] = loaded_node + + # Trigger callbacks + if loaded_node.status == NodeStatus.ACTIVE and loaded_node.node: + for callback in self._on_node_loaded: + try: + callback(node_id, loaded_node.node) + except Exception as e: + logger.error(f"Error in on_node_loaded callback: {e}") + elif loaded_node.status == NodeStatus.ERROR: + for callback in self._on_node_error: + try: + callback(node_id, loaded_node.error_message or "Unknown error") + except Exception as e: + logger.error(f"Error in on_node_error callback: {e}") + + await self._rebuild_pipeline() + + async def _handle_file_change(self, change_type: Change, file_path: Path) -> None: + """Handle a file change event.""" + if not file_path.suffix == ".py" or file_path.name.startswith("_"): + return + + node_id = file_path.stem + + if change_type == Change.deleted: + await self._unload_node_async(node_id) + await self._rebuild_pipeline() + return + + # Added or modified + if node_id in self._state.nodes: + # Check if file actually changed + current_hash = self._state.nodes[node_id].file_hash + try: + new_hash = self._compute_file_hash(file_path) + if new_hash == current_hash: + return # No actual change + except FileNotFoundError: + return + + # Unload existing node before reloading (for proper cleanup) + await self._unload_node_async(node_id) + + # Load/reload the node + loaded_node = self._load_node_from_file(file_path) + self._state.nodes[node_id] = loaded_node + + # Trigger callbacks + if loaded_node.status == NodeStatus.ACTIVE and loaded_node.node: + for callback in self._on_node_loaded: + try: + callback(node_id, loaded_node.node) + except Exception as e: + logger.error(f"Error in on_node_loaded callback: {e}") + elif loaded_node.status == NodeStatus.ERROR: + for callback in self._on_node_error: + try: + callback(node_id, loaded_node.error_message or "Unknown error") + except Exception as e: + logger.error(f"Error in on_node_error callback: {e}") + + await self._rebuild_pipeline() + + async def _watch_files(self) -> None: + """Watch the nodes directory for changes.""" + logger.info(f"Starting file watcher for {self.nodes_dir}") + + try: + async for changes in awatch( + self.nodes_dir, stop_event=self._shutdown_event + ): + for change_type, path_str in changes: + file_path = Path(path_str) + logger.info(f"File change detected: {change_type.name} {file_path}") + await self._handle_file_change(change_type, file_path) + except asyncio.CancelledError: + logger.info("File watcher cancelled") + except Exception as e: + logger.error(f"File watcher error: {e}") + + # ------------------------------------------------------------------------- + # Public API + # ------------------------------------------------------------------------- + + async def start(self) -> None: + """ + Start the pipeline controller. + + Performs initial scan of the nodes directory and starts the file watcher. + """ + if self._state.is_running: + logger.warning("Controller is already running") + return + + self._state.is_running = True + self._shutdown_event.clear() + + # Initial scan + await self._scan_nodes_directory() + + # Start file watcher + self._watch_task = asyncio.create_task(self._watch_files()) + logger.info("Pipeline controller started") + + async def stop(self) -> None: + """Stop the pipeline controller, file watcher, and all subscriptions.""" + if not self._state.is_running: + return + + logger.info("Stopping pipeline controller...") + self._shutdown_event.set() + + # Stop all MQTT subscriptions + for node_id, loaded_node in list(self._state.nodes.items()): + if ( + loaded_node.node is not None + and isinstance(loaded_node.node, MqttNode) + and loaded_node.node.mode == MqttNode.Mode.SUBSCRIBER + ): + try: + await loaded_node.node.stop_subscription() + except Exception as e: + logger.error( + f"Error stopping MQTT subscription for '{node_id}': {e}" + ) + + # Stop all cron jobs + for node_id, loaded_node in list(self._state.nodes.items()): + if ( + loaded_node.node is not None + and isinstance(loaded_node.node, DelayNode) + and loaded_node.node.cron_expr + ): + try: + await loaded_node.node.stop_cron() + except Exception as e: + logger.error(f"Error stopping cron for '{node_id}': {e}") + + if self._watch_task: + self._watch_task.cancel() + try: + await self._watch_task + except asyncio.CancelledError: + pass + self._watch_task = None + + self._state.is_running = False + logger.info("Pipeline controller stopped") + + async def reload_node(self, node_id: str) -> bool: + """ + Manually reload a specific node. + + :param node_id: The ID (filename stem) of the node to reload. + :type node_id: str + :returns: True if reload was successful. + :rtype: bool + """ + file_path = self.nodes_dir / f"{node_id}.py" + if not file_path.exists(): + logger.error(f"Node file not found: {file_path}") + return False + + await self._handle_file_change(Change.modified, file_path) + return ( + self._state.nodes.get( + node_id, + LoadedNode( + node=None, + file_path=file_path, + file_hash="", + status=NodeStatus.ERROR, + ), + ).status + == NodeStatus.ACTIVE + ) + + async def reload_all(self) -> None: + """Reload all nodes from the nodes directory.""" + self._state.nodes.clear() + await self._scan_nodes_directory() + + def add_node_from_code(self, node_id: str, code: str) -> bool: + """ + Add or update a node from code string. + + This is the entry point for frontend-provided node code. + + :param node_id: Unique identifier for the node. + :type node_id: str + :param code: Python code defining the node. + :type code: str + :returns: True if node was successfully added. + :rtype: bool + """ + file_path = self.nodes_dir / f"{node_id}.py" + + try: + # Write the code to file + file_path.write_text(code) + logger.info(f"Wrote node code to {file_path}") + return True + except Exception as e: + logger.error(f"Failed to write node file: {e}") + return False + + def remove_node(self, node_id: str) -> bool: + """ + Remove a node by deleting its file. + + :param node_id: The ID of the node to remove. + :type node_id: str + :returns: True if node was removed. + :rtype: bool + """ + file_path = self.nodes_dir / f"{node_id}.py" + + try: + if file_path.exists(): + file_path.unlink() + logger.info(f"Removed node file {file_path}") + return True + except Exception as e: + logger.error(f"Failed to remove node file: {e}") + return False + + @property + def pipeline(self) -> Pipeline | None: + """Get the current pipeline instance.""" + return self._state.pipeline + + @property + def nodes(self) -> dict[str, LoadedNode]: + """Get all loaded nodes with their status.""" + return self._state.nodes.copy() + + @property + def active_nodes(self) -> list[Node]: + """Get list of currently active nodes.""" + return [ + ln.node + for ln in self._state.nodes.values() + if ln.status == NodeStatus.ACTIVE and ln.node is not None + ] + + @property + def error_nodes(self) -> dict[str, str]: + """Get nodes that failed to load with their error messages.""" + return { + node_id: ln.error_message or "Unknown error" + for node_id, ln in self._state.nodes.items() + if ln.status == NodeStatus.ERROR + } + + def get_node_status(self, node_id: str) -> NodeStatus | None: + """Get the status of a specific node.""" + if node_id in self._state.nodes: + return self._state.nodes[node_id].status + return None + + def trigger_node(self, node_id: str, inputs: dict | None = None) -> Any: + """ + Trigger a node by its ID. + + :param node_id: The ID of the node to trigger. + :type node_id: str + :param inputs: Optional input values. + :type inputs: dict | None + :returns: Result of the node execution. + :rtype: Any + """ + if self._state.pipeline is None: + raise RuntimeError("Pipeline not initialized") + + loaded_node = self._state.nodes.get(node_id) + if loaded_node is None or loaded_node.node is None: + raise ValueError(f"Node '{node_id}' not found or not active") + + return loaded_node.node.trigger(inputs) + + def set_fastapi_app(self, app: "FastAPI") -> None: + """ + Set the FastAPI application for HTTP node route registration. + + This method can be called after initialization to attach a FastAPI + app. It will register routes for any existing HTTP trigger nodes. + + :param app: The FastAPI application instance. + :type app: FastAPI + """ + self._fastapi_app = app + + # Register routes for any existing HTTP trigger nodes + for node_id, loaded_node in self._state.nodes.items(): + if ( + loaded_node.node is not None + and loaded_node.status == NodeStatus.ACTIVE + and isinstance(loaded_node.node, HttpNode) + and loaded_node.node.mode == HttpNode.Mode.TRIGGER + ): + loaded_node.node.register_route(app) + logger.info(f"Registered HTTP route for existing node '{node_id}'") + + +async def main(): + """ + Main function to demonstrate the PipelineController usage. + + Sets up Redis or falls back to MemoryState, creates a controller, + and watches for node changes in the nodes directory. + """ + try: + state = RedisState( + host="localhost", + port=6379, + namespace="fluksio_controller_test", + ttl=3600, + ) + if state.ping(): + logger.info("Using Redis state backend") + else: + raise ConnectionError("Redis ping failed") + except Exception as e: + logger.warning("Redis not available (%s), using MemoryState", e) + state = MemoryState() + + # Create controller pointing to example nodes + nodes_dir = Path(__file__).parent / "nodes" + + controller = PipelineController( + nodes_dir=nodes_dir, + state_backend=state, + max_workers=4, + ) + + # Register callbacks to see what's happening + controller.on_node_loaded( + lambda nid, node: logger.info( + "Loaded node '%s': %d inputs, %d outputs", + nid, + len(node.requires), + len(node.provides), + ) + ) + controller.on_node_error( + lambda nid, err: logger.error("Error in node '%s': %s", nid, err.split("\n")[0]) + ) + controller.on_node_removed(lambda nid: logger.info("Removed node '%s'", nid)) + controller.on_pipeline_rebuilt( + lambda p: logger.info( + "Pipeline rebuilt: %d nodes, %d dependencies", + len(p.nodes), + len(p.dependencies), + ) + ) + + await controller.start() + + # Show current state + logger.info("--- Current State ---") + logger.info("Active nodes: %s", [n.name for n in controller.active_nodes]) + logger.info("Error nodes: %s", list(controller.error_nodes.keys())) + + while True: + await asyncio.sleep(1) + controller.pipeline.visualize() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + logger.info("Main process interrupted by user") diff --git a/backend/app/flow/dag_generator.py b/backend/app/flow/dag_generator.py new file mode 100644 index 0000000..a39a4e2 --- /dev/null +++ b/backend/app/flow/dag_generator.py @@ -0,0 +1,218 @@ +""" +DAG generation utilities for testing pipelines. + +This module provides functions to generate random directed acyclic graphs (DAGs) +with guaranteed properties like connectivity and no cycles. +""" + +from __future__ import annotations + +import networkx as nx +import numpy as np +from typing import Callable + +from util import Message, NodeParams +from nodes import Node, MLPNode + + +def generate_dag( + num_nodes: int, + edge_probability: float = 0.3, + seed: int | None = None, +) -> nx.DiGraph: + """ + Generate a random DAG using the Erdős-Rényi model with topological ordering. + + Creates edges only from lower-indexed to higher-indexed nodes to guarantee + acyclicity, then removes isolated nodes to ensure connectivity. + + :param num_nodes: Number of nodes in the graph. + :type num_nodes: int + :param edge_probability: Probability of edge between any two nodes. + :type edge_probability: float + :param seed: Random seed for reproducibility. + :type seed: int | None + :returns: A random DAG. + :rtype: nx.DiGraph + """ + rng = np.random.default_rng(seed) + G = nx.DiGraph() + G.add_nodes_from(range(num_nodes)) + + # Add edges only from lower to higher index (guarantees DAG) + for i in range(num_nodes): + for j in range(i + 1, num_nodes): + if rng.random() < edge_probability: + G.add_edge(i, j) + + # Ensure connectivity: connect isolated nodes + _ensure_connected(G, rng) + + return G + + +def _ensure_connected(G: nx.DiGraph, rng: np.random.Generator) -> None: + """ + Ensure the DAG is weakly connected by adding edges. + + :param G: The graph to modify in place. + :type G: nx.DiGraph + :param rng: Random number generator. + :type rng: np.random.Generator + """ + # Get weakly connected components + components = list(nx.weakly_connected_components(G)) + + if len(components) <= 1: + return + + # Sort nodes in each component by index for DAG-safe edge addition + sorted_components = [sorted(c) for c in components] + + # Connect components by adding edge from max of one to min of next + for i in range(len(sorted_components) - 1): + src = sorted_components[i][-1] # Last (highest) node in component + dst = sorted_components[i + 1][0] # First (lowest) node in next component + + # Ensure edge direction maintains DAG property + if src < dst: + G.add_edge(src, dst) + else: + G.add_edge(dst, src) + + +def dag_to_pipeline_nodes( + G: nx.DiGraph, + node_factory: Callable[..., Node] | None = None, + params: dict | None = None, + seed: int | None = None, +) -> tuple[list[Node], list[Message], list[Node]]: + """ + Convert a networkx DAG to pipeline nodes with messages. + + :param G: The DAG to convert. + :type G: nx.DiGraph + :param node_factory: Factory function to create nodes (default: MLPNode). + :type node_factory: Callable[..., Node] | None + :param params: Parameters to pass to node factory. + :type params: dict | None + :param seed: Random seed for reproducibility. + :type seed: int | None + :returns: Tuple of (nodes, all_messages, trigger_nodes). + :rtype: tuple[list[Node], list[Message], list[Node]] + """ + rng = np.random.default_rng(seed) + params = params or {} + + # Create a message for each edge + edge_messages: dict[tuple[int, int], Message] = {} + for src, dst in G.edges(): + edge_messages[(src, dst)] = Message(name=f"msg_{src}_{dst}") + + # Create nodes in topological order + nodes: list[Node] = [] + trigger_nodes: list[Node] = [] + + for node_id in nx.topological_sort(G): + # Inputs: messages from incoming edges + requires = [edge_messages[(src, node_id)] for src in G.predecessors(node_id)] + + # Outputs: messages for outgoing edges + provides = [edge_messages[(node_id, dst)] for dst in G.successors(node_id)] + + # Skip nodes with no outputs (sink nodes produce no messages) + # But we still need to create them to consume inputs + if node_factory: + node = node_factory( + requires=requires, + provides=provides, + params=params, + name=f"{node_id}", + ) + else: + node = MLPNode( + requires=requires, + provides=provides, + params=params, + name=f"{node_id}", + ) + + nodes.append(node) + + # Trigger nodes are sources (no inputs) + if len(requires) == 0: + trigger_nodes.append(node) + + all_messages = list(edge_messages.values()) + return nodes, all_messages, trigger_nodes + + +def generate_multi_pipeline_dag( + num_pipelines: int, + nodes_per_pipeline: int, + cross_pipeline_edges: int = 2, + edge_probability: float = 0.3, + seed: int | None = None, +) -> tuple[list[nx.DiGraph], nx.DiGraph]: + """ + Generate multiple DAGs that can be composed into a parent pipeline. + + Creates separate DAGs for each pipeline and adds cross-pipeline edges + to create dependencies between them. + + :param num_pipelines: Number of child pipelines. + :type num_pipelines: int + :param nodes_per_pipeline: Nodes in each pipeline. + :type nodes_per_pipeline: int + :param cross_pipeline_edges: Number of edges connecting pipelines. + :type cross_pipeline_edges: int + :param edge_probability: Edge probability within each pipeline. + :type edge_probability: float + :param seed: Random seed for reproducibility. + :type seed: int | None + :returns: Tuple of (list of pipeline DAGs, combined DAG). + :rtype: tuple[list[nx.DiGraph], nx.DiGraph] + """ + rng = np.random.default_rng(seed) + + pipeline_graphs: list[nx.DiGraph] = [] + combined = nx.DiGraph() + + # Generate each pipeline's DAG + for p in range(num_pipelines): + offset = p * nodes_per_pipeline + G = generate_dag( + nodes_per_pipeline, edge_probability, seed=rng.integers(0, 10000) + ) + + # Relabel nodes with offset + mapping = {n: n + offset for n in G.nodes()} + G = nx.relabel_nodes(G, mapping) + + # Add pipeline attribute + for n in G.nodes(): + G.nodes[n]["pipeline"] = p + + pipeline_graphs.append(G) + combined = nx.compose(combined, G) + + # Add cross-pipeline edges (from later nodes to earlier nodes of next pipeline) + for _ in range(cross_pipeline_edges): + p1, p2 = rng.choice(num_pipelines, size=2, replace=False) + if p1 > p2: + p1, p2 = p2, p1 + + # Get sink nodes from p1 (nodes with no outgoing edges within pipeline) + p1_nodes = [n for n in pipeline_graphs[p1].nodes()] + p1_sinks = [n for n in p1_nodes if pipeline_graphs[p1].out_degree(n) == 0] + + # Get source-ish nodes from p2 (nodes with few incoming edges) + p2_nodes = [n for n in pipeline_graphs[p2].nodes()] + p2_sources = [n for n in p2_nodes if combined.in_degree(n) <= 1] + + if p1_sinks and p2_sources: + src = rng.choice(p1_sinks) + dst = rng.choice(p2_sources) + combined.add_edge(src, dst) + + return pipeline_graphs, combined diff --git a/backend/app/flow/nodes.py b/backend/app/flow/nodes.py new file mode 100644 index 0000000..e585641 --- /dev/null +++ b/backend/app/flow/nodes.py @@ -0,0 +1,1969 @@ +from __future__ import annotations + +import asyncio +import logging +import numpy as np +from util import Message, NodeParams +from typing import TYPE_CHECKING, Callable, Any, Literal +import threading +import time +import httpx +from enum import Enum + +if TYPE_CHECKING: + from pipeline import Pipeline + from fastapi import FastAPI + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +class Node: + """ + A pipeline node that wraps a function with typed inputs/outputs. + + Nodes are the fundamental building blocks of a pipeline. Each node encapsulates + a function that processes data, with explicit message-based inputs (requires) + and outputs (provides). Nodes can be connected into a directed acyclic graph (DAG) + where data flows from upstream to downstream nodes. + + :param f: The function to execute when the node runs. + :type f: Callable + :param requires: Input messages this node consumes. Can be a single Message + or list of Messages. Empty for source nodes. + :type requires: Message | list[Message] + :param provides: Output messages this node produces. Can be a single Message + or list of Messages. + :type provides: Message | list[Message] + :param params: Additional parameters passed to the function during execution. + :type params: NodeParams + :param name: Optional name for the node. Defaults to function name. + :type name: str | None + + :ivar synchronous: If True, node only executes when all required inputs have + new versions since last execution. Useful for synchronizing multiple streams. + :vartype synchronous: bool + + :example: + >>> def process_temp(temperature, params): + ... return {"celsius": temperature * 0.5 + 32} + >>> + >>> temp_node = Node( + ... f=process_temp, + ... requires=Message(name="temperature", dtype=float), + ... provides=Message(name="celsius", dtype=float), + ... params={}, + ... ) + """ + + __slots__ = ( + "f", + "id", + "name", + "requires", + "provides", + "params", + "_pipeline", + "synchronous", + ) + + def __init__( + self, + f: Callable, + requires: Message | list[Message], + provides: Message | list[Message], + params: NodeParams, + name: str = None, + ): + self.f = f + self.id = id(f) + self._pipeline: Pipeline | None = None + self.name = name or f.__name__ + self.synchronous = params.get("synchronous", False) + + # Normalize to dict[name, Message] + self.requires = self._normalize_messages(requires) + self.provides = self._normalize_messages(provides) + self.params = params + + @staticmethod + def _normalize_messages(msgs: Message | list[Message]) -> dict[str, Message]: + """ + Convert Message or list of Messages to dict keyed by name. + + Provides a consistent internal representation for both single messages + and lists of messages. + + :param msgs: Single Message or list of Messages to normalize. + :type msgs: Message | list[Message] + :returns: Dictionary mapping message names to Message objects. + :rtype: dict[str, Message] + """ + if isinstance(msgs, Message): + return {msgs.name: msgs} + return {m.name: m for m in msgs} + + def bind(self, pipeline: Pipeline) -> None: + """ + Bind this node to a pipeline for external triggering. + + Once bound, the node can trigger downstream execution when called. + This is typically done automatically during pipeline construction. + + :param pipeline: The pipeline to bind this node to. + :type pipeline: Pipeline + """ + self._pipeline = pipeline + + def __repr__(self) -> str: + return self.name + + def __hash__(self) -> int: + return self.id + + def trigger(self, inputs: dict | None = None) -> dict | None: + """ + Trigger this node externally, executing downstream nodes if dependencies are met. + + This method is for nodes that receive data from upstream dependencies. + For trigger/subscriber nodes that inject data into the pipeline, use :meth:`inject`. + + :param inputs: Input values matching this node's ``requires``. + :type inputs: dict | None + :returns: Result of the node execution and downstream propagation. + :rtype: dict | None + :raises RuntimeError: If node is not bound to a pipeline. + """ + if self._pipeline is None: + raise RuntimeError("Node must be bound to a pipeline to trigger") + return self(inputs) + + def inject(self, outputs: dict | None = None) -> dict | None: + """ + Inject data into the pipeline as if this node produced it. + + This method is for trigger/subscriber nodes that receive external data + (e.g., HTTP requests, MQTT messages) and need to inject it into the pipeline. + The data is validated against this node's ``provides`` specification. + + For source nodes (nodes with no ``requires``), if no outputs are provided, + the node's function will be executed to generate outputs. + + :param outputs: Output values matching this node's ``provides``. + :type outputs: dict | None + :returns: Result of downstream propagation. + :rtype: dict | None + :raises RuntimeError: If node is not bound to a pipeline. + :raises TypeError: If output values don't match ``provides`` types. + :raises KeyError: If required output keys are missing. + """ + if self._pipeline is None: + raise RuntimeError("Node must be bound to a pipeline to inject") + + outputs = outputs or {} + + # For source nodes with no provided outputs, execute the function + # A source node has no requires (empty dict after normalization) + is_source_node = len(self.requires) == 0 + has_outputs = len(outputs) > 0 + + if not has_outputs and is_source_node: + outputs = self.f(params=self.params) or {} + + # Validate outputs against provides (strict mode) + validated_outputs = {} + for msg_name, msg in self.provides.items(): + if msg_name in outputs: + msg.check(outputs[msg_name]) # Raises TypeError if invalid + validated_outputs[msg_name] = outputs[msg_name] + + # Trigger downstream nodes with the validated outputs + return self._pipeline.trigger(self, validated_outputs) + + def __call__(self, inputs: dict | None = None) -> dict | None: + """ + Execute the node and trigger downstream nodes if bound to a pipeline. + + Validates inputs against the node's ``requires`` specification, executes + the wrapped function, validates outputs, and triggers downstream execution + if the node is bound to a pipeline. + + :param inputs: Input values keyed by message name. Must match the node's + ``requires`` specification. + :type inputs: dict | None + :returns: Node outputs if successful, or pipeline execution results if bound. + :rtype: dict | None + """ + inputs = inputs or {} + + # Validate inputs and build kwargs + input_args = { + k: (self.requires[k].check(v), v)[1] + for k, v in inputs.items() + if k in self.requires + } + + # Execute function + retval = self.f(**input_args, params=self.params) + + # Validate outputs + if retval and self.provides: + for k, v in retval.items(): + if k in self.provides: + self.provides[k].check(v) + + # Trigger downstream if bound to pipeline + return self._pipeline.trigger(self, retval) if self._pipeline else retval + + +class MLPNode(Node): + """ + Multi-Layer Perceptron node for neural network processing in pipelines. + + This node implements a simple single-layer neural network that applies + weights and biases to input values. Weights and biases are randomly + initialized using the provided random number generator. + + The computation follows the standard neural network formula: + output = weights @ inputs + biases + + :param requires: Input messages consumed by this node. + :type requires: Message | list[Message] + :param provides: Output messages produced by this node. + :type provides: Message | list[Message] + :param params: Parameters dict containing: + - ``rng`` (numpy.random.Generator): Random number generator for weight initialization + - Additional node parameters + :type params: NodeParams + :param name: Name for this node. + :type name: str + + :example: + >>> import numpy as np + >>> rng = np.random.default_rng(seed=42) + >>> mlp = MLPNode( + ... requires=[Message(name="input1", dtype=float), Message(name="input2", dtype=float)], + ... provides=[Message(name="output", dtype=float)], + ... params={"rng": rng}, + ... name="mlp_layer1", + ... ) + """ + + def __init__( + self, + requires: Message | list[Message], + provides: Message | list[Message], + params: NodeParams, + name: str, + ): + # Create a copy of params to avoid mutating the original + node_params = dict(params) + + # Initialize weights only if we have inputs and outputs + num_inputs = max(1, len(requires)) # At least 1 for bias-only nodes + num_outputs = max(1, len(provides)) + + node_params["weights"] = params["rng"].normal( + loc=1, size=(num_outputs, num_inputs) + ) + node_params["biases"] = params["rng"].normal(loc=0, size=(num_outputs,)) + node_params["requires"] = requires + node_params["provides"] = provides + + super().__init__( + MLPNode.f, + requires=requires, + provides=provides, + params=node_params, + name=name, + ) + + @staticmethod + def f(params, **kwargs): + """ + Execute the MLP node with one hidden layer. + + Applies weights and biases to input values and returns computed outputs. + + :param params: Node parameters including weights, biases, requires, and provides. + :type params: dict + :param kwargs: Input values from required messages. + :type kwargs: Any + :returns: Dictionary of output values for provided messages. + :rtype: dict | None + """ + req_keys = params["requires"] + prov_keys = params["provides"] + + logger.info( + "Executing MLP node in thread %s: %s -> %s", + threading.current_thread().name, + req_keys, + prov_keys, + ) + + # Handle case with no outputs (sink node) + if not prov_keys: + return None + + # Convert args to numpy array for matrix multiplication + if kwargs: + input_array = np.array([float(v) for k, v in kwargs.items()]) + else: + input_array = np.array([1.0]) # Bias-only for source nodes + + # Compute outputs + outputs = np.dot(params["weights"], input_array) + params["biases"] + + return {p.name: float(outputs[i]) for i, p in enumerate(prov_keys)} + + +class HttpNode(Node): + """ + HTTP node that can act as a trigger (receiver) or sender based on configuration. + + This node integrates with FastAPI to either: + + - **Trigger mode**: Receive incoming HTTP requests (GET/POST) and inject data + into the pipeline. Used when ``provides`` is specified but ``requires`` is empty. + - **Sender mode**: Make outgoing HTTP requests with pipeline data. Used when + ``requires`` is specified. + + :param url: The URL endpoint. For trigger mode, this is the route path + (e.g., "/sensors/temperature"). For sender mode, this is the full URL + to send requests to. + :type url: str + :param method: HTTP method - "GET" or "POST". + :type method: Literal["GET", "POST"] + :param requires: Messages required by this node (makes it a sender node). + :type requires: Message | list[Message] | None + :param provides: Messages provided by this node (makes it a trigger node). + :type provides: Message | list[Message] | None + :param params: Additional parameters for the node. + :type params: dict + :param name: Optional name for the node. + :type name: str | None + :param timeout: Request timeout in seconds (for sender mode). + :type timeout: float + :param headers: Additional HTTP headers. + :type headers: dict[str, str] | None + + :raises ValueError: If both ``requires`` and ``provides`` are empty, or if + the configuration is invalid. + + :example: + Trigger node (receives POST requests): + + >>> trigger = HttpNode( + ... url="/api/sensors/temperature", + ... method="POST", + ... provides=[Message(name="temperature", dtype=float)], + ... params={}, + ... ) + + Sender node (makes POST requests): + + >>> sender = HttpNode( + ... url="https://api.example.com/data", + ... method="POST", + ... requires=[Message(name="temperature", dtype=float)], + ... params={}, + ... ) + """ + + class Mode(Enum): + """Operating mode of the HTTP node.""" + + TRIGGER = "trigger" # Receives HTTP requests + SENDER = "sender" # Sends HTTP requests + + __slots__ = ( + "url", + "method", + "mode", + "timeout", + "headers", + "_route_registered", + "_http_client", + ) + + def __init__( + self, + url: str, + method: Literal["GET", "POST"] = "POST", + requires: Message | list[Message] = [], + provides: Message | list[Message] = [], + params: NodeParams = {}, + name: str | None = None, + ): + # Make a copy to avoid mutating the original and sharing state between nodes + params = dict(params) if params else {} + + # Determine mode based on requires/provides + has_requires = isinstance(requires, Message) or len(requires) > 0 + has_provides = isinstance(provides, Message) or len(provides) > 0 + + if not has_requires and not has_provides: + raise ValueError( + "HttpNode must have either 'requires' (sender) or 'provides' (trigger)" + ) + + # Determine mode: trigger if only provides, sender if has requires + if has_requires: + self.mode = HttpNode.Mode.SENDER + else: + self.mode = HttpNode.Mode.TRIGGER + + self.url = url + self.method = method.upper() + self.timeout = params.get("timeout", 30.0) + self.headers = params.get("headers", {}) + self._route_registered = False + self._http_client: httpx.AsyncClient | None = None + + # Set default name based on mode and URL + if name is None: + safe_url = url.replace("/", "_").replace(":", "").strip("_") + name = f"http_{self.mode.value}_{safe_url}" + + # Initialize parent with appropriate function + # For trigger mode, f is a no-op since data is injected via inject() + # For sender mode, f handles the outgoing HTTP request + super().__init__( + f=( + self._noop_trigger + if self.mode == HttpNode.Mode.TRIGGER + else self._sender_handler + ), + requires=requires, + provides=provides, + params=params, + name=name, + ) + + @staticmethod + def _noop_trigger(params: dict, **kwargs) -> dict | None: + """ + No-op function for trigger mode nodes. + + Trigger mode nodes inject data via :meth:`inject`, not :meth:`__call__`. + This function exists only to satisfy the Node interface. + """ + return None + + def _sender_handler(self, params: dict, **kwargs) -> dict | None: + """ + Send HTTP request with pipeline data (sender mode). + + This method is called when upstream dependencies are satisfied. + It sends the required data via HTTP request. + + :param params: Node parameters. + :type params: dict + :param kwargs: Pipeline data to send (from required messages). + :type kwargs: Any + :returns: Response data if the endpoint returns JSON, None otherwise. + :rtype: dict | None + """ + # Run async request in sync context + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + # We're in an async context, create a task + future = asyncio.ensure_future(self._send_request(kwargs)) + # This is tricky - we need to handle this properly + # For now, use a thread to run the async code + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor() as executor: + result = executor.submit( + asyncio.run, self._send_request(kwargs) + ).result() + return result + else: + return loop.run_until_complete(self._send_request(kwargs)) + except RuntimeError: + # No event loop, create one + return asyncio.run(self._send_request(kwargs)) + + async def _send_request(self, data: dict) -> dict | None: + """ + Send an async HTTP request. + + :param data: Data to send in the request. + :type data: dict + :returns: Response JSON if available, None otherwise. + :rtype: dict | None + """ + async with httpx.AsyncClient(timeout=self.timeout) as client: + try: + if self.method == "GET": + response = await client.get( + self.url, params=data, headers=self.headers + ) + else: # POST + response = await client.post( + self.url, json=data, headers=self.headers + ) + + response.raise_for_status() + + # Try to parse JSON response + try: + return response.json() + except Exception: + return None + + except httpx.HTTPStatusError as e: + logger.error( + "HTTP error in node '%s': status=%s, url=%s", + self.name, + e.response.status_code, + self.url, + ) + raise + except httpx.RequestError as e: + logger.error("Request error in node '%s': %s", self.name, e) + raise + + def register_route(self, app: "FastAPI") -> None: + """ + Register this node's HTTP endpoint with a FastAPI application. + + This method should only be called for trigger mode nodes. It creates + a route that, when called, triggers the node in the pipeline. + + :param app: The FastAPI application instance. + :type app: FastAPI + :raises RuntimeError: If called on a sender mode node. + + :example: + >>> from fastapi import FastAPI + >>> app = FastAPI() + >>> trigger_node = HttpNode( + ... url="/sensors/data", + ... method="POST", + ... provides=[Message(name="value", dtype=float)], + ... params={}, + ... ) + >>> trigger_node.register_route(app) + """ + if self.mode != HttpNode.Mode.TRIGGER: + raise RuntimeError("Can only register routes for trigger mode nodes") + + if self._route_registered: + return + + from starlette.requests import Request + from starlette.responses import JSONResponse + from starlette.routing import Route + + async def handle_request(request: Request) -> JSONResponse: + """ + Handle incoming HTTP request and trigger the pipeline. + + :param request: The incoming Starlette request. + :type request: Request + :returns: JSON response with trigger result. + :rtype: JSONResponse + """ + try: + # Parse request data + if self.method == "GET": + data = dict(request.query_params) + else: # POST + content_type = request.headers.get("content-type", "") + if "application/json" in content_type: + data = await request.json() + elif "application/x-www-form-urlencoded" in content_type: + form = await request.form() + data = dict(form) + else: + data = await request.json() # Default to JSON + + # Convert string values to appropriate types based on message definitions + typed_data = {} + for msg_name, msg in self.provides.items(): + if msg_name in data: + value = data[msg_name] + # Type conversion + if msg.dtype == float: + typed_data[msg_name] = float(value) + elif msg.dtype == int: + typed_data[msg_name] = int(value) + elif msg.dtype == bool: + typed_data[msg_name] = ( + value + if isinstance(value, bool) + else str(value).lower() in ("true", "1", "yes") + ) + else: + typed_data[msg_name] = value + + # Inject data into the pipeline (trigger mode nodes inject via provides) + result = self.inject(typed_data) + + return JSONResponse( + content={ + "status": "triggered", + "node": self.name, + "data": typed_data, + "result": result if isinstance(result, dict) else None, + } + ) + + except ValueError as e: + return JSONResponse( + content={"error": str(e)}, + status_code=400, + ) + except Exception as e: + return JSONResponse( + content={"error": str(e)}, + status_code=500, + ) + + # Create a Starlette Route and add it directly to the app's routes + route = Route( + self.url, + handle_request, + methods=[self.method], + name=self.name, + ) + app.routes.append(route) + + self._route_registered = True + logger.info( + "Registered %s route for node '%s': %s", + self.method, + self.name, + self.url, + ) + + def unregister_route(self, app: "FastAPI") -> None: + """ + Unregister this node's HTTP endpoint from a FastAPI application. + + :param app: The FastAPI application instance. + :type app: FastAPI + + .. note:: + FastAPI doesn't natively support route removal. This method + removes the route from the internal routes list, but the change + may not take effect until the application is restarted or + the OpenAPI schema is regenerated. + """ + if not self._route_registered: + return + + # FastAPI doesn't have a clean way to remove routes + # We need to filter them out from the routes list + app.routes[:] = [ + route + for route in app.routes + if not (hasattr(route, "name") and route.name == self.name) + ] + + self._route_registered = False + logger.info("Unregistered route for node '%s': %s", self.name, self.url) + + +class MqttNode(Node): + """ + MQTT node that can act as a subscriber (trigger) or publisher (sender). + + This node integrates with an MQTT broker to either: + + - **Trigger mode (Subscriber)**: Subscribe to MQTT topics and inject received + messages into the pipeline. Used when ``provides`` is specified but + ``requires`` is empty. + - **Sender mode (Publisher)**: Publish pipeline data to MQTT topics. Used when + ``requires`` is specified. + + The ``topic`` parameter in ``params`` controls the mapping between pipeline + message names and MQTT topics: + + - **dict**: Explicit mapping from message name to MQTT topic, e.g. + ``{"temperature": "sensors/room1/temp", "humidity": "sensors/room1/hum"}``. + - **str** (legacy): A single topic string. All messages are mapped to this + one topic (subscriber receives from it, publisher sends to it). + + :param requires: Messages required by this node (makes it a publisher node). + :type requires: Message | list[Message] | None + :param provides: Messages provided by this node (makes it a subscriber node). + :type provides: Message | list[Message] | None + :param params: Parameters dict containing: + - ``topic`` (str | dict): MQTT topic(s). A dict maps message names to + individual topics. A plain string uses that topic for all messages. + - ``broker_host`` (str): MQTT broker hostname (default: "localhost") + - ``broker_port`` (int): MQTT broker port (default: 1883) + - ``username`` (str | None): Optional username for authentication + - ``password`` (str | None): Optional password for authentication + - ``client_id`` (str | None): Optional client ID + - ``qos`` (int): Quality of Service level 0, 1, or 2 (default: 0) + - ``retain`` (bool): Retain flag for published messages (default: False) + - ``keepalive`` (int): Keepalive interval in seconds (default: 60) + :type params: dict + :param name: Optional name for the node. + :type name: str | None + + :raises ValueError: If both ``requires`` and ``provides`` are empty. + + :example: + Subscriber with per-message topics: + + >>> subscriber = MqttNode( + ... provides=[ + ... Message(name="inverter_input", dtype=float), + ... Message(name="inverter_output", dtype=float), + ... ], + ... params={ + ... "topic": { + ... "inverter_input": "sensors/pv", + ... "inverter_output": "sensors/output", + ... }, + ... "broker_host": "localhost", + ... }, + ... ) + + Publisher with per-message topics: + + >>> publisher = MqttNode( + ... requires=[ + ... Message(name="target_temp", dtype=float), + ... Message(name="fan_speed", dtype=int), + ... ], + ... params={ + ... "topic": { + ... "target_temp": "actuators/hvac/temp", + ... "fan_speed": "actuators/hvac/fan", + ... }, + ... "broker_host": "localhost", + ... "qos": 1, + ... }, + ... ) + + Legacy single-topic subscriber: + + >>> subscriber = MqttNode( + ... provides=[Message(name="temperature", dtype=float)], + ... params={"topic": "sensors/temperature", "broker_host": "localhost"}, + ... ) + """ + + class Mode(Enum): + """Operating mode of the MQTT node.""" + + SUBSCRIBER = "subscriber" # Receives MQTT messages (trigger) + PUBLISHER = "publisher" # Sends MQTT messages (sender) + + __slots__ = ( + "topics", + "mode", + "broker_host", + "broker_port", + "username", + "password", + "client_id", + "qos", + "retain", + "keepalive", + "_topic_to_messages", + "_subscription_task", + "_mqtt_client", + "_stop_event", + ) + + def __init__( + self, + requires: Message | list[Message] = [], + provides: Message | list[Message] = [], + params: dict | None = None, + name: str | None = None, + ): + # Make a copy to avoid mutating the original and sharing state between nodes + params = dict(params) if params else {} + + # Determine mode based on requires/provides + has_requires = isinstance(requires, Message) or len(requires) > 0 + has_provides = isinstance(provides, Message) or len(provides) > 0 + + if not has_requires and not has_provides: + raise ValueError( + "MqttNode must have either 'requires' (publisher) or 'provides' (subscriber)" + ) + + # Determine mode: subscriber if only provides, publisher if has requires + if has_requires: + self.mode = MqttNode.Mode.PUBLISHER + else: + self.mode = MqttNode.Mode.SUBSCRIBER + + # Build the message→topic mapping + raw_topic = params.get("topic", "*") + msg_names: list[str] + if self.mode == MqttNode.Mode.SUBSCRIBER: + # Normalise provides into a list of names + if isinstance(provides, Message): + msg_names = [provides.name] + elif isinstance(provides, list): + msg_names = [m.name if isinstance(m, Message) else m for m in provides] + else: + msg_names = list(provides) if provides else [] + else: + if isinstance(requires, Message): + msg_names = [requires.name] + elif isinstance(requires, list): + msg_names = [m.name if isinstance(m, Message) else m for m in requires] + else: + msg_names = list(requires) if requires else [] + + if isinstance(raw_topic, dict): + # Explicit mapping: message_name → topic + self.topics: dict[str, str] = dict(raw_topic) + else: + # Legacy single-topic string: every message uses the same topic + self.topics = {msg: raw_topic for msg in msg_names} + + # Build reverse lookup: topic → list of message names + self._topic_to_messages: dict[str, list[str]] = {} + for msg, topic in self.topics.items(): + self._topic_to_messages.setdefault(topic, []).append(msg) + + # MQTT connection parameters from params + self.broker_host = params.get("broker_host", "localhost") + self.broker_port = params.get("broker_port", 1883) + self.username = params.get("username") + self.password = params.get("password") + self.client_id = params.get("client_id") + self.qos = params.get("qos", 0) + self.retain = params.get("retain", False) + self.keepalive = params.get("keepalive", 60) + + # Runtime state + self._subscription_task: asyncio.Task | None = None + self._mqtt_client = None + self._stop_event: asyncio.Event | None = None + + # Set default name based on mode and topics + if name is None: + unique_topics = set(self.topics.values()) + if len(unique_topics) == 1: + safe_topic = ( + next(iter(unique_topics)) + .replace("/", "_") + .replace("+", "x") + .replace("#", "all") + .strip("_") + ) + else: + safe_topic = f"{len(unique_topics)}topics" + name = f"mqtt_{self.mode.value}_{safe_topic}" + + # Initialize parent with appropriate function + # For subscriber mode, f is a no-op since data is injected via inject() + # For publisher mode, f handles the outgoing MQTT publish + super().__init__( + f=( + self._noop_subscriber + if self.mode == MqttNode.Mode.SUBSCRIBER + else self._publisher_handler + ), + requires=requires, + provides=provides, + params=params, + name=name, + ) + + @staticmethod + def _noop_subscriber(params: dict, **kwargs) -> dict | None: + """ + No-op function for subscriber mode nodes. + + Subscriber mode nodes inject data via :meth:`inject`, not :meth:`__call__`. + This function exists only to satisfy the Node interface and should not + be called directly. + + :param params: Node parameters (unused). + :type params: dict + :param kwargs: Additional arguments (unused). + :type kwargs: Any + :returns: Always returns None. + :rtype: None + """ + return None + + def _publisher_handler(self, params: dict, **kwargs) -> dict | None: + """ + Publish pipeline data to MQTT topic (publisher mode). + + This method is called when upstream dependencies are satisfied. + It publishes the required data to the configured MQTT topic. + + :param params: Node parameters. + :type params: dict + :param kwargs: Pipeline data to publish (from required messages). + :type kwargs: Any + :returns: None (publishing is fire-and-forget). + :rtype: dict | None + """ + import json + + # Run async publish in sync context + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + # Use thread to run async code when already in async context + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor() as executor: + executor.submit(asyncio.run, self._publish_message(kwargs)).result() + else: + loop.run_until_complete(self._publish_message(kwargs)) + except RuntimeError: + # No event loop, create one + asyncio.run(self._publish_message(kwargs)) + + return None + + async def _publish_message(self, data: dict) -> None: + """ + Publish messages to their mapped MQTT topics. + + Each message in *data* is published to its corresponding topic + from the ``topics`` mapping. Messages are sent as individual + JSON payloads per topic. + + :param data: Data to publish, keyed by message name. + :type data: dict + """ + import json + import aiomqtt + + try: + async with aiomqtt.Client( + hostname=self.broker_host, + port=self.broker_port, + username=self.username, + password=self.password, + identifier=self.client_id, + keepalive=self.keepalive, + ) as client: + for msg_name, value in data.items(): + topic = self.topics.get(msg_name) + if topic is None: + logger.warning( + "No topic mapping for message '%s' in node '%s', skipping", + msg_name, + self.name, + ) + continue + + payload = json.dumps(value) + await client.publish( + topic, + payload=payload, + qos=self.qos, + retain=self.retain, + ) + logger.info( + "Published to '%s' from node '%s': %s", + topic, + self.name, + payload, + ) + + except Exception as e: + logger.error("MQTT publish error in node '%s': %s", self.name, e) + raise + + async def start_subscription(self) -> None: + """ + Start the MQTT subscription for trigger mode nodes. + + This method starts a background task that listens for messages + on the subscribed topic and triggers the pipeline when messages arrive. + + :raises RuntimeError: If called on a publisher mode node. + + :example: + >>> subscriber = MqttNode( + ... topic="sensors/#", + ... provides=[Message(name="value", dtype=float)], + ... params={"broker_host": "localhost"}, + ... ) + >>> await subscriber.start_subscription() + """ + if self.mode != MqttNode.Mode.SUBSCRIBER: + raise RuntimeError("Can only start subscription for subscriber mode nodes") + + if self._subscription_task is not None: + return # Already running + + self._stop_event = asyncio.Event() + self._subscription_task = asyncio.create_task(self._subscription_loop()) + logger.info( + "Started MQTT subscription for node '%s' to topics %s", + self.name, + list(self._topic_to_messages.keys()), + ) + + async def stop_subscription(self) -> None: + """ + Stop the MQTT subscription. + + Gracefully stops the background subscription task. + """ + if self._subscription_task is None: + return + + if self._stop_event: + self._stop_event.set() + + self._subscription_task.cancel() + try: + await self._subscription_task + except asyncio.CancelledError: + pass + + self._subscription_task = None + self._stop_event = None + logger.info( + "Stopped MQTT subscription for node '%s'", + self.name, + ) + + async def _subscription_loop(self) -> None: + """ + Background loop that listens for MQTT messages and triggers the pipeline. + + Subscribes to all unique topics from the ``topics`` mapping and + uses the reverse lookup ``_topic_to_messages`` to route incoming + payloads to the correct pipeline message names. + """ + import json + import aiomqtt + + while not (self._stop_event and self._stop_event.is_set()): + try: + async with aiomqtt.Client( + hostname=self.broker_host, + port=self.broker_port, + username=self.username, + password=self.password, + identifier=self.client_id, + keepalive=self.keepalive, + ) as client: + # Subscribe to every unique topic + for topic in self._topic_to_messages: + await client.subscribe(topic, qos=self.qos) + logger.info("[%s] Subscribed to %s", self.name, topic) + + async for message in client.messages: + if self._stop_event and self._stop_event.is_set(): + break + + try: + payload = message.payload.decode("utf-8") + incoming_topic = str(message.topic) + + logger.info( + "[%s] Received on %s: %s", + self.name, + incoming_topic, + payload, + ) + + # Find which message name(s) this topic maps to + msg_names = self._topic_to_messages.get(incoming_topic, []) + if not msg_names: + logger.debug( + "[%s] No mapping for topic '%s', ignoring", + self.name, + incoming_topic, + ) + continue + + # Parse the payload value + try: + parsed = json.loads(payload) + except json.JSONDecodeError: + parsed = payload + + # Type-convert and build typed_data for each mapped message + typed_data = {} + for msg_name in msg_names: + msg = self.provides.get(msg_name) + if msg is None: + continue + + # If parsed is a dict and contains the msg_name key, use that + if isinstance(parsed, dict) and msg_name in parsed: + value = parsed[msg_name] + else: + value = parsed + + # Type conversion + if msg.dtype == float: + typed_data[msg_name] = float(value) + elif msg.dtype == int: + typed_data[msg_name] = int(value) + elif msg.dtype == bool: + typed_data[msg_name] = ( + value + if isinstance(value, bool) + else str(value).lower() in ("true", "1", "yes") + ) + else: + typed_data[msg_name] = value + + if typed_data: + self.inject(typed_data) + + except Exception as e: + logger.error( + "[%s] Error processing message: %s", + self.name, + e, + exc_info=True, + ) + + except aiomqtt.MqttError as e: + logger.warning( + "MQTT connection error in node '%s': %s. Retrying in 5s...", + self.name, + e, + ) + if not (self._stop_event and self._stop_event.is_set()): + # Reconnect after a delay + await asyncio.sleep(5) + except asyncio.CancelledError: + break + except Exception as e: + logger.error( + "Unexpected error in MQTT subscription for node '%s': %s. Retrying in 5s...", + self.name, + e, + exc_info=True, + ) + if not (self._stop_event and self._stop_event.is_set()): + await asyncio.sleep(5) + + @property + def is_subscribed(self) -> bool: + """ + Check if the subscription is currently active. + + :returns: True if subscription task is running. + :rtype: bool + """ + return ( + self._subscription_task is not None and not self._subscription_task.done() + ) + + +class InfluxDbNode(Node): + """ + InfluxDB node for writing to and reading from InfluxDB. + + This node can perform both write and read operations independently: + + - **Write operation**: Triggered when upstream dependencies are satisfied + (data flows in via ``requires``). Writes data points to InfluxDB based + on the ``writes`` configuration in params. + - **Read operation**: Performed when the node provides data to downstream + nodes via ``provides``, based on the ``queries`` configuration in params. + + Both operations use a similar configuration pattern in params, making the + API consistent and the input/output data simple (just values). + + :param requires: Messages to write to InfluxDB. The actual value from each + message is written according to the corresponding config in ``writes``. + :type requires: Message | list[Message] | None + :param provides: Messages to read from InfluxDB. Each message gets its value + from a query defined in ``queries``. + :type provides: Message | list[Message] | None + :param params: Parameters dict containing: + - ``url`` (str): InfluxDB server URL (required) + - ``token`` (str): Authentication token (required) + - ``org`` (str): Organization name (required) + - ``bucket`` (str): Bucket name (required) + - ``write_precision`` (str): Write precision ("ns", "us", "ms", "s"), default "ms" + - ``query_range`` (str): Default time range for queries, e.g., "-1h", "-24h" + - ``writes`` (dict): Write configurations keyed by message name, each with: + - ``measurement`` (str): Measurement name to write to + - ``field`` (str): Field name to write (default: "value") + - ``tags`` (dict): Static tags to add to each point + - ``queries`` (dict): Query configurations keyed by message name, each with: + - ``measurement`` (str): Measurement name to query + - ``field`` (str): Field name to retrieve (default: "value") + - ``tags`` (dict): Optional tag filters + - ``range`` (str): Optional time range override + - ``aggregation`` (str): Aggregation function ("mean", "last", "first", "max", "min") + :type params: dict + :param name: Optional name for the node. + :type name: str | None + + :raises ValueError: If required params are missing or both requires and provides are empty. + + :example: + Write-only node (writes temperature values): + + >>> writer = InfluxDbNode( + ... requires=[Message(name="temperature", dtype=float)], + ... params={ + ... "url": "http://localhost:8086", + ... "token": "my-token", + ... "org": "my-org", + ... "bucket": "sensors", + ... "writes": { + ... "temperature": { + ... "measurement": "environment", + ... "field": "temp_celsius", + ... "tags": {"location": "room1", "sensor": "dht22"}, + ... } + ... }, + ... }, + ... ) + + Read-only node (queries average temperature): + + >>> reader = InfluxDbNode( + ... provides=[Message(name="avg_temperature", dtype=float)], + ... params={ + ... "url": "http://localhost:8086", + ... "token": "my-token", + ... "org": "my-org", + ... "bucket": "sensors", + ... "queries": { + ... "avg_temperature": { + ... "measurement": "environment", + ... "field": "temp_celsius", + ... "tags": {"location": "room1"}, + ... "range": "-1h", + ... "aggregation": "mean", + ... } + ... }, + ... }, + ... ) + + Combined read/write node: + + >>> node = InfluxDbNode( + ... requires=[Message(name="raw_temp", dtype=float)], + ... provides=[Message(name="avg_temp", dtype=float)], + ... params={ + ... "url": "http://localhost:8086", + ... "token": "my-token", + ... "org": "my-org", + ... "bucket": "sensors", + ... "writes": { + ... "raw_temp": { + ... "measurement": "temperature", + ... "field": "value", + ... "tags": {"source": "sensor"}, + ... } + ... }, + ... "queries": { + ... "avg_temp": { + ... "measurement": "temperature", + ... "field": "value", + ... "aggregation": "mean", + ... "range": "-5m", + ... } + ... }, + ... }, + ... ) + """ + + __slots__ = ( + "url", + "token", + "org", + "bucket", + "write_precision", + "query_range", + "writes", + "queries", + "_write_client", + "_query_client", + ) + + def __init__( + self, + requires: Message | list[Message] = [], + provides: Message | list[Message] = [], + params: dict | None = None, + name: str | None = None, + ): + # Make a copy to avoid mutating the original and sharing state between nodes + params = dict(params) if params else {} + + # Validate required params + required_params = ["url", "token", "org", "bucket"] + missing = [p for p in required_params if p not in params] + if missing: + raise ValueError(f"InfluxDbNode missing required params: {missing}") + + # Determine if we have any operations + has_requires = isinstance(requires, Message) or len(requires) > 0 + has_provides = isinstance(provides, Message) or len(provides) > 0 + + if not has_requires and not has_provides: + raise ValueError( + "InfluxDbNode must have either 'requires' (write) or 'provides' (read)" + ) + + # Store InfluxDB connection params + self.url = params["url"] + self.token = params["token"] + self.org = params["org"] + self.bucket = params["bucket"] + self.write_precision = params.get("write_precision", "ms") + self.query_range = params.get("query_range", "-1h") + self.writes = params.get("writes", {}) + self.queries = params.get("queries", {}) + + # Lazy-initialized clients + self._write_client = None + self._query_client = None + + # Set default name + if name is None: + name = f"influxdb_{self.bucket}" + + # Initialize parent + # The handler function depends on what operations are configured + super().__init__( + f=self._handler, + requires=requires, + provides=provides, + params=params, + name=name, + ) + + def _handler(self, params: dict, **kwargs) -> dict | None: + """ + Handle incoming data - write to InfluxDB and optionally query. + + This method is called when upstream dependencies (requires) are satisfied. + It writes the incoming data to InfluxDB and can also perform reads. + + :param params: Node parameters. + :type params: dict + :param kwargs: Incoming data from upstream nodes. + :type kwargs: Any + :returns: Query results if provides is configured, None otherwise. + :rtype: dict | None + """ + # Write incoming data + if kwargs: + self._write_points(kwargs) + + # If we have provides, perform queries + if self.provides: + return self._query_data() + + return None + + def _write_points(self, data: dict) -> None: + """ + Write data points to InfluxDB using configuration from ``writes``. + + The write configuration is looked up in ``self.writes`` by message name. + Each config specifies the measurement, field, and tags. The actual value + comes from the incoming data. + + :param data: Data to write, keyed by message name. Values can be: + - Simple values (float, int, str, bool): Written using config from ``writes`` + - List of values: Each value written as a separate point + - Dict with "value" key: Value extracted and written using config + - Dict with "value" and "tags" keys: Value written with merged tags + :type data: dict + """ + from influxdb_client import InfluxDBClient, Point, WritePrecision + from influxdb_client.client.write_api import SYNCHRONOUS + + try: + with InfluxDBClient(url=self.url, token=self.token, org=self.org) as client: + write_api = client.write_api(write_options=SYNCHRONOUS) + + precision_map = { + "ns": WritePrecision.NS, + "us": WritePrecision.US, + "ms": WritePrecision.MS, + "s": WritePrecision.S, + } + precision = precision_map.get(self.write_precision, WritePrecision.MS) + + for msg_name, msg_value in data.items(): + # Get write configuration for this message + write_config = self.writes.get(msg_name, {}) + + # Get measurement, field, and base tags from config + measurement = write_config.get("measurement", msg_name) + field = write_config.get("field", "value") + base_tags = write_config.get("tags", {}) + + # Handle list of values (batch write) + values_to_write = ( + msg_value if isinstance(msg_value, list) else [msg_value] + ) + + for item in values_to_write: + # Extract value and optional runtime tags + if isinstance(item, dict): + value = item.get("value", item) + runtime_tags = item.get("tags", {}) + # If no "value" key, treat the whole dict as invalid + if "value" not in item and not isinstance( + value, (int, float, str, bool) + ): + logger.warning( + "Skipping invalid item in node '%s': %s", + self.name, + item, + ) + continue + else: + value = item + runtime_tags = {} + + if value is None: + logger.info( + "Skipping None value for '%s' in node '%s'", + msg_name, + self.name, + ) + continue + + # Merge base tags with runtime tags (runtime takes precedence) + tags = {**base_tags, **runtime_tags} + + # Build the point + point = Point(measurement) + + for tag_key, tag_value in tags.items(): + point = point.tag(tag_key, str(tag_value)) + + point = point.field(field, value) + + # Write the point + write_api.write( + bucket=self.bucket, + org=self.org, + record=point, + write_precision=precision, + ) + + logger.info( + "Wrote to InfluxDB from node '%s': %s.%s=%s, tags=%s", + self.name, + measurement, + field, + value, + tags, + ) + + except Exception as e: + logger.error("InfluxDB write error in node '%s': %s", self.name, e) + raise + + def _query_data(self) -> dict: + """ + Query data from InfluxDB based on provides configuration. + + :returns: Dict of message name to queried value. + :rtype: dict + """ + from influxdb_client import InfluxDBClient + + results = {} + + try: + with InfluxDBClient(url=self.url, token=self.token, org=self.org) as client: + query_api = client.query_api() + + for msg_name, msg in self.provides.items(): + # Get query configuration for this message + query_config = self.queries.get(msg_name, {}) + + measurement = query_config.get("measurement", msg_name) + field = query_config.get("field", "value") + tags = query_config.get("tags", {}) + time_range = query_config.get("range", self.query_range) + aggregation = query_config.get("aggregation", "last") + + # Build Flux query + flux_query = self._build_flux_query( + measurement=measurement, + field=field, + tags=tags, + time_range=time_range, + aggregation=aggregation, + ) + + logger.info( + "Executing InfluxDB query for '%s' in node '%s': %s", + msg_name, + self.name, + flux_query, + ) + + # Execute query + tables = query_api.query(flux_query, org=self.org) + + # Extract result + value = self._extract_query_result(tables, msg.dtype) + + if value is not None: + results[msg_name] = value + logger.info( + "Query result for '%s' in node '%s': %s", + msg_name, + self.name, + value, + ) + else: + logger.info( + "No data found for '%s' in node '%s'", + msg_name, + self.name, + ) + + except Exception as e: + logger.error("InfluxDB query error in node '%s': %s", self.name, e) + raise + + return results + + def _build_flux_query( + self, + measurement: str, + field: str, + tags: dict, + time_range: str, + aggregation: str, + ) -> str: + """ + Build a Flux query string. + + :param measurement: Measurement name. + :type measurement: str + :param field: Field name. + :type field: str + :param tags: Tag filters. + :type tags: dict + :param time_range: Time range (e.g., "-1h"). + :type time_range: str + :param aggregation: Aggregation function. + :type aggregation: str + :returns: Flux query string. + :rtype: str + """ + # Base query + query_parts = [ + f'from(bucket: "{self.bucket}")', + f" |> range(start: {time_range})", + f' |> filter(fn: (r) => r["_measurement"] == "{measurement}")', + f' |> filter(fn: (r) => r["_field"] == "{field}")', + ] + + # Add tag filters + for tag_key, tag_value in tags.items(): + query_parts.append( + f' |> filter(fn: (r) => r["{tag_key}"] == "{tag_value}")' + ) + + # Add aggregation + aggregation_map = { + "mean": "mean()", + "last": "last()", + "first": "first()", + "max": "max()", + "min": "min()", + "sum": "sum()", + "count": "count()", + } + + if aggregation in aggregation_map: + query_parts.append(f" |> {aggregation_map[aggregation]}") + else: + # Default to last value + query_parts.append(" |> last()") + + return "\n".join(query_parts) + + def _extract_query_result(self, tables, dtype: type) -> Any: + """ + Extract a single value from query result tables. + + :param tables: InfluxDB query result tables. + :param dtype: Expected data type. + :type dtype: type + :returns: Extracted and typed value, or None if no data. + :rtype: Any + """ + for table in tables: + for record in table.records: + value = record.get_value() + + # Type conversion + if value is not None: + try: + if dtype == float: + return float(value) + elif dtype == int: + return int(value) + elif dtype == bool: + return bool(value) + elif dtype == str: + return str(value) + else: + return value + except (ValueError, TypeError): + return value + + return None + + def inject(self, outputs: dict | None = None) -> dict | None: + """ + Inject queried data into the pipeline. + + For InfluxDbNode, inject performs a query operation and injects + the results into the pipeline. This is useful for trigger-style + usage where you want to periodically query InfluxDB. + + :param outputs: Optional pre-set outputs (usually None for queries). + :type outputs: dict | None + :returns: Query results injected into the pipeline. + :rtype: dict | None + """ + if self._pipeline is None: + raise RuntimeError("Node must be bound to a pipeline to inject") + + # If outputs are provided, validate and use them + if outputs: + validated_outputs = {} + for msg_name, msg in self.provides.items(): + if msg_name in outputs: + msg.check(outputs[msg_name]) + validated_outputs[msg_name] = outputs[msg_name] + return self._pipeline.trigger(self, validated_outputs) + + # Otherwise, perform queries to get outputs + if self.provides: + query_results = self._query_data() + + # Validate results + validated_outputs = {} + for msg_name, msg in self.provides.items(): + if msg_name in query_results: + msg.check(query_results[msg_name]) + validated_outputs[msg_name] = query_results[msg_name] + + return self._pipeline.trigger(self, validated_outputs) + + return None + + +class DelayNode(Node): + """ + A node that adds delay, rate-limiting, and/or cron-scheduled emissions. + + This node can: + - Add a fixed delay before forwarding messages + - Rate-limit messages to a minimum interval between forwards + - Schedule emissions using crontab syntax (like a cron job) + + The node passes through all input values to outputs with matching names. + If ``requires`` and ``provides`` have the same message names, the values are + forwarded directly. Otherwise, you can specify a ``mapping`` in params. + + **Cron functionality:** + - Without ``requires``: Emits current timestamp at each cron tick + - With ``requires``: Emits last received input at each cron tick + + **Execution order:** rate check → delay → forward + + :param params: Parameters dict containing: + - ``delay`` (int): Fixed delay in seconds before forwarding (default: 0) + - ``interval`` (int): Minimum interval in seconds between forwards (default: 0) + - ``mapping`` (dict): Optional mapping from input names to output names + - ``cron`` (str): Crontab expression for scheduled emissions (optional). + Standard 5-field format: ``minute hour day-of-month month day-of-week`` + :type params: dict + :param kwargs: Additional arguments passed to Node (requires, provides, name) + + :example: + Simple passthrough with delay: + + >>> delay_node = DelayNode( + ... requires=[Message(name="temperature", dtype=float)], + ... provides=[Message(name="temperature", dtype=float)], + ... params={"delay": 1}, + ... ) + + Rate-limited forwarding: + + >>> throttle_node = DelayNode( + ... requires=[Message(name="sensor_data", dtype=float)], + ... provides=[Message(name="sensor_data", dtype=float)], + ... params={"interval": 5}, + ... ) + + Cron: emit timestamp every day at 08:30: + + >>> alarm_node = DelayNode( + ... provides=[Message(name="timestamp", dtype=float)], + ... params={"cron": "30 8 * * *"}, + ... ) + + Cron: emit stored input every 5 minutes: + + >>> periodic_node = DelayNode( + ... requires=[Message(name="value", dtype=float)], + ... provides=[Message(name="value", dtype=float)], + ... params={"cron": "*/5 * * * *"}, + ... ) + + Cron: emit on weekdays at 23:59: + + >>> weekday_node = DelayNode( + ... requires=[Message(name="daily_summary", dtype=float)], + ... provides=[Message(name="daily_summary", dtype=float)], + ... params={"cron": "59 23 * * 1-5"}, + ... ) + """ + + __slots__ = ( + "delay", + "interval", + "ts", + "mapping", + "cron_expr", + "last_input", + "_cron_task", + "_stop_cron", + ) + + def __init__(self, params: dict, **kwargs): + self.delay = int(params.get("delay", 0)) + self.interval = int(params.get("interval", 0)) + self.mapping = params.get("mapping", {}) + self.cron_expr = params.get("cron") + self.ts = 0 + self.last_input = {} + self._cron_task = None + self._stop_cron = None + + if self.mapping: + assert ( + self.mapping.keys() == self.requires + ), "Input mapping does not match node requirements." + assert ( + self.mapping.values() == self.provides + ), "Output mapping does not match node requirements." + + super().__init__(f=self._f, params=params, **kwargs) + + def _f(self, params, **kwargs): + """Forward messages with optional delay, rate-limiting, and alarm.""" + + logger.info(f"[{self.name}] Received {kwargs}") + + # Store last input for cron use + if kwargs: + self.last_input = dict(kwargs) + + # Apply rate limiting first + if self.interval > 0: + ts = time.time() + if ts <= self.ts + self.interval: + logger.info(f"[{self.name}] Stashing {kwargs}") + return None + self.ts = ts + + # Apply fixed delay + if self.delay > 0: + time.sleep(self.delay) + + # Build output dict + if not kwargs: + return None + + output = {} + if self.mapping: + for input_name, output_name in self.mapping.items(): + output[output_name] = kwargs[input_name] + else: + for input_name, output_name in zip( + self.requires, self.provides, strict=True + ): + output[output_name] = kwargs[input_name] + + logger.info(f"[{self.name}] Sending {output}") + return output + + # ----------------------------------------------------------------- + # Cron scheduler + # ----------------------------------------------------------------- + + async def start_cron(self): + """ + Start the cron scheduler. + + Requires the ``croniter`` package. The scheduler runs in the background + and triggers emissions according to the ``cron`` expression in params. + + :raises ValueError: If no ``cron`` expression is configured. + """ + if not self.cron_expr: + logger.warning("No cron expression configured for node '%s'", self.name) + return + + if self._cron_task is not None: + logger.info("Cron already running for node '%s'", self.name) + return + + self._stop_cron = asyncio.Event() + self._cron_task = asyncio.create_task(self._cron_loop()) + logger.info("Started cron for node '%s': %s", self.name, self.cron_expr) + + async def stop_cron(self): + """Stop the cron scheduler.""" + if self._cron_task is None: + return + + if self._stop_cron: + self._stop_cron.set() + + self._cron_task.cancel() + try: + await self._cron_task + except asyncio.CancelledError: + pass + + self._cron_task = None + self._stop_cron = None + logger.info("Stopped cron for node '%s'", self.name) + + async def _cron_loop(self): + """Background loop that sleeps until the next cron tick and triggers.""" + from datetime import datetime + + try: + from croniter import croniter + except ImportError: + logger.error( + "croniter package is required for cron scheduling. " + "Install it with: pip install croniter" + ) + return + + if not croniter.is_valid(self.cron_expr): + logger.error( + "Invalid cron expression '%s' for node '%s'", + self.cron_expr, + self.name, + ) + return + + cron = croniter(self.cron_expr, datetime.now()) + + while not (self._stop_cron and self._stop_cron.is_set()): + try: + # Compute seconds until next tick + next_dt = cron.get_next(datetime) + now = datetime.now() + wait_seconds = max(0, (next_dt - now).total_seconds()) + + logger.info( + "Node '%s' cron: next tick at %s (in %.1fs)", + self.name, + next_dt.isoformat(), + wait_seconds, + ) + + # Sleep until next tick (wake up on stop signal) + try: + await asyncio.wait_for(self._stop_cron.wait(), timeout=wait_seconds) + # If we get here, stop was requested + break + except asyncio.TimeoutError: + # Timeout means it's time to fire + pass + + logger.info( + "Cron triggered for node '%s' (%s)", + self.name, + self.cron_expr, + ) + await self._trigger_cron() + + except asyncio.CancelledError: + break + except Exception as e: + logger.error( + "Error in cron loop for node '%s': %s", + self.name, + e, + exc_info=True, + ) + # Back off on error to avoid tight loops + await asyncio.sleep(60) + + async def _trigger_cron(self): + """Emit data into the pipeline on a cron tick.""" + if self._pipeline is None: + logger.warning( + "Node '%s' not bound to pipeline, cannot trigger cron", + self.name, + ) + return + + try: + output = {} + + if len(self.requires) == 0: + # No requires → emit current timestamp for each provides + for msg_name in self.provides: + output[msg_name] = time.time() + logger.info("Cron emission for node '%s': timestamp", self.name) + elif self.last_input: + # Has requires and stored input → re-emit last input + if self.mapping: + for input_name, output_name in self.mapping.items(): + if input_name in self.last_input: + output[output_name] = self.last_input[input_name] + else: + for input_name, output_name in zip( + self.requires, self.provides, strict=True + ): + if input_name in self.last_input: + output[output_name] = self.last_input[input_name] + logger.info( + "Cron emission for node '%s': stored input %s", + self.name, + output, + ) + else: + logger.warning( + "Cron for node '%s' triggered but no input stored yet", + self.name, + ) + return + + if output: + self._pipeline.trigger(self, output) + except Exception as e: + logger.error( + "Error triggering cron for node '%s': %s", + self.name, + e, + exc_info=True, + ) diff --git a/backend/app/flow/nodes/alarm_test.py b/backend/app/flow/nodes/alarm_test.py new file mode 100644 index 0000000..43365d5 --- /dev/null +++ b/backend/app/flow/nodes/alarm_test.py @@ -0,0 +1,20 @@ +from nodes import DelayNode +from util import Message +import numpy as np +import logging + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +def create_node(params: dict) -> DelayNode: + + return DelayNode( + requires=[], + provides=[Message(name="timestamp", dtype=float)], + params={"cron": "* * * * * *"}, + name="alarm", + ) diff --git a/backend/app/flow/nodes/delay_test.py b/backend/app/flow/nodes/delay_test.py new file mode 100644 index 0000000..f7d194c --- /dev/null +++ b/backend/app/flow/nodes/delay_test.py @@ -0,0 +1,25 @@ +from nodes import DelayNode +from util import Message + +import logging + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +def create_node(params: dict) -> DelayNode: + return DelayNode( + params={ + "delay": 1, + }, + requires=[ + Message(name="random_value_rec_rate", dtype=int), + ], + provides=[ + Message(name="random_value_rec_rate_delayed", dtype=int), + ], + name="delay", + ) diff --git a/backend/app/flow/nodes/influx_test.py b/backend/app/flow/nodes/influx_test.py new file mode 100644 index 0000000..627c103 --- /dev/null +++ b/backend/app/flow/nodes/influx_test.py @@ -0,0 +1,35 @@ +from nodes import InfluxDbNode +from util import Message + +import logging + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +def create_node(params: dict) -> InfluxDbNode: + return InfluxDbNode( + requires=[ + Message(name="random_value_rec_rate", dtype=int), + ], + params={ + "url": params.get("influxdb_url", "http://10.200.200.115:8086"), + "token": params.get( + "influxdb_token", + "***REMOVED-INFLUXDB-TOKEN***==", + ), + "org": params.get("influxdb_org", "strobl"), + "bucket": params.get("influxdb_bucket", "test"), + "writes": { + "random_value_rec_rate": { + "measurement": "test", + "field": "random", + }, + }, + "synchronous": True, + }, + name="influxdb", + ) diff --git a/backend/app/flow/nodes/mqtt_test_a.py b/backend/app/flow/nodes/mqtt_test_a.py new file mode 100644 index 0000000..ca60425 --- /dev/null +++ b/backend/app/flow/nodes/mqtt_test_a.py @@ -0,0 +1,25 @@ +from nodes import MqttNode +from util import Message + +import logging + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +def create_node(params: dict) -> MqttNode: + return MqttNode( + requires=[ + Message(name="random_value_send", dtype=int), + ], + params={ + "broker_host": "127.0.0.1", + "topic": { + "random_value_send": "random_value", + }, + }, + name="mqtt_a", + ) diff --git a/backend/app/flow/nodes/mqtt_test_b.py b/backend/app/flow/nodes/mqtt_test_b.py new file mode 100644 index 0000000..bd42ec5 --- /dev/null +++ b/backend/app/flow/nodes/mqtt_test_b.py @@ -0,0 +1,25 @@ +from nodes import MqttNode +from util import Message + +import logging + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +def create_node(params: dict) -> MqttNode: + return MqttNode( + provides=[ + Message(name="random_value_rec", dtype=int), + ], + params={ + "broker_host": "127.0.0.1", + "topic": { + "random_value_rec": "random_value", + }, + }, + name="mqtt_b", + ) diff --git a/backend/app/flow/nodes/notify_test.py b/backend/app/flow/nodes/notify_test.py new file mode 100644 index 0000000..e7f8a46 --- /dev/null +++ b/backend/app/flow/nodes/notify_test.py @@ -0,0 +1,30 @@ +from nodes import Node +from util import Message + +import logging + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +def create_node(params: dict) -> Node: + def check_power(params, random_value_rec_rate_delayed: int, **kwargs): + threshold = params.get("alert_threshold", 1000.0) + + if random_value_rec_rate_delayed > 50: + logger.info(f"[alert_node] ⚠️ Input >50") + else: + logger.info(f"[alert_node] ✓ Input < 50") + + return Node( + f=check_power, + requires=[ + Message(name="random_value_rec_rate_delayed", dtype=int), + ], + provides=[], # Using object for optional string + params=params, + name="notify", + ) diff --git a/backend/app/flow/nodes/random_test.py b/backend/app/flow/nodes/random_test.py new file mode 100644 index 0000000..8b30c0f --- /dev/null +++ b/backend/app/flow/nodes/random_test.py @@ -0,0 +1,32 @@ +from nodes import Node +from util import Message +import numpy as np +import logging + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +def create_node(params: dict) -> Node: + def f(params, timestamp): + seed = int(params.get("seed", 1000)) + rng = np.random.default_rng(seed=seed) + + rand_value = int(100 * rng.uniform()) + + return {"random_value_send": rand_value} + + return Node( + provides=[ + Message(name="random_value_send", dtype=int), + ], + requires=[Message(name="timestamp", dtype=float)], + f=f, + params={ + "seed": "1000", + }, + name="random", + ) diff --git a/backend/app/flow/nodes/rate_test.py b/backend/app/flow/nodes/rate_test.py new file mode 100644 index 0000000..a2c0254 --- /dev/null +++ b/backend/app/flow/nodes/rate_test.py @@ -0,0 +1,26 @@ +from nodes import DelayNode +from util import Message + +import logging + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +def create_node(params: dict) -> DelayNode: + return DelayNode( + params={ + "delay": 0, + "interval": 3, + }, + requires=[ + Message(name="random_value_rec", dtype=int), + ], + provides=[ + Message(name="random_value_rec_rate", dtype=int), + ], + name="rate", + ) diff --git a/backend/app/flow/nodes_example/alert_node.py b/backend/app/flow/nodes_example/alert_node.py new file mode 100644 index 0000000..4a5f033 --- /dev/null +++ b/backend/app/flow/nodes_example/alert_node.py @@ -0,0 +1,44 @@ +""" +Example sink node: Alert Generator + +This node consumes the comfort index and generates alerts +when comfort drops below a threshold. It demonstrates a +sink node (produces no outputs for other nodes). +""" + +from nodes import Node +from util import Message + + +def create_node(params: dict) -> Node: + """Create the alert generator node.""" + + def check_comfort_alert(params, comfort_index: float = 50.0, **kwargs): + """ + Check if comfort level requires an alert. + + Generates an alert message if comfort is too low. + """ + threshold = params.get("alert_threshold", 60.0) + + if comfort_index < threshold: + alert_msg = f"LOW COMFORT ALERT: Index {comfort_index:.1f} below threshold {threshold}" + print(f"[alert_node] ⚠️ {alert_msg}") + # In a real implementation, this could: + # - Send email/SMS + # - Push to a message queue + # - Trigger home automation + return {"alert": alert_msg} + else: + print(f"[alert_node] ✓ Comfort level OK ({comfort_index:.1f})") + return {"alert": None} + + return Node( + f=check_comfort_alert, + requires=[Message(name="comfort_index", dtype=float)], + provides=[ + Message(name="alert", dtype=object) + ], # Using object for optional string + params=params, + name="alert_node", + ) diff --git a/backend/app/flow/nodes_example/comfort_calculator.py b/backend/app/flow/nodes_example/comfort_calculator.py new file mode 100644 index 0000000..6a7776e --- /dev/null +++ b/backend/app/flow/nodes_example/comfort_calculator.py @@ -0,0 +1,46 @@ +""" +Example processing node: Comfort Calculator + +This node takes temperature and humidity as inputs and calculates +a comfort index. It demonstrates how nodes can have dependencies +that are automatically resolved by the pipeline. +""" + +from nodes import Node +from util import Message + + +def create_node(params: dict) -> Node: + """Create the comfort calculator node.""" + + def calculate_comfort( + params, temperature: float = 20.0, humidity: float = 50.0, **kwargs + ): + """ + Calculate comfort index based on temperature and humidity. + + Uses a simplified heat index formula. + """ + # Simplified comfort calculation + # Ideal: 22°C, 45% humidity + temp_diff = abs(temperature - 22.0) + humidity_diff = abs(humidity - 45.0) + + comfort = 100.0 - (temp_diff * 3) - (humidity_diff * 0.5) + comfort = max(0, min(100, comfort)) + + print( + f"[comfort_calculator] T={temperature:.1f}°C, H={humidity:.1f}% -> Comfort={comfort:.1f}" + ) + return {"comfort_index": comfort} + + return Node( + f=calculate_comfort, + requires=[ + Message(name="temperature", dtype=float), + Message(name="humidity", dtype=float), + ], + provides=[Message(name="comfort_index", dtype=float)], + params=params, + name="comfort_calculator", + ) diff --git a/backend/app/flow/nodes_example/http_sender.py b/backend/app/flow/nodes_example/http_sender.py new file mode 100644 index 0000000..64e85d1 --- /dev/null +++ b/backend/app/flow/nodes_example/http_sender.py @@ -0,0 +1,35 @@ +""" +Example HTTP sender node: Data Publisher + +This node sends processed data to an external API endpoint +via HTTP POST requests. +""" + +from nodes import HttpNode +from util import Message + + +def create_node(params: dict) -> HttpNode: + """ + Create an HTTP sender node that publishes comfort data. + + :param params: Parameters passed from the controller. + :type params: dict + :returns: Configured HttpNode instance in sender mode. + :rtype: HttpNode + """ + # Get the target URL from params or use a default + target_url = params.get("webhook_url", "https://httpbin.org/post") + + return HttpNode( + url=target_url, + method="POST", + requires=[ + Message(name="comfort_index", dtype=float), + Message(name="alert", dtype=object), + ], + params=params, + name="data_publisher", + timeout=10.0, + headers={"X-Source": "fluksio-pipeline"}, + ) diff --git a/backend/app/flow/nodes_example/http_trigger.py b/backend/app/flow/nodes_example/http_trigger.py new file mode 100644 index 0000000..3763d2c --- /dev/null +++ b/backend/app/flow/nodes_example/http_trigger.py @@ -0,0 +1,30 @@ +""" +Example HTTP trigger node: Webhook Receiver + +This node acts as a webhook endpoint that receives temperature data +via HTTP POST requests and injects it into the pipeline. +""" + +from nodes import HttpNode +from util import Message + + +def create_node(params: dict) -> HttpNode: + """ + Create an HTTP trigger node that receives temperature data. + + :param params: Parameters passed from the controller. + :type params: dict + :returns: Configured HttpNode instance in trigger mode. + :rtype: HttpNode + """ + return HttpNode( + url="/api/sensors/temperature", + method="POST", + provides=[ + Message(name="temperature", dtype=float), + Message(name="sensor_id", dtype=str), + ], + params=params, + name="temperature_webhook", + ) diff --git a/backend/app/flow/nodes_example/humidity_sensor.py b/backend/app/flow/nodes_example/humidity_sensor.py new file mode 100644 index 0000000..98364a7 --- /dev/null +++ b/backend/app/flow/nodes_example/humidity_sensor.py @@ -0,0 +1,29 @@ +""" +Example trigger node: Humidity Sensor + +Another trigger node that simulates a humidity sensor. +""" + +from nodes import Node +from util import Message + + +def create_node(params: dict) -> Node: + """Create the humidity sensor node.""" + + def read_humidity(params, **kwargs): + """Simulate reading humidity from a sensor.""" + import random + + humidity = 50.0 + random.gauss(0, 10) # 50% ± 10% + humidity = max(0, min(100, humidity)) # Clamp to [0, 100] + print(f"[humidity_sensor] Read humidity: {humidity:.1f}%") + return {"humidity": humidity} + + return Node( + f=read_humidity, + requires=[], + provides=[Message(name="humidity", dtype=float)], + params=params, + name="humidity_sensor", + ) diff --git a/backend/app/flow/nodes_example/influxdb_reader.py b/backend/app/flow/nodes_example/influxdb_reader.py new file mode 100644 index 0000000..08eac17 --- /dev/null +++ b/backend/app/flow/nodes_example/influxdb_reader.py @@ -0,0 +1,52 @@ +""" +Example InfluxDB reader node: Average Temperature Provider + +This node queries InfluxDB for average temperature and provides it +to downstream nodes. +""" + +from nodes import InfluxDbNode +from util import Message + + +def create_node(params: dict) -> InfluxDbNode: + """ + Create an InfluxDB reader node that queries average temperature. + + :param params: Parameters passed from the controller, should include: + - ``influxdb_url``: InfluxDB server URL + - ``influxdb_token``: Authentication token + - ``influxdb_org``: Organization name + - ``influxdb_bucket``: Bucket name + :type params: dict + :returns: Configured InfluxDbNode instance. + :rtype: InfluxDbNode + """ + return InfluxDbNode( + provides=[ + Message(name="avg_temperature", dtype=float), + Message(name="max_temperature", dtype=float), + ], + params={ + "url": params.get("influxdb_url", "http://localhost:8086"), + "token": params.get("influxdb_token", "my-token"), + "org": params.get("influxdb_org", "my-org"), + "bucket": params.get("influxdb_bucket", "sensors"), + "query_range": "-1h", + "queries": { + "avg_temperature": { + "measurement": "temperature", + "field": "value", + "tags": {"location": "room1"}, + "aggregation": "mean", + }, + "max_temperature": { + "measurement": "temperature", + "field": "value", + "tags": {"location": "room1"}, + "aggregation": "max", + }, + }, + }, + name="influxdb_temperature_reader", + ) diff --git a/backend/app/flow/nodes_example/influxdb_writer.py b/backend/app/flow/nodes_example/influxdb_writer.py new file mode 100644 index 0000000..43e43b8 --- /dev/null +++ b/backend/app/flow/nodes_example/influxdb_writer.py @@ -0,0 +1,54 @@ +""" +Example InfluxDB writer node: Temperature Logger + +This node writes temperature readings to InfluxDB. +The input is just a float value - the measurement, field, and tags +are configured via params. +""" + +from nodes import InfluxDbNode +from util import Message + + +def create_node(params: dict) -> InfluxDbNode: + """ + Create an InfluxDB writer node that logs temperature data. + + The node receives a simple float value and writes it to InfluxDB + with the configured measurement, field, and tags. + + :param params: Parameters passed from the controller, should include: + - ``influxdb_url``: InfluxDB server URL + - ``influxdb_token``: Authentication token + - ``influxdb_org``: Organization name + - ``influxdb_bucket``: Bucket name + :type params: dict + :returns: Configured InfluxDbNode instance. + :rtype: InfluxDbNode + """ + return InfluxDbNode( + requires=[ + Message(name="temperature", dtype=float), + Message(name="humidity", dtype=float), + ], + params={ + "url": params.get("influxdb_url", "http://localhost:8086"), + "token": params.get("influxdb_token", "my-token"), + "org": params.get("influxdb_org", "my-org"), + "bucket": params.get("influxdb_bucket", "sensors"), + "write_precision": "ms", + "writes": { + "temperature": { + "measurement": "environment", + "field": "temp_celsius", + "tags": {"location": "room1", "sensor": "dht22"}, + }, + "humidity": { + "measurement": "environment", + "field": "humidity_percent", + "tags": {"location": "room1", "sensor": "dht22"}, + }, + }, + }, + name="influxdb_temperature_writer", + ) diff --git a/backend/app/flow/nodes_example/mqtt_publisher.py b/backend/app/flow/nodes_example/mqtt_publisher.py new file mode 100644 index 0000000..0a2571b --- /dev/null +++ b/backend/app/flow/nodes_example/mqtt_publisher.py @@ -0,0 +1,36 @@ +""" +Example MQTT publisher node: Alert Publisher + +This node publishes alerts to an MQTT topic when comfort levels +are outside acceptable ranges. +""" + +from nodes import MqttNode +from util import Message + + +def create_node(params: dict) -> MqttNode: + """ + Create an MQTT publisher node that sends alert messages. + + :param params: Parameters passed from the controller, should include: + - ``broker_host``: MQTT broker hostname + - ``broker_port``: MQTT broker port (optional, default 1883) + - ``qos``: Quality of Service level (optional, default 0) + :type params: dict + :returns: Configured MqttNode instance in publisher mode. + :rtype: MqttNode + """ + return MqttNode( + topic="alerts/comfort", + requires=[ + Message(name="comfort_index", dtype=float), + Message(name="alert", dtype=object), + ], + params={ + **params, + "qos": params.get("qos", 1), # Use QoS 1 for alerts + "retain": True, # Retain last alert + }, + name="mqtt_alert_publisher", + ) diff --git a/backend/app/flow/nodes_example/mqtt_subscriber.py b/backend/app/flow/nodes_example/mqtt_subscriber.py new file mode 100644 index 0000000..f058c19 --- /dev/null +++ b/backend/app/flow/nodes_example/mqtt_subscriber.py @@ -0,0 +1,31 @@ +""" +Example MQTT subscriber node: Temperature Sensor Listener + +This node subscribes to an MQTT topic and triggers the pipeline +when temperature readings are received. +""" + +from nodes import MqttNode +from util import Message + + +def create_node(params: dict) -> MqttNode: + """ + Create an MQTT subscriber node that listens for temperature data. + + :param params: Parameters passed from the controller, should include: + - ``broker_host``: MQTT broker hostname + - ``broker_port``: MQTT broker port (optional, default 1883) + :type params: dict + :returns: Configured MqttNode instance in subscriber mode. + :rtype: MqttNode + """ + return MqttNode( + topic="sensors/temperature", + provides=[ + Message(name="temperature", dtype=float), + Message(name="sensor_id", dtype=str), + ], + params=params, + name="mqtt_temperature_listener", + ) diff --git a/backend/app/flow/nodes_example/temperature_sensor.py b/backend/app/flow/nodes_example/temperature_sensor.py new file mode 100644 index 0000000..5e5a5fb --- /dev/null +++ b/backend/app/flow/nodes_example/temperature_sensor.py @@ -0,0 +1,37 @@ +""" +Example trigger node: Temperature Sensor + +This is a trigger node (no inputs) that simulates a temperature sensor. +Trigger nodes act as entry points to the pipeline - they can be triggered +externally via HTTP, MQTT, or other mechanisms. +""" + +from nodes import Node +from util import Message + + +def create_node(params: dict) -> Node: + """ + Create the temperature sensor node. + + :param params: Parameters passed from the controller. + :type params: dict + :returns: Configured Node instance. + :rtype: Node + """ + + def read_temperature(params, **kwargs): + """Simulate reading temperature from a sensor.""" + import random + + temperature = 20.0 + random.gauss(0, 2) # 20°C ± 2°C + print(f"[temperature_sensor] Read temperature: {temperature:.2f}°C") + return {"temperature": temperature} + + return Node( + f=read_temperature, + requires=[], # No inputs - this is a trigger node + provides=[Message(name="temperature", dtype=float)], + params=params, + name="temperature_sensor", + ) diff --git a/backend/app/flow/pipeline.py b/backend/app/flow/pipeline.py new file mode 100644 index 0000000..3305c68 --- /dev/null +++ b/backend/app/flow/pipeline.py @@ -0,0 +1,626 @@ +""" +Pipeline module for directed acyclic graph execution. + +This module provides a Pipeline class that manages nodes with automatic +dependency resolution and supports both sequential and parallel execution. +""" + +from __future__ import annotations + +from collections import deque +from concurrent.futures import ThreadPoolExecutor, Future, wait + +import matplotlib.pyplot as plt +import networkx as nx + +from nodes import Node +from state import StateBackend, MemoryState +from util import Message + + +class Pipeline: + """ + Directed acyclic graph of nodes with automatic dependency resolution. + + A Pipeline manages a collection of nodes, automatically resolving their + dependencies and executing them in the correct order. Supports parallel + execution, external triggering, and composition of multiple pipelines. + + :param nodes: List of nodes owned by this pipeline. + :type nodes: list[Node] | None + :param pipelines: Child pipelines to compose into this pipeline. + :type pipelines: list[Pipeline] | None + :param inputs: External input messages (not produced by any node). + :type inputs: list[Message] | None + :param outputs: Output messages (for documentation purposes). + :type outputs: list[Message] | None + :param max_workers: Maximum thread pool workers for parallel execution. + :type max_workers: int | None + :param state: State backend for storing pipeline values. + :type state: StateBackend | None + + :example: + >>> pipeline = Pipeline( + ... nodes=[node_a, node_b], + ... pipelines=[child_pipeline], + ... max_workers=4 + ... ) + >>> result = pipeline.run() + """ + + __slots__ = ( + "_own_nodes", + "_child_pipelines", + "_parent", + "_state", + "produces", + "dependencies", + "_edges", + "_execution_order", + "_downstream_cache", + "_max_workers", + ) + + def __init__( + self, + nodes: list[Node] | None = None, + pipelines: list[Pipeline] | None = None, + inputs: list[Message] | None = None, + outputs: list[Message] | None = None, + max_workers: int | None = None, + state: StateBackend | None = None, + ) -> None: + self._own_nodes = nodes or [] + self._child_pipelines = pipelines or [] + self._parent: Pipeline | None = None + self._max_workers = max_workers + + # Set parent reference for child pipelines + for child in self._child_pipelines: + child._parent = self + + # Collect all nodes and build graph + all_nodes = self._collect_all_nodes() + + # Use provided state backend or create default MemoryState + self._state: StateBackend = state or MemoryState() + + # Build producer map and dependency graph + self.produces: dict[str, Node | None] = { + **({m.name: None for m in inputs} if inputs else {}), + **{msg: node for node in all_nodes for msg in node.provides}, + } + self.dependencies: dict[Node, frozenset[Node]] = { + node: frozenset( + self.produces[msg] for msg in node.requires if self.produces.get(msg) + ) + for node in all_nodes + } + + # Lazy-initialized caches + self._edges: dict[Node, set[Node]] | None = None + self._execution_order: list[Node] | None = None + self._downstream_cache: dict[Node, list[Node]] = {} + + # Bind all nodes to root pipeline + for node in all_nodes: + node.bind(self._root) + + # ------------------------------------------------------------------------- + # Properties + # ------------------------------------------------------------------------- + + @property + def _root(self) -> Pipeline: + """ + Get the root pipeline in the hierarchy. + + :returns: The topmost parent pipeline, or self if no parent. + :rtype: Pipeline + """ + return self._parent._root if self._parent else self + + @property + def nodes(self) -> list[Node]: + """ + All nodes in this pipeline and child pipelines. + + :returns: Flattened list of all nodes. + :rtype: list[Node] + """ + return self._collect_all_nodes() + + @property + def edges(self) -> dict[Node, set[Node]]: + """ + Reverse dependency graph mapping producers to consumers. + + Lazily built on first access. + + :returns: Dict mapping each node to its downstream consumers. + :rtype: dict[Node, set[Node]] + """ + if self._edges is None: + self._edges = {node: set() for node in self.nodes} + for consumer, producers in self.dependencies.items(): + for producer in producers: + self._edges[producer].add(consumer) + return self._edges + + @property + def state(self) -> StateBackend: + """ + State backend for storing pipeline values. + + :returns: State backend. + :rtype: StateBackend + """ + return self._state._data + + # ------------------------------------------------------------------------- + # Node Access + # ------------------------------------------------------------------------- + + def _collect_all_nodes(self) -> list[Node]: + """ + Recursively collect nodes from this pipeline and all children. + + :returns: List of all nodes in the hierarchy. + :rtype: list[Node] + """ + nodes = list(self._own_nodes) + for child in self._child_pipelines: + nodes.extend(child._collect_all_nodes()) + return nodes + + def get_node_by_id(self, nid) -> Node | None: + """ + Find a node by its ID. + + Searches this pipeline's own nodes first, then child pipelines. + + :param nid: The ID of the node to find. + :type nid: Any + :returns: The node with the given ID, or None if not found. + :rtype: Node | None + """ + if node := next((n for n in self._own_nodes if n.id is nid), None): + return node + for child in self._child_pipelines: + if node := child.get_node_by_id(nid): + return node + return None + + # ------------------------------------------------------------------------- + # Graph Algorithms + # ------------------------------------------------------------------------- + + def _topological_sort(self) -> list[Node]: + """ + Compute topological ordering of nodes using Kahn's algorithm. + + Results are cached for subsequent calls. + + :returns: Nodes in topologically sorted order. + :rtype: list[Node] + :raises ValueError: If a cycle is detected in the graph. + """ + if self._execution_order is not None: + return self._execution_order + + in_degree = {node: len(deps) for node, deps in self.dependencies.items()} + queue = deque(n for n, deg in in_degree.items() if deg == 0) + result: list[Node] = [] + + while queue: + node = queue.popleft() + result.append(node) + for consumer in self.edges[node]: + in_degree[consumer] -= 1 + if in_degree[consumer] == 0: + queue.append(consumer) + + if len(result) != len(self.nodes): + raise ValueError("Cycle detected in pipeline graph") + + self._execution_order = result + return result + + def _get_downstream(self, start: Node) -> list[Node]: + """ + Get all downstream nodes from a starting node in topological order. + + Results are cached per start node. + + :param start: The node to find downstream nodes from. + :type start: Node + :returns: Topologically sorted downstream nodes. + :rtype: list[Node] + """ + if start not in self._downstream_cache: + reachable: set[Node] = set() + queue = deque([start]) + while queue: + for consumer in self.edges[queue.popleft()]: + if consumer not in reachable: + reachable.add(consumer) + queue.append(consumer) + self._downstream_cache[start] = [ + n for n in self._topological_sort() if n in reachable + ] + return self._downstream_cache[start] + + # ------------------------------------------------------------------------- + # Synchronous Node Support + # ------------------------------------------------------------------------- + + def _version_key(self, msg_name: str) -> str: + """Get the state key for a message's version number.""" + return f"__version__:{msg_name}" + + def _last_seen_key(self, node_name: str, msg_name: str) -> str: + """Get the state key for the version a node last processed.""" + return f"__last_seen__:{node_name}:{msg_name}" + + def _increment_message_versions(self, outputs: dict) -> None: + """ + Increment version numbers for all output messages. + + :param outputs: Dict of message names to values. + :type outputs: dict + """ + for msg_name in outputs: + self._state.increment(self._version_key(msg_name)) + + def _check_synchronous_ready(self, node: Node) -> tuple[bool, dict[str, int]]: + """ + Check if a synchronous node is ready to execute. + + A synchronous node is ready when ALL its required inputs have a newer + version than what the node last processed. + + :param node: The synchronous node to check. + :type node: Node + :returns: Tuple of (is_ready, current_versions dict). + :rtype: tuple[bool, dict[str, int]] + """ + if not node.requires: + return True, {} + + # Build all keys we need to fetch + version_keys = [self._version_key(msg) for msg in node.requires] + last_seen_keys = [self._last_seen_key(node.name, msg) for msg in node.requires] + + # Fetch all values atomically + all_keys = version_keys + last_seen_keys + values = self._state.get_multi(all_keys) + + # Check each input + current_versions = {} + all_newer = True + + for msg_name in node.requires: + version_key = self._version_key(msg_name) + last_seen_key = self._last_seen_key(node.name, msg_name) + + current_version = values.get(version_key) or 0 + last_seen_version = values.get(last_seen_key) or 0 + + current_versions[msg_name] = current_version + + # For synchronous nodes, version must be: + # 1. Greater than 0 (message has been received at least once) + # 2. Strictly greater than last seen (message has been updated since last execution) + if current_version == 0 or current_version <= last_seen_version: + all_newer = False + + return all_newer, current_versions + + def _try_acquire_synchronous_execution( + self, + node: Node, + current_versions: dict[str, int], + ) -> bool: + """ + Attempt to acquire exclusive execution rights for a synchronous node. + + Uses compare-and-swap to atomically verify versions haven't changed + and update last_seen versions. This prevents race conditions when + multiple threads try to execute the same synchronous node. + + :param node: The node attempting to execute. + :type node: Node + :param current_versions: The versions that were checked. + :type current_versions: dict[str, int] + :returns: True if execution rights acquired, False otherwise. + :rtype: bool + """ + if not current_versions: + return True + + # Build expected values and updates + expected = {} + updates = {} + + for msg_name, version in current_versions.items(): + version_key = self._version_key(msg_name) + last_seen_key = self._last_seen_key(node.name, msg_name) + + # Expect the version hasn't changed since we checked + expected[version_key] = version + # Update last_seen to this version + updates[last_seen_key] = version + + return self._state.compare_and_swap_multi(expected, updates) + + def _is_node_ready(self, node: Node, state: StateBackend) -> bool: + """ + Check if a node is ready to execute. + + For non-synchronous nodes: ready when all required inputs exist in state. + For synchronous nodes: ready when all inputs are newer than last processed + AND we can acquire exclusive execution rights. + + :param node: The node to check. + :type node: Node + :param state: State backend containing input values. + :type state: StateBackend + :returns: True if the node should execute. + :rtype: bool + """ + # First check: all required inputs must exist in state + with state.lock(): + for msg_name in node.requires: + if msg_name not in state: + return False + + # For non-synchronous nodes, that's all we need + if not node.synchronous: + return True + + # For synchronous nodes, check if all inputs have been updated + is_ready, current_versions = self._check_synchronous_ready(node) + + if not is_ready: + return False + + # Try to acquire execution rights atomically + # This prevents race conditions when multiple triggers happen concurrently + return self._try_acquire_synchronous_execution(node, current_versions) + + # ------------------------------------------------------------------------- + # Execution Core + # ------------------------------------------------------------------------- + + def _execute_node( + self, + node: Node, + state: StateBackend, + ) -> dict | None: + """ + Execute a single node without triggering downstream propagation. + + :param node: The node to execute. + :type node: Node + :param state: State backend containing input values. + :type state: StateBackend + :returns: Node outputs, or None if no outputs. + :rtype: dict | None + """ + node._pipeline = None + try: + # Gather inputs with locking + with state.lock(): + inputs = {k: state[k] for k in node.requires if k in state} + + result = node(inputs) + + # Update state with outputs and increment versions + if result: + with state.lock(): + state.update(result) + # Increment version numbers for synchronous node tracking + self._increment_message_versions(result) + + return result + finally: + node._pipeline = self._root + + def _execute_parallel( + self, + nodes_subset: set[Node] | None, + state: StateBackend, + check_ready: bool = False, + ) -> StateBackend: + """ + Execute nodes in parallel using dynamic scheduling. + + :param nodes_subset: Subset of nodes to execute, or None for all nodes. + :type nodes_subset: set[Node] | None + :param state: State backend for inputs/outputs. + :type state: StateBackend + :param check_ready: If True, check state for required inputs before scheduling. + :type check_ready: bool + :returns: State backend after execution. + :rtype: StateBackend + """ + target_nodes = nodes_subset or set(self.nodes) + if not target_nodes: + return state + + # Build in-degree map (only counting deps within target set) + in_degree = { + n: sum(1 for dep in self.dependencies[n] if dep in target_nodes) + for n in target_nodes + } + + # Track submitted/completed/skipped nodes + submitted: set[Node] = set() + skipped: set[Node] = set() # Synchronous nodes that weren't ready + node_futures: dict[Node, Future] = {} + + def is_ready(n: Node) -> bool: + """Check if node can be scheduled.""" + if in_degree[n] != 0: + return False + if check_ready: + # Use the enhanced readiness check that handles synchronous nodes + return self._is_node_ready(n, state) + return True + + def submit_ready(executor: ThreadPoolExecutor) -> None: + """Submit all currently ready nodes.""" + for n in target_nodes: + if n not in submitted and n not in skipped and is_ready(n): + submitted.add(n) + node_futures[n] = executor.submit(self._execute_node, n, state) + elif n not in submitted and n.synchronous and in_degree[n] == 0: + # Mark synchronous nodes that weren't ready as skipped + # They may become ready on a future trigger + skipped.add(n) + + with ThreadPoolExecutor(max_workers=self._max_workers) as executor: + submit_ready(executor) + + while node_futures: + done, _ = wait(node_futures.values(), return_when="FIRST_COMPLETED") + completed = [n for n, f in node_futures.items() if f in done] + + for n in completed: + future = node_futures.pop(n) + if exc := future.exception(): + raise exc + + # Only propagate to downstream nodes if this node produced output + # If result is None, the node chose not to forward data (e.g., rate limiting) + result = future.result() + if result is not None: + # Update in-degrees and submit newly ready nodes + for consumer in self.edges[n]: + if consumer in target_nodes: + in_degree[consumer] -= 1 + submit_ready(executor) + + return state + + # ------------------------------------------------------------------------- + # Public Execution API + # ------------------------------------------------------------------------- + + def run(self, inputs: dict | None = None) -> StateBackend: + """ + Execute the entire pipeline. + + :param inputs: Initial input values to seed the state. + :type inputs: dict | None + :returns: State backend containing all computed values. + :rtype: StateBackend + + :example: + >>> result = pipeline.run({"input": 42}) + """ + # Create a fresh state for this run + state = MemoryState() + if inputs: + state.update(inputs) + + return self._execute_parallel(None, state, check_ready=False) + + def trigger(self, node: Node, outputs: dict | None) -> StateBackend: + """ + Trigger execution after a node runs externally. + + Updates the shared state with node outputs, increments message versions + for synchronous node tracking, and executes all downstream nodes whose + dependencies are satisfied. + + :param node: The node that was triggered. + :type node: Node + :param outputs: Outputs produced by the triggered node. + :type outputs: dict | None + :returns: Updated shared state. + :rtype: StateBackend + """ + root = self._root + state = root._state + + if outputs: + with state.lock(): + state.update(outputs) + # Increment version numbers for synchronous node tracking + root._increment_message_versions(outputs) + + downstream = set(root._get_downstream(node)) + if not downstream: + return state + + return root._execute_parallel(downstream, state, check_ready=True) + + def reset(self) -> None: + """ + Reset pipeline state for a new execution cycle. + + Clears all computed values from the shared state. + """ + self._root._state.clear() + + # ------------------------------------------------------------------------- + # Visualization + # ------------------------------------------------------------------------- + + def visualize(self, filename: str = "pipeline.png") -> None: + """ + Save a visualization of the pipeline graph. + + :param filename: Output filename for the image. + :type filename: str + """ + G = nx.DiGraph() + G.add_nodes_from(self.nodes) + G.add_edges_from( + (producer, consumer) + for consumer, producers in self.dependencies.items() + for producer in producers + ) + + plt.figure(figsize=(10, 6)) + pos = nx.spring_layout(G, k=0.8, seed=42) + + node_size = 1800 + node_radius = (node_size**0.5) / 2 + + nx.draw_networkx_nodes( + G, + pos, + node_size=node_size, + node_color="#59849B", + edgecolors="#59849B", + linewidths=1.2, + alpha=0.95, + ) + nx.draw_networkx_edges( + G, + pos, + arrows=True, + arrowstyle="-|>", + arrowsize=18, + width=2.8, + edge_color="#DE8F6E", + connectionstyle="arc3,rad=0.05", + min_source_margin=node_radius, + min_target_margin=node_radius, + ) + nx.draw_networkx_labels( + G, + pos, + font_size=15, + font_color="#F5F9E9", + font_weight="bold", + ) + + plt.axis("off") + plt.gca().set_facecolor("#333232") + plt.gcf().set_facecolor("#333232") + plt.tight_layout() + plt.savefig(filename, dpi=200) + plt.close() diff --git a/backend/app/flow/state.py b/backend/app/flow/state.py new file mode 100644 index 0000000..09a53c8 --- /dev/null +++ b/backend/app/flow/state.py @@ -0,0 +1,478 @@ +""" +State management module with pluggable backends. + +This module provides state storage backends for pipeline execution, +supporting both in-memory storage and Redis for distributed execution. +""" + +from __future__ import annotations + +import pickle +from abc import ABC, abstractmethod +from contextlib import contextmanager +from threading import RLock +from typing import Any, Iterator + +import redis + + +class StateBackend(ABC): + """ + Abstract base class for state storage backends. + + Provides a dict-like interface for storing and retrieving state values + with support for distributed locking. + """ + + @abstractmethod + def get(self, key: str, default: Any = None) -> Any: + """ + Get a value by key. + + :param key: The key to retrieve. + :type key: str + :param default: Default value if key not found. + :type default: Any + :returns: The stored value or default. + :rtype: Any + """ + ... + + @abstractmethod + def set(self, key: str, value: Any) -> None: + """ + Set a value by key. + + :param key: The key to set. + :type key: str + :param value: The value to store. + :type value: Any + """ + ... + + @abstractmethod + def exists(self, key: str) -> bool: + """ + Check if a key exists. + + :param key: The key to check. + :type key: str + :returns: True if key exists. + :rtype: bool + """ + ... + + @abstractmethod + def clear(self) -> None: + """Clear all keys in the state.""" + ... + + @abstractmethod + def keys(self) -> list[str]: + """ + Get all keys. + + :returns: List of all keys. + :rtype: list[str] + """ + ... + + @abstractmethod + def update(self, mapping: dict[str, Any]) -> None: + """ + Update multiple keys atomically. + + :param mapping: Dict of key-value pairs to set. + :type mapping: dict[str, Any] + """ + ... + + @abstractmethod + @contextmanager + def lock(self, timeout: float = 10.0): + """ + Acquire a lock for thread-safe operations. + + :param timeout: Lock timeout in seconds. + :type timeout: float + :yields: Lock context. + """ + ... + + # ------------------------------------------------------------------------- + # Atomic operations for synchronous node support + # ------------------------------------------------------------------------- + + @abstractmethod + def increment(self, key: str) -> int: + """ + Atomically increment a counter and return the new value. + + :param key: The key to increment. + :type key: str + :returns: The new value after incrementing. + :rtype: int + """ + ... + + @abstractmethod + def get_multi(self, keys: list[str]) -> dict[str, Any]: + """ + Get multiple values atomically. + + :param keys: List of keys to retrieve. + :type keys: list[str] + :returns: Dict mapping keys to values (missing keys have None). + :rtype: dict[str, Any] + """ + ... + + @abstractmethod + def compare_and_swap_multi( + self, + expected: dict[str, Any], + updates: dict[str, Any], + ) -> bool: + """ + Atomically check expected values and apply updates if all match. + + This is the key primitive for race-condition-free synchronous node execution. + If any expected value doesn't match the current value, no updates are applied. + + :param expected: Dict of keys to their expected values. + :type expected: dict[str, Any] + :param updates: Dict of keys to their new values (applied only if expected matches). + :type updates: dict[str, Any] + :returns: True if the swap succeeded, False if expected values didn't match. + :rtype: bool + """ + ... + + def __contains__(self, key: str) -> bool: + return self.exists(key) + + def __getitem__(self, key: str) -> Any: + if not self.exists(key): + raise KeyError(key) + return self.get(key) + + def __setitem__(self, key: str, value: Any) -> None: + self.set(key, value) + + def __iter__(self) -> Iterator[str]: + return iter(self.keys()) + + def __len__(self) -> int: + return len(self.keys()) + + +class MemoryState(StateBackend): + """ + In-memory state backend using a thread-safe dict. + + Suitable for single-process execution and testing. + + :example: + >>> state = MemoryState() + >>> state["key"] = "value" + >>> state["key"] + 'value' + """ + + __slots__ = ("_data", "_lock") + + def __init__(self) -> None: + self._data: dict[str, Any] = {} + self._lock = RLock() # Reentrant lock for nested access + + def get(self, key: str, default: Any = None) -> Any: + with self._lock: + return self._data.get(key, default) + + def set(self, key: str, value: Any) -> None: + with self._lock: + self._data[key] = value + + def exists(self, key: str) -> bool: + with self._lock: + return key in self._data + + def clear(self) -> None: + with self._lock: + self._data.clear() + + def keys(self) -> list[str]: + with self._lock: + return list(self._data.keys()) + + def update(self, mapping: dict[str, Any]) -> None: + with self._lock: + self._data.update(mapping) + + @contextmanager + def lock(self, timeout: float = 10.0): + """Acquire the internal lock.""" + acquired = self._lock.acquire(timeout=timeout) + if not acquired: + raise TimeoutError("Failed to acquire lock") + try: + yield + finally: + self._lock.release() + + def increment(self, key: str) -> int: + """Atomically increment a counter and return the new value.""" + with self._lock: + value = self._data.get(key, 0) + new_value = value + 1 + self._data[key] = new_value + return new_value + + def get_multi(self, keys: list[str]) -> dict[str, Any]: + """Get multiple values atomically.""" + with self._lock: + return {k: self._data.get(k) for k in keys} + + def compare_and_swap_multi( + self, + expected: dict[str, Any], + updates: dict[str, Any], + ) -> bool: + """Atomically check expected values and apply updates if all match.""" + with self._lock: + # Check all expected values + for key, exp_value in expected.items(): + actual = self._data.get(key) + if actual != exp_value: + return False + + # All matched, apply updates + self._data.update(updates) + return True + + +class RedisState(StateBackend): + """ + Redis-based state backend for distributed execution. + + Supports automatic serialization using pickle and distributed locking. + Uses a namespace prefix to isolate different pipeline executions. + + :param host: Redis host address. + :type host: str + :param port: Redis port number. + :type port: int + :param db: Redis database number. + :type db: int + :param namespace: Key prefix for isolation. + :type namespace: str + :param password: Redis password (optional). + :type password: str | None + :param ttl: Time-to-live for keys in seconds (optional). + :type ttl: int | None + + :example: + >>> state = RedisState(host="localhost", namespace="my_pipeline") + >>> state["result"] = {"value": 42} + >>> state["result"] + {'value': 42} + """ + + __slots__ = ("_client", "_namespace", "_ttl", "_lock_name") + + def __init__( + self, + host: str = "localhost", + port: int = 6379, + db: int = 0, + namespace: str = "pipeline", + password: str | None = None, + ttl: int | None = None, + ) -> None: + self._client = redis.Redis( + host=host, + port=port, + db=db, + password=password, + decode_responses=False, + ) + self._namespace = namespace + self._ttl = ttl + self._lock_name = f"{namespace}:_lock" + + def _key(self, key: str) -> str: + """Generate namespaced key.""" + return f"{self._namespace}:{key}" + + def _serialize(self, value: Any) -> bytes: + """Serialize value for storage.""" + return pickle.dumps(value) + + def _deserialize(self, data: bytes | None) -> Any: + """Deserialize value from storage.""" + return pickle.loads(data) if data else None + + def get(self, key: str, default: Any = None) -> Any: + data = self._client.get(self._key(key)) + return self._deserialize(data) if data is not None else default + + def set(self, key: str, value: Any) -> None: + data = self._serialize(value) + if self._ttl: + self._client.setex(self._key(key), self._ttl, data) + else: + self._client.set(self._key(key), data) + + def exists(self, key: str) -> bool: + return bool(self._client.exists(self._key(key))) + + def clear(self) -> None: + """Clear all keys in the namespace.""" + pattern = f"{self._namespace}:*" + cursor = 0 + while True: + cursor, keys = self._client.scan(cursor, match=pattern, count=100) + if keys: + self._client.delete(*keys) + if cursor == 0: + break + + def keys(self) -> list[str]: + """Get all keys in the namespace (without prefix).""" + pattern = f"{self._namespace}:*" + prefix_len = len(self._namespace) + 1 + result = [] + cursor = 0 + while True: + cursor, keys = self._client.scan(cursor, match=pattern, count=100) + for k in keys: + key_str = k.decode("utf-8") + # Skip the lock key + if not key_str.endswith(":_lock"): + result.append(key_str[prefix_len:]) + if cursor == 0: + break + return result + + def update(self, mapping: dict[str, Any]) -> None: + """Update multiple keys atomically using a pipeline.""" + if not mapping: + return + pipe = self._client.pipeline() + for key, value in mapping.items(): + data = self._serialize(value) + if self._ttl: + pipe.setex(self._key(key), self._ttl, data) + else: + pipe.set(self._key(key), data) + pipe.execute() + + @contextmanager + def lock(self, timeout: float = 10.0): + """ + Acquire a distributed lock using Redis. + + :param timeout: Lock timeout in seconds. + :type timeout: float + :yields: Lock context. + :raises TimeoutError: If lock cannot be acquired. + """ + redis_lock = self._client.lock( + self._lock_name, + timeout=timeout, + blocking_timeout=timeout, + ) + acquired = redis_lock.acquire() + if not acquired: + raise TimeoutError("Failed to acquire Redis lock") + try: + yield + finally: + try: + redis_lock.release() + except redis.exceptions.LockError: + pass # Lock may have expired + + def ping(self) -> bool: + """ + Check if Redis connection is available. + + :returns: True if connected. + :rtype: bool + """ + try: + return self._client.ping() + except redis.ConnectionError: + return False + + def increment(self, key: str) -> int: + """Atomically increment a counter using Redis INCR.""" + return self._client.incr(self._key(key)) + + def get_multi(self, keys: list[str]) -> dict[str, Any]: + """Get multiple values atomically using Redis MGET.""" + if not keys: + return {} + + full_keys = [self._key(k) for k in keys] + values = self._client.mget(full_keys) + + result = {} + for key, value in zip(keys, values): + result[key] = self._deserialize(value) if value is not None else None + + return result + + def compare_and_swap_multi( + self, + expected: dict[str, Any], + updates: dict[str, Any], + ) -> bool: + """ + Atomically check expected values and apply updates using Redis WATCH/MULTI. + + Uses optimistic locking with WATCH to detect concurrent modifications. + If any watched key changes before EXEC, the transaction is aborted. + + :param expected: Dict of keys to their expected values. + :type expected: dict[str, Any] + :param updates: Dict of keys to their new values. + :type updates: dict[str, Any] + :returns: True if the swap succeeded, False otherwise. + :rtype: bool + """ + if not expected: + # No expectations, just set the updates + self.update(updates) + return True + + watch_keys = [self._key(k) for k in expected.keys()] + + try: + # Start watching the keys for changes + self._client.watch(*watch_keys) + + # Check current values + current = self.get_multi(list(expected.keys())) + + for key, exp_value in expected.items(): + if current.get(key) != exp_value: + self._client.unwatch() + return False + + # All values match - execute the transaction + pipe = self._client.pipeline(True) # True = use MULTI/EXEC + + for key, value in updates.items(): + data = self._serialize(value) + if self._ttl: + pipe.setex(self._key(key), self._ttl, data) + else: + pipe.set(self._key(key), data) + + pipe.execute() + return True + + except redis.WatchError: + # Another client modified one of the watched keys + return False diff --git a/backend/app/flow/test.py b/backend/app/flow/test.py new file mode 100644 index 0000000..69a4a04 --- /dev/null +++ b/backend/app/flow/test.py @@ -0,0 +1,73 @@ +from pipeline import Pipeline +from state import MemoryState, RedisState +from dag_generator import ( + generate_multi_pipeline_dag, + dag_to_pipeline_nodes, +) +import time +import numpy as np +import threading + + +# Thread-safe RNG +rng_lock = threading.Lock() +rng = np.random.default_rng(1000) + +# Parameters for nodes +params = {"rng": rng, "rng_lock": rng_lock} + +state = RedisState( + host="localhost", + port=6379, + namespace="fluksio_pipeline", + ttl=3600, # Keys expire after 1 hour +) +print("Using Redis state backend") +assert state.ping(), "Can't connect to redis" + +n_pipelines = 30 +n_nodes_per_pipeline = 100 + +print("\n=== Generating multi-pipeline DAG ===") +start = time.time() +pipeline_graphs, combined_graph = generate_multi_pipeline_dag( + num_pipelines=n_pipelines, + nodes_per_pipeline=n_nodes_per_pipeline, + cross_pipeline_edges=2, + edge_probability=0.4, + seed=5, +) + +# Create pipelines from each sub-graph +child_pipelines = [] +all_trigger_nodes = [] + +for i, G in enumerate(pipeline_graphs): + nodes_i, _, triggers_i = dag_to_pipeline_nodes(G, params=params, seed=42 + i) + child_pipelines.append(Pipeline(nodes=nodes_i)) + all_trigger_nodes.extend(triggers_i) + print(f"Pipeline {i}: {len(nodes_i)} nodes, {len(triggers_i)} triggers") + +# Compose into parent pipeline +pipeline = Pipeline( + pipelines=child_pipelines, + max_workers=4, + state=state, +) + +print(f"Generated DAG in {time.time() - start:.2f} seconds") + +# Visualize the complete pipeline +if n_pipelines * n_nodes_per_pipeline < 1000: + pipeline.visualize("composed_pipeline.png") + +# ============================================================================= +# Run the pipeline by triggering source nodes +# ============================================================================= +print(f"\n=== Triggering {len(all_trigger_nodes)} source nodes ===") +for i, trigger_node in enumerate(all_trigger_nodes): + print(f"Triggering node {i + 1}/{len(all_trigger_nodes)}: {trigger_node.name}") + pipeline.get_node_by_id(trigger_node.id).trigger() + print(f"State contains {len(list(pipeline._state.keys()))} values") + +print("\n=== Pipeline execution complete ===") diff --git a/backend/app/flow/test_controller.py b/backend/app/flow/test_controller.py new file mode 100644 index 0000000..0470e2f --- /dev/null +++ b/backend/app/flow/test_controller.py @@ -0,0 +1,194 @@ +""" +Test script for the PipelineController. + +This script demonstrates the controller's ability to: +1. Scan a nodes directory and build a pipeline +2. Watch for file changes and update the pipeline dynamically +3. Handle errors gracefully without breaking the pipeline +4. Accept node code from external sources (simulating frontend input) +""" + +import asyncio +import sys +from pathlib import Path + +# Add flow directory to path for imports +sys.path.insert(0, str(Path(__file__).parent)) + +from controller import PipelineController, NodeStatus +from state import RedisState, MemoryState + + +async def main(): + """Run the controller test.""" + + print("=" * 60) + print("Pipeline Controller Test") + print("=" * 60) + + # Use Redis if available, otherwise fall back to memory + try: + state = RedisState( + host="localhost", + port=6379, + namespace="fluksio_controller_test", + ttl=3600, + ) + if state.ping(): + print("✓ Using Redis state backend") + else: + raise ConnectionError("Redis ping failed") + except Exception as e: + print(f"⚠ Redis not available ({e}), using MemoryState") + state = MemoryState() + + # Create controller pointing to example nodes + nodes_dir = Path(__file__).parent / "nodes_example" + + controller = PipelineController( + nodes_dir=nodes_dir, + state_backend=state, + max_workers=4, + node_params={"alert_threshold": 70.0}, # Custom param for alert node + ) + + # Register callbacks to see what's happening + controller.on_node_loaded( + lambda nid, node: print( + f" ✓ Loaded: {nid} ({len(node.requires)} inputs, {len(node.provides)} outputs)" + ) + ) + controller.on_node_error( + lambda nid, err: print(f" ✗ Error in {nid}: {err.split(chr(10))[0]}") + ) + controller.on_node_removed(lambda nid: print(f" ⊘ Removed: {nid}")) + controller.on_pipeline_rebuilt( + lambda p: print( + f" ⟳ Pipeline rebuilt: {len(p.nodes)} nodes, {len(p.dependencies)} dependencies" + ) + ) + + # Start the controller + print("\n--- Starting Controller ---") + await controller.start() + + # Show current state + print("\n--- Current State ---") + print(f"Active nodes: {[n.name for n in controller.active_nodes]}") + print(f"Error nodes: {list(controller.error_nodes.keys())}") + + if controller.pipeline: + print(f"\nPipeline dependency graph:") + for node, deps in controller.pipeline.dependencies.items(): + dep_names = [d.name for d in deps] if deps else ["(trigger)"] + print(f" {node.name} <- {dep_names}") + + # Test triggering nodes + print("\n--- Testing Node Triggers ---") + + if controller.pipeline: + # Find trigger nodes (nodes with no dependencies) + trigger_nodes = [ + n + for n in controller.active_nodes + if not controller.pipeline.dependencies.get(n) + ] + + print(f"Trigger nodes: {[n.name for n in trigger_nodes]}") + + # Trigger each trigger node + for node in trigger_nodes: + print(f"\nTriggering: {node.name}") + try: + node.trigger() + except Exception as e: + print(f" Error: {e}") + + # Show state after triggers + print(f"\nState after triggers:") + for key in controller.state_backend.keys(): + value = controller.state_backend.get(key) + print(f" {key}: {value}") + + # Test dynamic node addition + print("\n--- Testing Dynamic Node Addition ---") + + new_node_code = ''' +"""Dynamically added node that logs all sensor data.""" + +from nodes import Node +from util import Message + + +def create_node(params): + def log_data(params, temperature=None, humidity=None, comfort_index=None, **kwargs): + """Log all available sensor data.""" + print(f"[data_logger] Logging: T={temperature}, H={humidity}, Comfort={comfort_index}") + return {"log_entry": f"T={temperature}, H={humidity}, C={comfort_index}"} + + return Node( + f=log_data, + requires=[ + Message(name="temperature", dtype=float), + Message(name="humidity", dtype=float), + Message(name="comfort_index", dtype=float), + ], + provides=[Message(name="log_entry", dtype=str)], + params=params, + name="data_logger", + ) +''' + + print("Adding 'data_logger' node from code...") + controller.add_node_from_code("data_logger", new_node_code) + + # Wait for file watcher to pick up the change + await asyncio.sleep(1.0) + + print(f"\nActive nodes after addition: {[n.name for n in controller.active_nodes]}") + + # Test adding a broken node + print("\n--- Testing Error Handling ---") + + broken_node_code = ''' +"""This node has an intentional error.""" + +from nodes import Node +from util import Message + + +def create_node(params): + # This will cause a NameError + return undefined_variable_that_does_not_exist +''' + + print("Adding 'broken_node' with intentional error...") + controller.add_node_from_code("broken_node", broken_node_code) + + await asyncio.sleep(1.0) + + print( + f"\nActive nodes (should exclude broken): {[n.name for n in controller.active_nodes]}" + ) + print(f"Error nodes: {list(controller.error_nodes.keys())}") + + # The pipeline should still work with the other nodes + if controller.pipeline: + print(f"\nPipeline still has {len(controller.pipeline.nodes)} working nodes") + + # Clean up test nodes + print("\n--- Cleaning Up ---") + controller.remove_node("data_logger") + controller.remove_node("broken_node") + + await asyncio.sleep(0.5) + + print(f"Active nodes after cleanup: {[n.name for n in controller.active_nodes]}") + + # Stop the controller + await controller.stop() + print("\n✓ Controller stopped") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/app/flow/test_http_node.py b/backend/app/flow/test_http_node.py new file mode 100644 index 0000000..750892f --- /dev/null +++ b/backend/app/flow/test_http_node.py @@ -0,0 +1,270 @@ +""" +Test script for HttpNode functionality. + +This script demonstrates both trigger (receiver) and sender modes of HttpNode, +including integration with FastAPI and the PipelineController. +""" + +import asyncio +import sys +from pathlib import Path + +# Add flow directory to path for imports +sys.path.insert(0, str(Path(__file__).parent)) + +from fastapi import FastAPI +from starlette.testclient import TestClient + +from nodes import HttpNode, Node +from pipeline import Pipeline +from state import MemoryState +from util import Message +from controller import PipelineController + + +def test_http_node_modes(): + """Test HttpNode mode detection.""" + print("=" * 60) + print("Testing HttpNode Mode Detection") + print("=" * 60) + + # Trigger mode: only provides + trigger = HttpNode( + url="/api/data", + method="POST", + provides=[Message(name="value", dtype=float)], + ) + print(f"✓ Trigger node mode: {trigger.mode}") + assert trigger.mode == HttpNode.Mode.TRIGGER + + # Sender mode: has requires + sender = HttpNode( + url="https://httpbin.org/post", + method="POST", + requires=[Message(name="value", dtype=float)], + ) + print(f"✓ Sender node mode: {sender.mode}") + assert sender.mode == HttpNode.Mode.SENDER + + # Both requires and provides = sender mode + hybrid = HttpNode( + url="https://httpbin.org/post", + method="POST", + requires=[Message(name="input", dtype=float)], + provides=[Message(name="output", dtype=float)], + ) + print(f"✓ Hybrid node mode: {hybrid.mode}") + assert hybrid.mode == HttpNode.Mode.SENDER + + # Error: neither requires nor provides + try: + invalid = HttpNode(url="/api/nothing", method="GET") + assert False, "Should have raised ValueError" + except ValueError as e: + print(f"✓ Correctly rejected invalid config: {e}") + + print("\n✓ All mode detection tests passed!\n") + + +def test_http_trigger_with_fastapi(): + """Test HTTP trigger node with FastAPI.""" + print("=" * 60) + print("Testing HTTP Trigger with FastAPI") + print("=" * 60) + + # Create FastAPI app + app = FastAPI() + + # Create a simple pipeline with HTTP trigger + trigger = HttpNode( + url="/api/sensors/temperature", + method="POST", + provides=[ + Message(name="temperature", dtype=float), + Message(name="sensor_id", dtype=str), + ], + name="temp_trigger", + ) + + # Create a processing node + def process_temp(params, temperature=0.0, sensor_id="unknown", **kwargs): + celsius = temperature + fahrenheit = celsius * 9 / 5 + 32 + print(f"[processor] Sensor {sensor_id}: {celsius}°C = {fahrenheit}°F") + return {"fahrenheit": fahrenheit} + + processor = Node( + f=process_temp, + requires=[ + Message(name="temperature", dtype=float), + Message(name="sensor_id", dtype=str), + ], + provides=[Message(name="fahrenheit", dtype=float)], + params={}, + name="temp_processor", + ) + + # Build pipeline + state = MemoryState() + pipeline = Pipeline( + nodes=[trigger, processor], + state=state, + max_workers=2, + ) + + # Register the HTTP route + trigger.register_route(app) + + # Test with TestClient + client = TestClient(app) + + print("\nSending POST request to /api/sensors/temperature...") + response = client.post( + "/api/sensors/temperature", + json={"temperature": 25.5, "sensor_id": "sensor_001"}, + ) + + print(f"Response status: {response.status_code}") + print(f"Response body: {response.json()}") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "triggered" + assert data["node"] == "temp_trigger" + + # Check pipeline state + print(f"\nPipeline state after trigger:") + for key in state.keys(): + print(f" {key}: {state.get(key)}") + + print("\n✓ HTTP trigger test passed!\n") + + +def test_http_sender(): + """Test HTTP sender node.""" + print("=" * 60) + print("Testing HTTP Sender") + print("=" * 60) + + # Create a sender node that posts to httpbin + sender = HttpNode( + url="https://httpbin.org/post", + method="POST", + requires=[ + Message(name="temperature", dtype=float), + Message(name="humidity", dtype=float), + ], + name="data_sender", + params=dict( + timeout=10.0, + headers={"X-Custom-Header": "fluksio-test"}, + ), + ) + + print(f"Sender node created: {sender.name}") + print(f" URL: {sender.url}") + print(f" Method: {sender.method}") + print(f" Mode: {sender.mode}") + + # Note: Actual HTTP call would need async context or be mocked + # For this test, we just verify the node is configured correctly + print("\n✓ HTTP sender configuration test passed!\n") + + +async def test_controller_with_http_nodes(): + """Test PipelineController with HTTP nodes.""" + print("=" * 60) + print("Testing Controller with HTTP Nodes") + print("=" * 60) + + import tempfile + + # Create FastAPI app + app = FastAPI() + + with tempfile.TemporaryDirectory() as tmpdir: + nodes_dir = Path(tmpdir) / "nodes" + nodes_dir.mkdir() + + # Create an HTTP trigger node file + trigger_code = """ +from nodes import HttpNode +from util import Message + +def create_node(params): + return HttpNode( + url="/api/webhook", + method="POST", + provides=[ + Message(name="event_type", dtype=str), + Message(name="payload", dtype=object), + ], + name="webhook_receiver", + ) +""" + (nodes_dir / "webhook.py").write_text(trigger_code) + + # Create controller with FastAPI app + controller = PipelineController( + nodes_dir=nodes_dir, + max_workers=2, + fastapi_app=app, + ) + + # Register callbacks + controller.on_node_loaded( + lambda nid, node: print( + f" ✓ Loaded: {nid} (mode: {getattr(node, 'mode', 'N/A')})" + ) + ) + + # Start controller + print("\nStarting controller...") + await controller.start() + + print(f"\nActive nodes: {[n.name for n in controller.active_nodes]}") + print(f"HTTP trigger nodes: {[n.name for n in controller.http_trigger_nodes]}") + print(f"HTTP sender nodes: {[n.name for n in controller.http_sender_nodes]}") + + # Check that route was registered + route_paths = [r.path for r in app.routes if hasattr(r, "path")] + print(f"\nRegistered routes: {route_paths}") + + if "/api/webhook" in route_paths: + print("✓ Webhook route registered!") + + # Test the endpoint + client = TestClient(app) + response = client.post( + "/api/webhook", + json={"event_type": "test", "payload": {"key": "value"}}, + ) + print(f"Webhook response: {response.json()}") + + # Stop controller + await controller.stop() + + print("\n✓ Controller with HTTP nodes test passed!\n") + + +def main(): + """Run all tests.""" + print("\n" + "=" * 60) + print("HttpNode Test Suite") + print("=" * 60 + "\n") + + # Synchronous tests + test_http_node_modes() + test_http_trigger_with_fastapi() + test_http_sender() + + # Async tests + asyncio.run(test_controller_with_http_nodes()) + + print("=" * 60) + print("All tests completed successfully!") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/backend/app/flow/test_influxdb_node.py b/backend/app/flow/test_influxdb_node.py new file mode 100644 index 0000000..cb4430e --- /dev/null +++ b/backend/app/flow/test_influxdb_node.py @@ -0,0 +1,447 @@ +""" +Test script for InfluxDbNode functionality. + +This script demonstrates write and read operations with InfluxDB. +Requires an InfluxDB instance running. + +To run a local InfluxDB instance: + docker run -d -p 8086:8086 \ + -e DOCKER_INFLUXDB_INIT_MODE=setup \ + -e DOCKER_INFLUXDB_INIT_USERNAME=admin \ + -e DOCKER_INFLUXDB_INIT_PASSWORD=adminpass \ + -e DOCKER_INFLUXDB_INIT_ORG=my-org \ + -e DOCKER_INFLUXDB_INIT_BUCKET=sensors \ + -e DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=my-super-secret-token \ + influxdb:2.7 +""" + +import asyncio +import sys +import time +from pathlib import Path + +# Add flow directory to path for imports +sys.path.insert(0, str(Path(__file__).parent)) + +from nodes import InfluxDbNode, Node +from pipeline import Pipeline +from state import MemoryState +from util import Message + + +# Default test configuration +TEST_CONFIG = { + "url": "http://10.200.200.115:8086", + "token": "***REMOVED-INFLUXDB-TOKEN***==", + "org": "strobl", + "bucket": "test", +} + + +def test_influxdb_node_validation(): + """Test InfluxDbNode parameter validation.""" + print("=" * 60) + print("Testing InfluxDbNode Validation") + print("=" * 60) + + # Test missing required params + try: + node = InfluxDbNode( + requires=[Message(name="data", dtype=float)], + params={"url": "http://localhost:8086"}, # Missing token, org, bucket + ) + assert False, "Should have raised ValueError" + except ValueError as e: + print(f"✓ Correctly rejected missing params: {e}") + + # Test neither requires nor provides + try: + node = InfluxDbNode(params=TEST_CONFIG) + assert False, "Should have raised ValueError" + except ValueError as e: + print(f"✓ Correctly rejected empty requires/provides: {e}") + + # Test valid write-only node + writer = InfluxDbNode( + requires=[Message(name="temperature", dtype=float)], + params={ + **TEST_CONFIG, + "writes": { + "temperature": { + "measurement": "sensor_data", + "field": "temp_celsius", + "tags": {"location": "room1"}, + } + }, + }, + ) + print(f"✓ Created write-only node: {writer.name}") + assert len(writer.requires) == 1 + assert len(writer.provides) == 0 + + # Test valid read-only node + reader = InfluxDbNode( + provides=[Message(name="avg_temperature", dtype=float)], + params={ + **TEST_CONFIG, + "queries": { + "avg_temperature": { + "measurement": "sensor_data", + "field": "temp_celsius", + "aggregation": "mean", + } + }, + }, + ) + print(f"✓ Created read-only node: {reader.name}") + assert len(reader.requires) == 0 + assert len(reader.provides) == 1 + + # Test combined read/write node + combined = InfluxDbNode( + requires=[Message(name="raw_temp", dtype=float)], + provides=[Message(name="avg_temp", dtype=float)], + params={ + **TEST_CONFIG, + "writes": { + "raw_temp": { + "measurement": "temperature", + "field": "value", + "tags": {"source": "sensor"}, + } + }, + "queries": { + "avg_temp": { + "measurement": "temperature", + "field": "value", + "aggregation": "mean", + } + }, + }, + ) + print(f"✓ Created combined node: {combined.name}") + assert len(combined.requires) == 1 + assert len(combined.provides) == 1 + + print("\n✓ All validation tests passed!\n") + + +def test_flux_query_building(): + """Test Flux query string generation.""" + print("=" * 60) + print("Testing Flux Query Building") + print("=" * 60) + + node = InfluxDbNode( + provides=[Message(name="value", dtype=float)], + params={ + **TEST_CONFIG, + "queries": { + "value": { + "measurement": "temperature", + "field": "celsius", + "tags": {"location": "room1", "sensor": "dht22"}, + "range": "-24h", + "aggregation": "mean", + } + }, + }, + ) + + query = node._build_flux_query( + measurement="temperature", + field="celsius", + tags={"location": "room1", "sensor": "dht22"}, + time_range="-24h", + aggregation="mean", + ) + + print(f"Generated Flux query:\n{query}\n") + + # Verify query components + assert 'from(bucket: "test")' in query + assert "range(start: -24h)" in query + assert 'r["_measurement"] == "temperature"' in query + assert 'r["_field"] == "celsius"' in query + assert 'r["location"] == "room1"' in query + assert 'r["sensor"] == "dht22"' in query + assert "mean()" in query + + print("✓ Query contains all expected components") + + # Test different aggregations + for agg in ["last", "first", "max", "min", "sum", "count"]: + query = node._build_flux_query( + measurement="test", + field="value", + tags={}, + time_range="-1h", + aggregation=agg, + ) + assert f"{agg}()" in query + print(f"✓ Aggregation '{agg}' works") + + print("\n✓ All query building tests passed!\n") + + +def test_influxdb_write(skip_if_no_server: bool = True): + """Test writing data to InfluxDB.""" + print("=" * 60) + print("Testing InfluxDB Write") + print("=" * 60) + + # Create writer node with write configuration + writer = InfluxDbNode( + requires=[Message(name="temperature", dtype=float)], + params={ + **TEST_CONFIG, + "writes": { + "temperature": { + "measurement": "test_temperature", + "field": "value", + "tags": {"location": "test_room", "sensor": "test_sensor"}, + } + }, + }, + name="test_writer", + ) + + # Build a simple pipeline + state = MemoryState() + pipeline = Pipeline(nodes=[writer], state=state, max_workers=1) + + # Test data to write - now just a simple value! + test_data = {"temperature": 25.5} + + print(f"Attempting to write: {test_data}") + + try: + writer._write_points(test_data) + print("✓ Write successful!") + except Exception as e: + if skip_if_no_server: + print(f"⚠ Write failed (server may not be running): {e}") + else: + raise + + # Test with runtime tags override + test_data_with_tags = { + "temperature": {"value": 26.0, "tags": {"sensor": "override_sensor"}} + } + print(f"Attempting to write with runtime tags: {test_data_with_tags}") + + try: + writer._write_points(test_data_with_tags) + print("✓ Write with runtime tags successful!") + except Exception as e: + if skip_if_no_server: + print(f"⚠ Write failed (server may not be running): {e}") + else: + raise + + print("\n✓ Write test completed!\n") + + +def test_influxdb_read(skip_if_no_server: bool = True): + """Test reading data from InfluxDB.""" + print("=" * 60) + print("Testing InfluxDB Read") + print("=" * 60) + + # Create reader node + reader = InfluxDbNode( + provides=[ + Message(name="last_temp", dtype=float), + Message(name="avg_temp", dtype=float), + ], + params={ + **TEST_CONFIG, + "query_range": "-1h", + "queries": { + "last_temp": { + "measurement": "test_temperature", + "field": "value", + "tags": {"location": "test_room"}, + "aggregation": "last", + }, + "avg_temp": { + "measurement": "test_temperature", + "field": "value", + "tags": {"location": "test_room"}, + "aggregation": "mean", + }, + }, + }, + name="test_reader", + ) + + print(f"Attempting to query data...") + + try: + results = reader._query_data() + print(f"✓ Query successful! Results: {results}") + + if results: + for key, value in results.items(): + print(f" {key}: {value}") + else: + print(" No data found (this is OK if no data was written)") + except Exception as e: + if skip_if_no_server: + print(f"⚠ Query failed (server may not be running): {e}") + else: + raise + + print("\n✓ Read test completed!\n") + + +def test_influxdb_pipeline_integration(skip_if_no_server: bool = True): + """Test InfluxDbNode in a complete pipeline.""" + print("=" * 60) + print("Testing InfluxDB Pipeline Integration") + print("=" * 60) + + # Create a source node that generates sensor data (now just a float!) + def generate_sensor_data(params, **kwargs): + """Generate test sensor data.""" + import random + + return {"temperature": 20.0 + random.random() * 10} + + source = Node( + f=generate_sensor_data, + requires=[], + provides=[Message(name="temperature", dtype=float)], + params={}, + name="sensor_source", + ) + + # Create InfluxDB writer that receives the sensor data + writer = InfluxDbNode( + requires=[Message(name="temperature", dtype=float)], + params={ + **TEST_CONFIG, + "writes": { + "temperature": { + "measurement": "pipeline_test", + "field": "temp_value", + "tags": { + "source": "test_pipeline", + "run_id": str(int(time.time())), + }, + } + }, + }, + name="influx_writer", + ) + + # Build pipeline + state = MemoryState() + pipeline = Pipeline( + nodes=[source, writer], + state=state, + max_workers=2, + ) + + print(f"Pipeline created with nodes: {[n.name for n in pipeline.nodes]}") + print(f"Dependencies: {pipeline.dependencies}") + + try: + # Trigger the source node to start the pipeline + print("\nTriggering source node...") + result = source.inject({}) # Source has no inputs, just generates output + + print(f"Pipeline execution result: {result}") + print("✓ Pipeline integration successful!") + except Exception as e: + if skip_if_no_server: + print(f"⚠ Pipeline test failed (server may not be running): {e}") + else: + raise + + print("\n✓ Pipeline integration test completed!\n") + + +async def test_influxdb_async_operations(skip_if_no_server: bool = True): + """Test async write and query operations.""" + print("=" * 60) + print("Testing InfluxDB Async Operations") + print("=" * 60) + + # Create a combined node with write and query configs + node = InfluxDbNode( + requires=[Message(name="temperature", dtype=float)], + provides=[Message(name="last_value", dtype=float)], + params={ + **TEST_CONFIG, + "writes": { + "temperature": { + "measurement": "async_test", + "field": "value", + "tags": {"test": "async"}, + } + }, + "queries": { + "last_value": { + "measurement": "async_test", + "field": "value", + "aggregation": "last", + } + }, + }, + name="async_test_node", + ) + + try: + # Test async write - now just a simple value! + print("Testing async write...") + await node.write_async({"temperature": 42.0}) + print("✓ Async write successful!") + + # Wait a moment for the write to be visible + await asyncio.sleep(0.5) + + # Test async query + print("Testing async query...") + results = await node.query_async() + print(f"✓ Async query successful! Results: {results}") + + except Exception as e: + if skip_if_no_server: + print(f"⚠ Async test failed (server may not be running): {e}") + else: + raise + + print("\n✓ Async operations test completed!\n") + + +def main(): + """Run all tests.""" + print("\n" + "=" * 60) + print("InfluxDbNode Test Suite") + print("=" * 60 + "\n") + + # Synchronous tests (always run) + test_influxdb_node_validation() + test_flux_query_building() + + # Tests that require InfluxDB server + print("\n" + "-" * 60) + print("The following tests require InfluxDB on localhost:8086") + print("-" * 60 + "\n") + + try: + test_influxdb_write(skip_if_no_server=True) + test_influxdb_read(skip_if_no_server=True) + test_influxdb_pipeline_integration(skip_if_no_server=True) + asyncio.run(test_influxdb_async_operations(skip_if_no_server=True)) + except KeyboardInterrupt: + print("\nTests interrupted by user") + + print("=" * 60) + print("All tests completed!") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/backend/app/flow/test_mqtt_node.py b/backend/app/flow/test_mqtt_node.py new file mode 100644 index 0000000..70ae975 --- /dev/null +++ b/backend/app/flow/test_mqtt_node.py @@ -0,0 +1,334 @@ +""" +Test script for MqttNode functionality. + +This script demonstrates both subscriber (trigger) and publisher (sender) modes +of MqttNode. Requires an MQTT broker running (e.g., mosquitto). + +To run a local mosquitto broker: + docker run -it -p 1883:1883 eclipse-mosquitto mosquitto -c /mosquitto-no-auth.conf + +Or install locally: + sudo apt install mosquitto mosquitto-clients + sudo systemctl start mosquitto +""" + +import asyncio +import sys +import json +from pathlib import Path + +# Add flow directory to path for imports +sys.path.insert(0, str(Path(__file__).parent)) + +from nodes import MqttNode, Node +from pipeline import Pipeline +from state import MemoryState +from util import Message + +host = "127.0.0.1" + + +def test_mqtt_node_modes(): + """Test MqttNode mode detection.""" + print("=" * 60) + print("Testing MqttNode Mode Detection") + print("=" * 60) + + # Subscriber mode: only provides + subscriber = MqttNode( + topic="sensors/temperature", + provides=[Message(name="temperature", dtype=float)], + params={"broker_host": host}, + ) + print(f"✓ Subscriber node mode: {subscriber.mode}") + assert subscriber.mode == MqttNode.Mode.SUBSCRIBER + + # Publisher mode: has requires + publisher = MqttNode( + topic="actuators/hvac", + requires=[Message(name="target_temp", dtype=float)], + params={"broker_host": host}, + ) + print(f"✓ Publisher node mode: {publisher.mode}") + assert publisher.mode == MqttNode.Mode.PUBLISHER + + # Both requires and provides = publisher mode + hybrid = MqttNode( + topic="devices/thermostat", + requires=[Message(name="input", dtype=float)], + provides=[Message(name="output", dtype=float)], + params={"broker_host": host}, + ) + print(f"✓ Hybrid node mode: {hybrid.mode}") + assert hybrid.mode == MqttNode.Mode.PUBLISHER + + # Error: neither requires nor provides + try: + invalid = MqttNode(topic="invalid/topic", params={}) + assert False, "Should have raised ValueError" + except ValueError as e: + print(f"✓ Correctly rejected invalid config: {e}") + + print("\n✓ All mode detection tests passed!\n") + + +def test_mqtt_node_config(): + """Test MqttNode configuration from params.""" + print("=" * 60) + print("Testing MqttNode Configuration") + print("=" * 60) + + node = MqttNode( + topic="test/topic", + provides=[Message(name="value", dtype=float)], + params={ + "broker_host": "mqtt.example.com", + "broker_port": 8883, + "username": "user", + "password": "secret", + "client_id": "test-client", + "qos": 2, + "retain": True, + "keepalive": 120, + }, + ) + + assert node.broker_host == "mqtt.example.com" + assert node.broker_port == 8883 + assert node.username == "user" + assert node.password == "secret" + assert node.client_id == "test-client" + assert node.qos == 2 + assert node.retain is True + assert node.keepalive == 120 + + print(f"✓ broker_host: {node.broker_host}") + print(f"✓ broker_port: {node.broker_port}") + print(f"✓ username: {node.username}") + print(f"✓ qos: {node.qos}") + print(f"✓ retain: {node.retain}") + print(f"✓ keepalive: {node.keepalive}") + + print("\n✓ Configuration test passed!\n") + + +async def test_mqtt_publisher(): + """Test MQTT publisher node (requires broker).""" + print("=" * 60) + print("Testing MQTT Publisher") + print("=" * 60) + + publisher = MqttNode( + topic="test/fluksio/output", + requires=[ + Message(name="temperature", dtype=float), + Message(name="humidity", dtype=float), + ], + params={ + "broker_host": host, + "broker_port": 1883, + "qos": 1, + }, + name="test_publisher", + ) + + print(f"Publisher node created: {publisher.name}") + print(f" Topic: {publisher.topic}") + print(f" Mode: {publisher.mode}") + print(f" Broker: {publisher.broker_host}:{publisher.broker_port}") + + # Try to publish (will fail if no broker is running) + try: + # Create a simple pipeline to bind the node + state = MemoryState() + pipeline = Pipeline(nodes=[publisher], state=state, max_workers=1) + + # Directly call the publish method + await publisher._publish_message({"temperature": 25.5, "humidity": 60.0}) + print("✓ Published message successfully!") + except Exception as e: + print(f"⚠ Could not publish (broker may not be running): {e}") + + print("\n✓ Publisher test completed!\n") + + +async def test_mqtt_subscriber(): + """Test MQTT subscriber node (requires broker).""" + print("=" * 60) + print("Testing MQTT Subscriber") + print("=" * 60) + + # Create a subscriber node + subscriber = MqttNode( + topic="test/fluksio/input", + provides=[ + Message(name="value", dtype=float), + Message(name="unit", dtype=str), + ], + params={ + "broker_host": host, + "broker_port": 1883, + }, + name="test_subscriber", + ) + + # Create a processing node that will be triggered + received_data = [] + + def process_data(params, value=0.0, unit="unknown", **kwargs): + print(f"[processor] Received: value={value}, unit={unit}") + received_data.append({"value": value, "unit": unit}) + return {"processed": True} + + processor = Node( + f=process_data, + requires=[ + Message(name="value", dtype=float), + Message(name="unit", dtype=str), + ], + provides=[Message(name="processed", dtype=bool)], + params={}, + name="data_processor", + ) + + # Build pipeline + state = MemoryState() + pipeline = Pipeline( + nodes=[subscriber, processor], + state=state, + max_workers=2, + ) + + print(f"Subscriber node created: {subscriber.name}") + print(f" Topic: {subscriber.topic}") + print(f" Mode: {subscriber.mode}") + + # Try to start subscription + try: + await subscriber.start_subscription() + print("✓ Subscription started!") + + # Wait a bit for potential messages + print(" Listening for 3 seconds...") + await asyncio.sleep(3) + + # Stop subscription + await subscriber.stop_subscription() + print("✓ Subscription stopped!") + + if received_data: + print(f"✓ Received {len(received_data)} messages") + else: + print(" No messages received (publish to test/fluksio/input to test)") + + except Exception as e: + print(f"⚠ Could not subscribe (broker may not be running): {e}") + + print("\n✓ Subscriber test completed!\n") + + +async def test_mqtt_integration(): + """Test full MQTT pub/sub integration (requires broker).""" + print("=" * 60) + print("Testing MQTT Integration (Pub/Sub)") + print("=" * 60) + + import aiomqtt + + topic = "test/fluksio/integration" + + # Create subscriber + subscriber = MqttNode( + topic=topic, + provides=[Message(name="sensor_value", dtype=float)], + params={"broker_host": host}, + name="integration_subscriber", + ) + + # Track received messages + received_values = [] + + def track_value(params, sensor_value=0.0, **kwargs): + print(f"[tracker] Received sensor_value={sensor_value}") + received_values.append(sensor_value) + return {"tracked": sensor_value} + + tracker = Node( + f=track_value, + requires=[Message(name="sensor_value", dtype=float)], + provides=[Message(name="tracked", dtype=float)], + params={}, + name="value_tracker", + ) + + # Build pipeline + state = MemoryState() + pipeline = Pipeline( + nodes=[subscriber, tracker], + state=state, + max_workers=2, + ) + + try: + # Start subscriber + await subscriber.start_subscription() + print("✓ Subscriber started") + + # Publish some test messages + async with aiomqtt.Client(hostname=host) as client: + for i in range(3): + value = 20.0 + i * 5 + payload = json.dumps({"sensor_value": value}) + await client.publish(topic, payload) + print(f" Published: {payload}") + await asyncio.sleep(0.5) + + # Wait for messages to be processed + await asyncio.sleep(1) + + # Stop subscriber + await subscriber.stop_subscription() + print("✓ Subscriber stopped") + + # Check results + print(f"\nReceived values: {received_values}") + if len(received_values) == 3: + print("✓ All messages received and processed!") + else: + print(f"⚠ Expected 3 messages, got {len(received_values)}") + + except Exception as e: + print(f"⚠ Integration test failed (broker may not be running): {e}") + + print("\n✓ Integration test completed!\n") + + +def main(): + """Run all tests.""" + print("\n" + "=" * 60) + print("MqttNode Test Suite") + print("=" * 60 + "\n") + + # Synchronous tests (always run) + test_mqtt_node_modes() + test_mqtt_node_config() + + # Async tests (require broker) + print("\n" + "-" * 60) + print(f"The following tests require an MQTT broker on {host}:1883") + print("-" * 60 + "\n") + + try: + asyncio.run(test_mqtt_publisher()) + asyncio.run(test_mqtt_subscriber()) + asyncio.run(test_mqtt_integration()) + except KeyboardInterrupt: + print("\nTests interrupted by user") + + print("=" * 60) + print("All tests completed!") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/backend/app/flow/test_synchronous_nodes.py b/backend/app/flow/test_synchronous_nodes.py new file mode 100644 index 0000000..fd2cdb3 --- /dev/null +++ b/backend/app/flow/test_synchronous_nodes.py @@ -0,0 +1,512 @@ +""" +Test synchronous node execution in pipelines. + +This module tests the "synchronous" flag which ensures a node only executes +when ALL its inputs have changed since the last execution. + +Synchronous nodes are useful for: +- Aggregation nodes that need all inputs to be "fresh" +- Nodes that should only process complete "batches" of data +- Avoiding redundant executions when multiple inputs update at different rates +""" + +import sys +import threading +import time +from pathlib import Path + +# Add flow directory to path for imports +sys.path.insert(0, str(Path(__file__).parent)) + +from state import MemoryState, RedisState +from pipeline import Pipeline +from nodes import Node +from util import Message + + +def test_synchronous_basic(): + """Test basic synchronous node behavior.""" + print("=" * 60) + print("Testing Basic Synchronous Node Behavior") + print("=" * 60) + + execution_log = [] + + def sensor_a(params, **kwargs): + value = params.get("value", 1.0) + execution_log.append(f"sensor_a -> {value}") + return {"temp_a": value} + + def sensor_b(params, **kwargs): + value = params.get("value", 2.0) + execution_log.append(f"sensor_b -> {value}") + return {"temp_b": value} + + def sync_processor(params, temp_a=0, temp_b=0, **kwargs): + result = temp_a + temp_b + execution_log.append(f"run_sync_processor({temp_a}, {temp_b}) -> {result}") + return {"combined": result} + + def async_processor(params, temp_a=0, temp_b=0, **kwargs): + result = temp_a * temp_b + execution_log.append(f"run_async_processor({temp_a}, {temp_b}) -> {result}") + return {"product": result} + + # Create nodes + node_a = Node( + f=sensor_a, + requires=[], + provides=[Message(name="temp_a", dtype=float)], + params={"value": 10.0}, + name="sensor_a", + ) + + node_b = Node( + f=sensor_b, + requires=[], + provides=[Message(name="temp_b", dtype=float)], + params={"value": 20.0}, + name="sensor_b", + ) + + # Synchronous node - only executes when BOTH inputs change + sync_node = Node( + f=sync_processor, + requires=[ + Message(name="temp_a", dtype=float), + Message(name="temp_b", dtype=float), + ], + provides=[Message(name="combined", dtype=float)], + params={"synchronous": True}, + name="sync_processor", + ) + + # Non-synchronous node - executes whenever any input is available + async_node = Node( + f=async_processor, + requires=[ + Message(name="temp_a", dtype=float), + Message(name="temp_b", dtype=float), + ], + provides=[Message(name="product", dtype=float)], + params={"synchronous": False}, + name="async_processor", + ) + + # Build pipeline + state = MemoryState() + pipeline = Pipeline( + nodes=[node_a, node_b, sync_node, async_node], + state=state, + max_workers=1, # Single worker for predictable ordering + ) + + print("\n--- Triggering sensor_a (first time) ---") + execution_log.clear() + node_a.inject({}) + print(f"Execution log: {execution_log}") + print(f"State: temp_a={state.get('temp_a')}, temp_b={state.get('temp_b')}") + + # Neither processor should run yet (temp_b missing) + assert "run_async_processor" not in str( + execution_log + ), "async_processor ran too early" + assert "run_sync_processor" not in str( + execution_log + ), "sync_processor ran too early" + print("✓ Neither processor ran (temp_b not yet available)") + + print("\n--- Triggering sensor_b (first time) ---") + execution_log.clear() + node_b.inject({}) + print(f"Execution log: {execution_log}") + + # Both should run now (first time both inputs are available) + assert "run_async_processor" in str( + execution_log + ), "async_processor should have run" + assert "run_sync_processor" in str(execution_log), "sync_processor should have run" + print("✓ Both processors ran (first time both inputs available)") + + print("\n--- Triggering sensor_a (second time) ---") + node_a.params["value"] = 15.0 + execution_log.clear() + node_a.inject({}) + print(f"Execution log: {execution_log}") + + # async_processor SHOULD run (any input change triggers it) + # sync_processor should NOT run (only temp_a changed, not temp_b) + assert "run_async_processor" in str( + execution_log + ), "async_processor should have run" + assert "run_sync_processor" not in str( + execution_log + ), "sync_processor should NOT have run (only temp_a changed)" + print("✓ Only async_processor ran (temp_b hasn't changed)") + + print("\n--- Triggering sensor_b (second time) ---") + node_b.params["value"] = 25.0 + execution_log.clear() + node_b.inject({}) + print(f"Execution log: {execution_log}") + + # Both should run now (both inputs have changed since last sync execution) + assert "run_async_processor" in str( + execution_log + ), "async_processor should have run" + assert "run_sync_processor" in str( + execution_log + ), "sync_processor should have run (both inputs changed)" + print("✓ Both processors ran (both inputs have changed)") + + # Verify final state values + assert ( + pipeline.state.get("combined") == 40.0 + ), f"Expected combined=40.0, got {pipeline.state.get('combined')}" + assert ( + pipeline.state.get("product") == 375.0 + ), f"Expected product=375.0, got {pipeline.state.get('product')}" + print( + f"\n✓ Final state: combined={pipeline.state.get('combined')}, product={pipeline.state.get('product')}" + ) + + print("\n✓ Basic synchronous test passed!\n") + + +def test_synchronous_multiple_triggers(): + """Test that synchronous nodes handle rapid successive triggers correctly.""" + print("=" * 60) + print("Testing Synchronous Node with Multiple Rapid Triggers") + print("=" * 60) + + sync_execution_count = {"count": 0} + async_execution_count = {"count": 0} + + def source_x(params, **kwargs): + return {"x": params.get("value", 1)} + + def source_y(params, **kwargs): + return {"y": params.get("value", 2)} + + def sync_consumer(params, x=0, y=0, **kwargs): + sync_execution_count["count"] += 1 + return {"sync_out": x + y} + + def async_consumer(params, x=0, y=0, **kwargs): + async_execution_count["count"] += 1 + return {"async_out": x * y} + + node_x = Node( + f=source_x, + requires=[], + provides=[Message(name="x", dtype=int)], + params={"value": 1}, + name="source_x", + ) + + node_y = Node( + f=source_y, + requires=[], + provides=[Message(name="y", dtype=int)], + params={"value": 1}, + name="source_y", + ) + + sync_node = Node( + f=sync_consumer, + requires=[Message(name="x", dtype=int), Message(name="y", dtype=int)], + provides=[Message(name="sync_out", dtype=int)], + params={"synchronous": True}, + name="sync_consumer", + ) + + async_node = Node( + f=async_consumer, + requires=[Message(name="x", dtype=int), Message(name="y", dtype=int)], + provides=[Message(name="async_out", dtype=int)], + params={"synchronous": False}, + name="async_consumer", + ) + + state = MemoryState() + pipeline = Pipeline( + nodes=[node_x, node_y, sync_node, async_node], + state=state, + max_workers=1, + ) + + # Initial trigger to get both inputs + node_x.inject({}) + node_y.inject({}) + + initial_sync = sync_execution_count["count"] + initial_async = async_execution_count["count"] + + print(f"After initial triggers: sync={initial_sync}, async={initial_async}") + assert initial_sync == 1, "sync_consumer should have run once initially" + assert initial_async == 1, "async_consumer should have run once initially" + + # Now trigger X multiple times without triggering Y + print("\nTriggering source_x 5 times without changing source_y...") + for i in range(5): + node_x.params["value"] = 10 + i + node_x.inject({}) + + final_sync = sync_execution_count["count"] + final_async = async_execution_count["count"] + + print(f"After 5 more X triggers: sync={final_sync}, async={final_async}") + + # sync_consumer should NOT have run again (Y didn't change) + assert ( + final_sync == initial_sync + ), f"sync_consumer should still be at {initial_sync}, got {final_sync}" + # async_consumer should have run 5 more times + assert ( + final_async == initial_async + 5 + ), f"async_consumer should be at {initial_async + 5}, got {final_async}" + + print("✓ Synchronous node correctly waited for both inputs to change") + + # Now trigger Y once - sync should run + print("\nTriggering source_y once...") + node_y.params["value"] = 100 + node_y.inject({}) + + after_y_sync = sync_execution_count["count"] + after_y_async = async_execution_count["count"] + + print(f"After Y trigger: sync={after_y_sync}, async={after_y_async}") + + assert after_y_sync == initial_sync + 1, f"sync_consumer should have run once more" + assert after_y_async == final_async + 1, f"async_consumer should have run once more" + + print("\n✓ Multiple triggers test passed!\n") + + +def test_synchronous_race_condition(): + """Test that synchronous nodes handle concurrent triggers correctly.""" + print("=" * 60) + print("Testing Synchronous Node Race Condition Handling") + print("=" * 60) + + execution_count = {"sync": 0, "async": 0} + execution_lock = threading.Lock() + + def source_a(params, **kwargs): + return {"data_a": params.get("value", 1)} + + def source_b(params, **kwargs): + return {"data_b": params.get("value", 2)} + + def sync_consumer(params, data_a=0, data_b=0, **kwargs): + with execution_lock: + execution_count["sync"] += 1 + time.sleep(0.01) # Small delay to increase chance of race conditions + return {"sync_result": data_a + data_b} + + def async_consumer(params, data_a=0, data_b=0, **kwargs): + with execution_lock: + execution_count["async"] += 1 + return {"async_result": data_a * data_b} + + node_a = Node( + f=source_a, + requires=[], + provides=[Message(name="data_a", dtype=int)], + params={"value": 10}, + name="source_a", + ) + + node_b = Node( + f=source_b, + requires=[], + provides=[Message(name="data_b", dtype=int)], + params={"value": 20}, + name="source_b", + ) + + sync_node = Node( + f=sync_consumer, + requires=[Message(name="data_a", dtype=int), Message(name="data_b", dtype=int)], + provides=[Message(name="sync_result", dtype=int)], + params={"synchronous": True}, + name="sync_consumer", + ) + + async_node = Node( + f=async_consumer, + requires=[Message(name="data_a", dtype=int), Message(name="data_b", dtype=int)], + provides=[Message(name="async_result", dtype=int)], + params={"synchronous": False}, + name="async_consumer", + ) + + state = MemoryState() + pipeline = Pipeline( + nodes=[node_a, node_b, sync_node, async_node], + state=state, + max_workers=4, # Multiple workers for concurrency + ) + + # Trigger both sensors multiple times concurrently + num_rounds = 5 + print(f"\nTriggering both sensors {num_rounds} times concurrently...") + + threads = [] + for i in range(num_rounds): + node_a.params["value"] = 10 + i + node_b.params["value"] = 20 + i + + t1 = threading.Thread(target=lambda: node_a.inject({})) + t2 = threading.Thread(target=lambda: node_b.inject({})) + threads.extend([t1, t2]) + t1.start() + t2.start() + + # Wait for all threads + for t in threads: + t.join() + + # Give pipeline time to process + time.sleep(0.5) + + print(f"\nExecution counts:") + print(f" sync_consumer: {execution_count['sync']}") + print(f" async_consumer: {execution_count['async']}") + + # sync_consumer should execute at most num_rounds times + # (could be less if some triggers happened before the other input updated) + assert ( + execution_count["sync"] <= num_rounds + 1 + ), f"sync_consumer ran too many times: {execution_count['sync']} (expected <= {num_rounds + 1})" + + # async_consumer will run more frequently + assert ( + execution_count["async"] >= execution_count["sync"] + ), "async_consumer should run at least as often as sync_consumer" + + print( + f"\n✓ sync_consumer ran {execution_count['sync']} times (max expected: {num_rounds + 1})" + ) + print(f"✓ async_consumer ran {execution_count['async']} times") + print("\n✓ Race condition test passed!\n") + + +def test_state_backend_atomic_operations(): + """Test the atomic operations used for synchronous node support.""" + print("=" * 60) + print("Testing State Backend Atomic Operations") + print("=" * 60) + + state = MemoryState() + + # Test increment + print("\nTesting increment...") + assert state.increment("counter") == 1 + assert state.increment("counter") == 2 + assert state.increment("counter") == 3 + print("✓ increment works correctly") + + # Test get_multi + print("\nTesting get_multi...") + state.set("a", 1) + state.set("b", 2) + state.set("c", 3) + result = state.get_multi(["a", "b", "c", "missing"]) + assert result == {"a": 1, "b": 2, "c": 3, "missing": None} + print("✓ get_multi works correctly") + + # Test compare_and_swap_multi - success case + print("\nTesting compare_and_swap_multi (success)...") + success = state.compare_and_swap_multi( + expected={"a": 1, "b": 2}, updates={"a": 10, "b": 20, "new_key": 100} + ) + assert success, "compare_and_swap_multi should succeed" + assert state.get("a") == 10 + assert state.get("b") == 20 + assert state.get("new_key") == 100 + print("✓ compare_and_swap_multi succeeded and applied updates") + + # Test compare_and_swap_multi - failure case + print("\nTesting compare_and_swap_multi (failure)...") + success = state.compare_and_swap_multi( + expected={"a": 1, "b": 20}, updates={"a": 999, "b": 999} # a is now 10, not 1 + ) + assert not success, "compare_and_swap_multi should fail" + assert state.get("a") == 10, "a should be unchanged" + assert state.get("b") == 20, "b should be unchanged" + print("✓ compare_and_swap_multi correctly rejected mismatched expectation") + + print("\n✓ State backend atomic operations test passed!\n") + + +def test_concurrent_compare_and_swap(): + """Test that compare_and_swap_multi handles concurrent access correctly.""" + print("=" * 60) + print("Testing Concurrent Compare-and-Swap") + print("=" * 60) + + state = MemoryState() + state.set("version", 0) + + success_count = {"count": 0} + failure_count = {"count": 0} + count_lock = threading.Lock() + + def try_swap(thread_id: int): + """Try to atomically increment the version.""" + for _ in range(10): + current = state.get("version") + success = state.compare_and_swap_multi( + expected={"version": current}, updates={"version": current + 1} + ) + with count_lock: + if success: + success_count["count"] += 1 + else: + failure_count["count"] += 1 + + # Start multiple threads trying to increment + threads = [threading.Thread(target=try_swap, args=(i,)) for i in range(5)] + for t in threads: + t.start() + for t in threads: + t.join() + + final_version = state.get("version") + total_attempts = success_count["count"] + failure_count["count"] + + print(f"Total attempts: {total_attempts}") + print(f"Successful swaps: {success_count['count']}") + print(f"Failed swaps (race lost): {failure_count['count']}") + print(f"Final version: {final_version}") + + # The final version should equal the number of successful swaps + assert ( + final_version == success_count["count"] + ), f"Version mismatch: {final_version} != {success_count['count']}" + + print("\n✓ Concurrent compare-and-swap test passed!\n") + + +def main(): + """Run all synchronous node tests.""" + print("\n" + "=" * 60) + print("Synchronous Node Test Suite") + print("=" * 60 + "\n") + + # Run all tests + test_state_backend_atomic_operations() + test_concurrent_compare_and_swap() + test_synchronous_basic() + test_synchronous_multiple_triggers() + test_synchronous_race_condition() + + print("=" * 60) + print("All synchronous node tests completed!") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/backend/app/flow/util.py b/backend/app/flow/util.py new file mode 100644 index 0000000..3ae5185 --- /dev/null +++ b/backend/app/flow/util.py @@ -0,0 +1,47 @@ +from pydantic import BaseModel, Field, field_validator, ConfigDict +from typing import Any, Tuple, Type +import hashlib + + +class Message(BaseModel): + """Defines a message type with validation constraints.""" + + model_config = ConfigDict(frozen=True) # Immutable for hashability + + name: str + dtype: Type = float + shape: Tuple[int, ...] = () + vrange: Tuple[float, float] = (0.0, 1.0) + + @property + def id(self) -> str: + return hashlib.sha256(self.name.encode()).hexdigest()[:12] + + def check(self, value: Any) -> None: + if not isinstance(value, self.dtype): + raise TypeError( + f"{self.name}: expected {self.dtype.__name__}, got {type(value).__name__}" + ) + + def __repr__(self) -> str: + return f"Message({self.name})" + + def __hash__(self) -> int: + return hash(self.name) + + +class NodeParams(BaseModel): + """Parameters passed to node functions.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + params: dict[str, Any] = Field(default_factory=dict) + + def __getitem__(self, key: str) -> Any: + return self.params[key] + + def __contains__(self, key: str) -> bool: + return key in self.params + + def get(self, key: str, default: Any = None) -> Any: + return self.params.get(key, default) diff --git a/backend/app/initial_data.py b/backend/app/initial_data.py new file mode 100644 index 0000000..d806c3d --- /dev/null +++ b/backend/app/initial_data.py @@ -0,0 +1,23 @@ +import logging + +from sqlmodel import Session + +from app.core.db import engine, init_db + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def init() -> None: + with Session(engine) as session: + init_db(session) + + +def main() -> None: + logger.info("Creating initial data") + init() + logger.info("Initial data created") + + +if __name__ == "__main__": + main() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..9a95801 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,33 @@ +import sentry_sdk +from fastapi import FastAPI +from fastapi.routing import APIRoute +from starlette.middleware.cors import CORSMiddleware + +from app.api.main import api_router +from app.core.config import settings + + +def custom_generate_unique_id(route: APIRoute) -> str: + return f"{route.tags[0]}-{route.name}" + + +if settings.SENTRY_DSN and settings.ENVIRONMENT != "local": + sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True) + +app = FastAPI( + title=settings.PROJECT_NAME, + openapi_url=f"{settings.API_V1_STR}/openapi.json", + generate_unique_id_function=custom_generate_unique_id, +) + +# Set all CORS enabled origins +if settings.all_cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=settings.all_cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + +app.include_router(api_router, prefix=settings.API_V1_STR) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..b5132e0 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,129 @@ +import uuid +from datetime import datetime, timezone + +from pydantic import EmailStr +from sqlalchemy import DateTime +from sqlmodel import Field, Relationship, SQLModel + + +def get_datetime_utc() -> datetime: + return datetime.now(timezone.utc) + + +# Shared properties +class UserBase(SQLModel): + email: EmailStr = Field(unique=True, index=True, max_length=255) + is_active: bool = True + is_superuser: bool = False + full_name: str | None = Field(default=None, max_length=255) + + +# Properties to receive via API on creation +class UserCreate(UserBase): + password: str = Field(min_length=8, max_length=128) + + +class UserRegister(SQLModel): + email: EmailStr = Field(max_length=255) + password: str = Field(min_length=8, max_length=128) + full_name: str | None = Field(default=None, max_length=255) + + +# Properties to receive via API on update, all are optional +class UserUpdate(UserBase): + email: EmailStr | None = Field(default=None, max_length=255) # type: ignore + password: str | None = Field(default=None, min_length=8, max_length=128) + + +class UserUpdateMe(SQLModel): + full_name: str | None = Field(default=None, max_length=255) + email: EmailStr | None = Field(default=None, max_length=255) + + +class UpdatePassword(SQLModel): + current_password: str = Field(min_length=8, max_length=128) + new_password: str = Field(min_length=8, max_length=128) + + +# Database model, database table inferred from class name +class User(UserBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + hashed_password: str + created_at: datetime | None = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore + ) + items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) + + +# Properties to return via API, id is always required +class UserPublic(UserBase): + id: uuid.UUID + created_at: datetime | None = None + + +class UsersPublic(SQLModel): + data: list[UserPublic] + count: int + + +# Shared properties +class ItemBase(SQLModel): + title: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=255) + + +# Properties to receive on item creation +class ItemCreate(ItemBase): + pass + + +# Properties to receive on item update +class ItemUpdate(ItemBase): + title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore + + +# Database model, database table inferred from class name +class Item(ItemBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + created_at: datetime | None = Field( + default_factory=get_datetime_utc, + sa_type=DateTime(timezone=True), # type: ignore + ) + owner_id: uuid.UUID = Field( + foreign_key="user.id", nullable=False, ondelete="CASCADE" + ) + owner: User | None = Relationship(back_populates="items") + + +# Properties to return via API, id is always required +class ItemPublic(ItemBase): + id: uuid.UUID + owner_id: uuid.UUID + created_at: datetime | None = None + + +class ItemsPublic(SQLModel): + data: list[ItemPublic] + count: int + + +# Generic message +class Message(SQLModel): + message: str + + +# JSON payload containing access token +class Token(SQLModel): + access_token: str + token_type: str = "bearer" + + +# Contents of JWT token +class TokenPayload(SQLModel): + sub: str | None = None + + +class NewPassword(SQLModel): + token: str + new_password: str = Field(min_length=8, max_length=128) diff --git a/backend/app/tests_pre_start.py b/backend/app/tests_pre_start.py new file mode 100644 index 0000000..0ce6045 --- /dev/null +++ b/backend/app/tests_pre_start.py @@ -0,0 +1,39 @@ +import logging + +from sqlalchemy import Engine +from sqlmodel import Session, select +from tenacity import after_log, before_log, retry, stop_after_attempt, wait_fixed + +from app.core.db import engine + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +max_tries = 60 * 5 # 5 minutes +wait_seconds = 1 + + +@retry( + stop=stop_after_attempt(max_tries), + wait=wait_fixed(wait_seconds), + before=before_log(logger, logging.INFO), + after=after_log(logger, logging.WARN), +) +def init(db_engine: Engine) -> None: + try: + # Try to create session to check if DB is awake + with Session(db_engine) as session: + session.exec(select(1)) + except Exception as e: + logger.error(e) + raise e + + +def main() -> None: + logger.info("Initializing service") + init(engine) + logger.info("Service finished initializing") + + +if __name__ == "__main__": + main() diff --git a/backend/app/utils.py b/backend/app/utils.py new file mode 100644 index 0000000..ac029f6 --- /dev/null +++ b/backend/app/utils.py @@ -0,0 +1,123 @@ +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + +import emails # type: ignore +import jwt +from jinja2 import Template +from jwt.exceptions import InvalidTokenError + +from app.core import security +from app.core.config import settings + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +@dataclass +class EmailData: + html_content: str + subject: str + + +def render_email_template(*, template_name: str, context: dict[str, Any]) -> str: + template_str = ( + Path(__file__).parent / "email-templates" / "build" / template_name + ).read_text() + html_content = Template(template_str).render(context) + return html_content + + +def send_email( + *, + email_to: str, + subject: str = "", + html_content: str = "", +) -> None: + assert settings.emails_enabled, "no provided configuration for email variables" + message = emails.Message( + subject=subject, + html=html_content, + mail_from=(settings.EMAILS_FROM_NAME, settings.EMAILS_FROM_EMAIL), + ) + smtp_options = {"host": settings.SMTP_HOST, "port": settings.SMTP_PORT} + if settings.SMTP_TLS: + smtp_options["tls"] = True + elif settings.SMTP_SSL: + smtp_options["ssl"] = True + if settings.SMTP_USER: + smtp_options["user"] = settings.SMTP_USER + if settings.SMTP_PASSWORD: + smtp_options["password"] = settings.SMTP_PASSWORD + response = message.send(to=email_to, smtp=smtp_options) + logger.info(f"send email result: {response}") + + +def generate_test_email(email_to: str) -> EmailData: + project_name = settings.PROJECT_NAME + subject = f"{project_name} - Test email" + html_content = render_email_template( + template_name="test_email.html", + context={"project_name": settings.PROJECT_NAME, "email": email_to}, + ) + return EmailData(html_content=html_content, subject=subject) + + +def generate_reset_password_email(email_to: str, email: str, token: str) -> EmailData: + project_name = settings.PROJECT_NAME + subject = f"{project_name} - Password recovery for user {email}" + link = f"{settings.FRONTEND_HOST}/reset-password?token={token}" + html_content = render_email_template( + template_name="reset_password.html", + context={ + "project_name": settings.PROJECT_NAME, + "username": email, + "email": email_to, + "valid_hours": settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS, + "link": link, + }, + ) + return EmailData(html_content=html_content, subject=subject) + + +def generate_new_account_email( + email_to: str, username: str, password: str +) -> EmailData: + project_name = settings.PROJECT_NAME + subject = f"{project_name} - New account for user {username}" + html_content = render_email_template( + template_name="new_account.html", + context={ + "project_name": settings.PROJECT_NAME, + "username": username, + "password": password, + "email": email_to, + "link": settings.FRONTEND_HOST, + }, + ) + return EmailData(html_content=html_content, subject=subject) + + +def generate_password_reset_token(email: str) -> str: + delta = timedelta(hours=settings.EMAIL_RESET_TOKEN_EXPIRE_HOURS) + now = datetime.now(timezone.utc) + expires = now + delta + exp = expires.timestamp() + encoded_jwt = jwt.encode( + {"exp": exp, "nbf": now, "sub": email}, + settings.SECRET_KEY, + algorithm=security.ALGORITHM, + ) + return encoded_jwt + + +def verify_password_reset_token(token: str) -> str | None: + try: + decoded_token = jwt.decode( + token, settings.SECRET_KEY, algorithms=[security.ALGORITHM] + ) + return str(decoded_token["sub"]) + except InvalidTokenError: + return None diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..95f76be --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,86 @@ +[project] +name = "app" +version = "0.1.0" +description = "" +requires-python = ">=3.10,<4.0" +dependencies = [ + "fastapi[standard]<1.0.0,>=0.114.2", + "python-multipart<1.0.0,>=0.0.7", + "email-validator<3.0.0.0,>=2.1.0.post1", + "tenacity<9.0.0,>=8.2.3", + "pydantic>2.0", + "emails<1.0,>=0.6", + "jinja2<4.0.0,>=3.1.4", + "alembic<2.0.0,>=1.12.1", + "httpx<1.0.0,>=0.25.1", + "psycopg[binary]<4.0.0,>=3.1.13", + "sqlmodel<1.0.0,>=0.0.21", + "pydantic-settings<3.0.0,>=2.2.1", + "sentry-sdk[fastapi]<2.0.0,>=1.40.6", + "pyjwt<3.0.0,>=2.8.0", + "pwdlib[argon2,bcrypt]>=0.3.0", + "networkx>=3.4.2", + "numpy>=2.2.6", + "matplotlib>=3.10.8", + "redis>=7.1.0", + "scipy>=1.15.3", + "watchfiles>=1.0.0", + "aiomqtt>=2.0.0", + "influxdb-client[async]>=1.40.0", + "croniter>=1.3.0", +] + +[dependency-groups] +dev = [ + "pytest<8.0.0,>=7.4.3", + "mypy<2.0.0,>=1.8.0", + "ruff<1.0.0,>=0.2.2", + "prek>=0.2.24,<1.0.0", + "coverage<8.0.0,>=7.4.3", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.mypy] +strict = true +exclude = ["venv", ".venv", "alembic"] + +[tool.ruff] +target-version = "py310" +exclude = ["alembic"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG001", # unused arguments in functions + "T201", # print statements are not allowed +] +ignore = [ + "E501", # line too long, handled by black + "B008", # do not perform function calls in argument defaults + "W191", # indentation contains tabs + "B904", # Allow raising exceptions without from e, for HTTPException +] + +[tool.ruff.lint.pyupgrade] +# Preserve types, even if a file imports `from __future__ import annotations`. +keep-runtime-typing = true + +[tool.coverage.run] +source = ["app"] +dynamic_context = "test_function" + +[tool.coverage.report] +show_missing = true +sort = "-Cover" + +[tool.coverage.html] +show_contexts = true diff --git a/backend/scripts/format.sh b/backend/scripts/format.sh new file mode 100755 index 0000000..7be2f81 --- /dev/null +++ b/backend/scripts/format.sh @@ -0,0 +1,5 @@ +#!/bin/sh -e +set -x + +ruff check app scripts --fix +ruff format app scripts diff --git a/backend/scripts/lint.sh b/backend/scripts/lint.sh new file mode 100644 index 0000000..b3b2b4e --- /dev/null +++ b/backend/scripts/lint.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e +set -x + +mypy app +ruff check app +ruff format app --check diff --git a/backend/scripts/prestart.sh b/backend/scripts/prestart.sh new file mode 100644 index 0000000..1b395d5 --- /dev/null +++ b/backend/scripts/prestart.sh @@ -0,0 +1,13 @@ +#! /usr/bin/env bash + +set -e +set -x + +# Let the DB start +python app/backend_pre_start.py + +# Run migrations +alembic upgrade head + +# Create initial data in DB +python app/initial_data.py diff --git a/backend/scripts/test.sh b/backend/scripts/test.sh new file mode 100755 index 0000000..38c3e89 --- /dev/null +++ b/backend/scripts/test.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -e +set -x + +coverage run -m pytest tests/ +coverage report +coverage html --title "${@-coverage}" diff --git a/backend/scripts/tests-start.sh b/backend/scripts/tests-start.sh new file mode 100644 index 0000000..89dcb0d --- /dev/null +++ b/backend/scripts/tests-start.sh @@ -0,0 +1,7 @@ +#! /usr/bin/env bash +set -e +set -x + +python app/tests_pre_start.py + +bash scripts/test.sh "$@" diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/api/__init__.py b/backend/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/api/routes/__init__.py b/backend/tests/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/api/routes/test_items.py b/backend/tests/api/routes/test_items.py new file mode 100644 index 0000000..3e82cd0 --- /dev/null +++ b/backend/tests/api/routes/test_items.py @@ -0,0 +1,164 @@ +import uuid + +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app.core.config import settings +from tests.utils.item import create_random_item + + +def test_create_item( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + data = {"title": "Foo", "description": "Fighters"} + response = client.post( + f"{settings.API_V1_STR}/items/", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 200 + content = response.json() + assert content["title"] == data["title"] + assert content["description"] == data["description"] + assert "id" in content + assert "owner_id" in content + + +def test_read_item( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + item = create_random_item(db) + response = client.get( + f"{settings.API_V1_STR}/items/{item.id}", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + content = response.json() + assert content["title"] == item.title + assert content["description"] == item.description + assert content["id"] == str(item.id) + assert content["owner_id"] == str(item.owner_id) + + +def test_read_item_not_found( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + response = client.get( + f"{settings.API_V1_STR}/items/{uuid.uuid4()}", + headers=superuser_token_headers, + ) + assert response.status_code == 404 + content = response.json() + assert content["detail"] == "Item not found" + + +def test_read_item_not_enough_permissions( + client: TestClient, normal_user_token_headers: dict[str, str], db: Session +) -> None: + item = create_random_item(db) + response = client.get( + f"{settings.API_V1_STR}/items/{item.id}", + headers=normal_user_token_headers, + ) + assert response.status_code == 403 + content = response.json() + assert content["detail"] == "Not enough permissions" + + +def test_read_items( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + create_random_item(db) + create_random_item(db) + response = client.get( + f"{settings.API_V1_STR}/items/", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + content = response.json() + assert len(content["data"]) >= 2 + + +def test_update_item( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + item = create_random_item(db) + data = {"title": "Updated title", "description": "Updated description"} + response = client.put( + f"{settings.API_V1_STR}/items/{item.id}", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 200 + content = response.json() + assert content["title"] == data["title"] + assert content["description"] == data["description"] + assert content["id"] == str(item.id) + assert content["owner_id"] == str(item.owner_id) + + +def test_update_item_not_found( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + data = {"title": "Updated title", "description": "Updated description"} + response = client.put( + f"{settings.API_V1_STR}/items/{uuid.uuid4()}", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 404 + content = response.json() + assert content["detail"] == "Item not found" + + +def test_update_item_not_enough_permissions( + client: TestClient, normal_user_token_headers: dict[str, str], db: Session +) -> None: + item = create_random_item(db) + data = {"title": "Updated title", "description": "Updated description"} + response = client.put( + f"{settings.API_V1_STR}/items/{item.id}", + headers=normal_user_token_headers, + json=data, + ) + assert response.status_code == 403 + content = response.json() + assert content["detail"] == "Not enough permissions" + + +def test_delete_item( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + item = create_random_item(db) + response = client.delete( + f"{settings.API_V1_STR}/items/{item.id}", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + content = response.json() + assert content["message"] == "Item deleted successfully" + + +def test_delete_item_not_found( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + response = client.delete( + f"{settings.API_V1_STR}/items/{uuid.uuid4()}", + headers=superuser_token_headers, + ) + assert response.status_code == 404 + content = response.json() + assert content["detail"] == "Item not found" + + +def test_delete_item_not_enough_permissions( + client: TestClient, normal_user_token_headers: dict[str, str], db: Session +) -> None: + item = create_random_item(db) + response = client.delete( + f"{settings.API_V1_STR}/items/{item.id}", + headers=normal_user_token_headers, + ) + assert response.status_code == 403 + content = response.json() + assert content["detail"] == "Not enough permissions" diff --git a/backend/tests/api/routes/test_login.py b/backend/tests/api/routes/test_login.py new file mode 100644 index 0000000..96677a2 --- /dev/null +++ b/backend/tests/api/routes/test_login.py @@ -0,0 +1,191 @@ +from unittest.mock import patch + +from fastapi.testclient import TestClient +from pwdlib.hashers.bcrypt import BcryptHasher +from sqlmodel import Session + +from app.core.config import settings +from app.core.security import get_password_hash, verify_password +from app.crud import create_user +from app.models import User, UserCreate +from app.utils import generate_password_reset_token +from tests.utils.user import user_authentication_headers +from tests.utils.utils import random_email, random_lower_string + + +def test_get_access_token(client: TestClient) -> None: + login_data = { + "username": settings.FIRST_SUPERUSER, + "password": settings.FIRST_SUPERUSER_PASSWORD, + } + r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) + tokens = r.json() + assert r.status_code == 200 + assert "access_token" in tokens + assert tokens["access_token"] + + +def test_get_access_token_incorrect_password(client: TestClient) -> None: + login_data = { + "username": settings.FIRST_SUPERUSER, + "password": "incorrect", + } + r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) + assert r.status_code == 400 + + +def test_use_access_token( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + r = client.post( + f"{settings.API_V1_STR}/login/test-token", + headers=superuser_token_headers, + ) + result = r.json() + assert r.status_code == 200 + assert "email" in result + + +def test_recovery_password( + client: TestClient, normal_user_token_headers: dict[str, str] +) -> None: + with ( + patch("app.core.config.settings.SMTP_HOST", "smtp.example.com"), + patch("app.core.config.settings.SMTP_USER", "admin@example.com"), + ): + email = "test@example.com" + r = client.post( + f"{settings.API_V1_STR}/password-recovery/{email}", + headers=normal_user_token_headers, + ) + assert r.status_code == 200 + assert r.json() == { + "message": "If that email is registered, we sent a password recovery link" + } + + +def test_recovery_password_user_not_exits( + client: TestClient, normal_user_token_headers: dict[str, str] +) -> None: + email = "jVgQr@example.com" + r = client.post( + f"{settings.API_V1_STR}/password-recovery/{email}", + headers=normal_user_token_headers, + ) + # Should return 200 with generic message to prevent email enumeration attacks + assert r.status_code == 200 + assert r.json() == { + "message": "If that email is registered, we sent a password recovery link" + } + + +def test_reset_password(client: TestClient, db: Session) -> None: + email = random_email() + password = random_lower_string() + new_password = random_lower_string() + + user_create = UserCreate( + email=email, + full_name="Test User", + password=password, + is_active=True, + is_superuser=False, + ) + user = create_user(session=db, user_create=user_create) + token = generate_password_reset_token(email=email) + headers = user_authentication_headers(client=client, email=email, password=password) + data = {"new_password": new_password, "token": token} + + r = client.post( + f"{settings.API_V1_STR}/reset-password/", + headers=headers, + json=data, + ) + + assert r.status_code == 200 + assert r.json() == {"message": "Password updated successfully"} + + db.refresh(user) + verified, _ = verify_password(new_password, user.hashed_password) + assert verified + + +def test_reset_password_invalid_token( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + data = {"new_password": "changethis", "token": "invalid"} + r = client.post( + f"{settings.API_V1_STR}/reset-password/", + headers=superuser_token_headers, + json=data, + ) + response = r.json() + + assert "detail" in response + assert r.status_code == 400 + assert response["detail"] == "Invalid token" + + +def test_login_with_bcrypt_password_upgrades_to_argon2( + client: TestClient, db: Session +) -> None: + """Test that logging in with a bcrypt password hash upgrades it to argon2.""" + email = random_email() + password = random_lower_string() + + # Create a bcrypt hash directly (simulating legacy password) + bcrypt_hasher = BcryptHasher() + bcrypt_hash = bcrypt_hasher.hash(password) + assert bcrypt_hash.startswith("$2") # bcrypt hashes start with $2 + + user = User(email=email, hashed_password=bcrypt_hash, is_active=True) + db.add(user) + db.commit() + db.refresh(user) + + assert user.hashed_password.startswith("$2") + + login_data = {"username": email, "password": password} + r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) + assert r.status_code == 200 + tokens = r.json() + assert "access_token" in tokens + + db.refresh(user) + + # Verify the hash was upgraded to argon2 + assert user.hashed_password.startswith("$argon2") + + verified, updated_hash = verify_password(password, user.hashed_password) + assert verified + # Should not need another update since it's already argon2 + assert updated_hash is None + + +def test_login_with_argon2_password_keeps_hash(client: TestClient, db: Session) -> None: + """Test that logging in with an argon2 password hash does not update it.""" + email = random_email() + password = random_lower_string() + + # Create an argon2 hash (current default) + argon2_hash = get_password_hash(password) + assert argon2_hash.startswith("$argon2") + + # Create user with argon2 hash + user = User(email=email, hashed_password=argon2_hash, is_active=True) + db.add(user) + db.commit() + db.refresh(user) + + original_hash = user.hashed_password + + login_data = {"username": email, "password": password} + r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) + assert r.status_code == 200 + tokens = r.json() + assert "access_token" in tokens + + db.refresh(user) + + assert user.hashed_password == original_hash + assert user.hashed_password.startswith("$argon2") diff --git a/backend/tests/api/routes/test_private.py b/backend/tests/api/routes/test_private.py new file mode 100644 index 0000000..1e1f985 --- /dev/null +++ b/backend/tests/api/routes/test_private.py @@ -0,0 +1,26 @@ +from fastapi.testclient import TestClient +from sqlmodel import Session, select + +from app.core.config import settings +from app.models import User + + +def test_create_user(client: TestClient, db: Session) -> None: + r = client.post( + f"{settings.API_V1_STR}/private/users/", + json={ + "email": "pollo@listo.com", + "password": "password123", + "full_name": "Pollo Listo", + }, + ) + + assert r.status_code == 200 + + data = r.json() + + user = db.exec(select(User).where(User.id == data["id"])).first() + + assert user + assert user.email == "pollo@listo.com" + assert user.full_name == "Pollo Listo" diff --git a/backend/tests/api/routes/test_users.py b/backend/tests/api/routes/test_users.py new file mode 100644 index 0000000..9c4cdd5 --- /dev/null +++ b/backend/tests/api/routes/test_users.py @@ -0,0 +1,521 @@ +import uuid +from unittest.mock import patch + +from fastapi.testclient import TestClient +from sqlmodel import Session, select + +from app import crud +from app.core.config import settings +from app.core.security import verify_password +from app.models import User, UserCreate +from tests.utils.user import create_random_user +from tests.utils.utils import random_email, random_lower_string + + +def test_get_users_superuser_me( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + r = client.get(f"{settings.API_V1_STR}/users/me", headers=superuser_token_headers) + current_user = r.json() + assert current_user + assert current_user["is_active"] is True + assert current_user["is_superuser"] + assert current_user["email"] == settings.FIRST_SUPERUSER + + +def test_get_users_normal_user_me( + client: TestClient, normal_user_token_headers: dict[str, str] +) -> None: + r = client.get(f"{settings.API_V1_STR}/users/me", headers=normal_user_token_headers) + current_user = r.json() + assert current_user + assert current_user["is_active"] is True + assert current_user["is_superuser"] is False + assert current_user["email"] == settings.EMAIL_TEST_USER + + +def test_create_user_new_email( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + with ( + patch("app.utils.send_email", return_value=None), + patch("app.core.config.settings.SMTP_HOST", "smtp.example.com"), + patch("app.core.config.settings.SMTP_USER", "admin@example.com"), + ): + username = random_email() + password = random_lower_string() + data = {"email": username, "password": password} + r = client.post( + f"{settings.API_V1_STR}/users/", + headers=superuser_token_headers, + json=data, + ) + assert 200 <= r.status_code < 300 + created_user = r.json() + user = crud.get_user_by_email(session=db, email=username) + assert user + assert user.email == created_user["email"] + + +def test_get_existing_user_as_superuser( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + username = random_email() + password = random_lower_string() + user_in = UserCreate(email=username, password=password) + user = crud.create_user(session=db, user_create=user_in) + user_id = user.id + r = client.get( + f"{settings.API_V1_STR}/users/{user_id}", + headers=superuser_token_headers, + ) + assert 200 <= r.status_code < 300 + api_user = r.json() + existing_user = crud.get_user_by_email(session=db, email=username) + assert existing_user + assert existing_user.email == api_user["email"] + + +def test_get_non_existing_user_as_superuser( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + r = client.get( + f"{settings.API_V1_STR}/users/{uuid.uuid4()}", + headers=superuser_token_headers, + ) + assert r.status_code == 404 + assert r.json() == {"detail": "User not found"} + + +def test_get_existing_user_current_user(client: TestClient, db: Session) -> None: + username = random_email() + password = random_lower_string() + user_in = UserCreate(email=username, password=password) + user = crud.create_user(session=db, user_create=user_in) + user_id = user.id + + login_data = { + "username": username, + "password": password, + } + r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) + tokens = r.json() + a_token = tokens["access_token"] + headers = {"Authorization": f"Bearer {a_token}"} + + r = client.get( + f"{settings.API_V1_STR}/users/{user_id}", + headers=headers, + ) + assert 200 <= r.status_code < 300 + api_user = r.json() + existing_user = crud.get_user_by_email(session=db, email=username) + assert existing_user + assert existing_user.email == api_user["email"] + + +def test_get_existing_user_permissions_error( + db: Session, + client: TestClient, + normal_user_token_headers: dict[str, str], +) -> None: + user = create_random_user(db) + + r = client.get( + f"{settings.API_V1_STR}/users/{user.id}", + headers=normal_user_token_headers, + ) + assert r.status_code == 403 + assert r.json() == {"detail": "The user doesn't have enough privileges"} + + +def test_get_non_existing_user_permissions_error( + client: TestClient, + normal_user_token_headers: dict[str, str], +) -> None: + user_id = uuid.uuid4() + + r = client.get( + f"{settings.API_V1_STR}/users/{user_id}", + headers=normal_user_token_headers, + ) + assert r.status_code == 403 + assert r.json() == {"detail": "The user doesn't have enough privileges"} + + +def test_create_user_existing_username( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + username = random_email() + # username = email + password = random_lower_string() + user_in = UserCreate(email=username, password=password) + crud.create_user(session=db, user_create=user_in) + data = {"email": username, "password": password} + r = client.post( + f"{settings.API_V1_STR}/users/", + headers=superuser_token_headers, + json=data, + ) + created_user = r.json() + assert r.status_code == 400 + assert "_id" not in created_user + + +def test_create_user_by_normal_user( + client: TestClient, normal_user_token_headers: dict[str, str] +) -> None: + username = random_email() + password = random_lower_string() + data = {"email": username, "password": password} + r = client.post( + f"{settings.API_V1_STR}/users/", + headers=normal_user_token_headers, + json=data, + ) + assert r.status_code == 403 + + +def test_retrieve_users( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + username = random_email() + password = random_lower_string() + user_in = UserCreate(email=username, password=password) + crud.create_user(session=db, user_create=user_in) + + username2 = random_email() + password2 = random_lower_string() + user_in2 = UserCreate(email=username2, password=password2) + crud.create_user(session=db, user_create=user_in2) + + r = client.get(f"{settings.API_V1_STR}/users/", headers=superuser_token_headers) + all_users = r.json() + + assert len(all_users["data"]) > 1 + assert "count" in all_users + for item in all_users["data"]: + assert "email" in item + + +def test_update_user_me( + client: TestClient, normal_user_token_headers: dict[str, str], db: Session +) -> None: + full_name = "Updated Name" + email = random_email() + data = {"full_name": full_name, "email": email} + r = client.patch( + f"{settings.API_V1_STR}/users/me", + headers=normal_user_token_headers, + json=data, + ) + assert r.status_code == 200 + updated_user = r.json() + assert updated_user["email"] == email + assert updated_user["full_name"] == full_name + + user_query = select(User).where(User.email == email) + user_db = db.exec(user_query).first() + assert user_db + assert user_db.email == email + assert user_db.full_name == full_name + + +def test_update_password_me( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + new_password = random_lower_string() + data = { + "current_password": settings.FIRST_SUPERUSER_PASSWORD, + "new_password": new_password, + } + r = client.patch( + f"{settings.API_V1_STR}/users/me/password", + headers=superuser_token_headers, + json=data, + ) + assert r.status_code == 200 + updated_user = r.json() + assert updated_user["message"] == "Password updated successfully" + + user_query = select(User).where(User.email == settings.FIRST_SUPERUSER) + user_db = db.exec(user_query).first() + assert user_db + assert user_db.email == settings.FIRST_SUPERUSER + verified, _ = verify_password(new_password, user_db.hashed_password) + assert verified + + # Revert to the old password to keep consistency in test + old_data = { + "current_password": new_password, + "new_password": settings.FIRST_SUPERUSER_PASSWORD, + } + r = client.patch( + f"{settings.API_V1_STR}/users/me/password", + headers=superuser_token_headers, + json=old_data, + ) + db.refresh(user_db) + + assert r.status_code == 200 + verified, _ = verify_password( + settings.FIRST_SUPERUSER_PASSWORD, user_db.hashed_password + ) + assert verified + + +def test_update_password_me_incorrect_password( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + new_password = random_lower_string() + data = {"current_password": new_password, "new_password": new_password} + r = client.patch( + f"{settings.API_V1_STR}/users/me/password", + headers=superuser_token_headers, + json=data, + ) + assert r.status_code == 400 + updated_user = r.json() + assert updated_user["detail"] == "Incorrect password" + + +def test_update_user_me_email_exists( + client: TestClient, normal_user_token_headers: dict[str, str], db: Session +) -> None: + username = random_email() + password = random_lower_string() + user_in = UserCreate(email=username, password=password) + user = crud.create_user(session=db, user_create=user_in) + + data = {"email": user.email} + r = client.patch( + f"{settings.API_V1_STR}/users/me", + headers=normal_user_token_headers, + json=data, + ) + assert r.status_code == 409 + assert r.json()["detail"] == "User with this email already exists" + + +def test_update_password_me_same_password_error( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + data = { + "current_password": settings.FIRST_SUPERUSER_PASSWORD, + "new_password": settings.FIRST_SUPERUSER_PASSWORD, + } + r = client.patch( + f"{settings.API_V1_STR}/users/me/password", + headers=superuser_token_headers, + json=data, + ) + assert r.status_code == 400 + updated_user = r.json() + assert ( + updated_user["detail"] == "New password cannot be the same as the current one" + ) + + +def test_register_user(client: TestClient, db: Session) -> None: + username = random_email() + password = random_lower_string() + full_name = random_lower_string() + data = {"email": username, "password": password, "full_name": full_name} + r = client.post( + f"{settings.API_V1_STR}/users/signup", + json=data, + ) + assert r.status_code == 200 + created_user = r.json() + assert created_user["email"] == username + assert created_user["full_name"] == full_name + + user_query = select(User).where(User.email == username) + user_db = db.exec(user_query).first() + assert user_db + assert user_db.email == username + assert user_db.full_name == full_name + verified, _ = verify_password(password, user_db.hashed_password) + assert verified + + +def test_register_user_already_exists_error(client: TestClient) -> None: + password = random_lower_string() + full_name = random_lower_string() + data = { + "email": settings.FIRST_SUPERUSER, + "password": password, + "full_name": full_name, + } + r = client.post( + f"{settings.API_V1_STR}/users/signup", + json=data, + ) + assert r.status_code == 400 + assert r.json()["detail"] == "The user with this email already exists in the system" + + +def test_update_user( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + username = random_email() + password = random_lower_string() + user_in = UserCreate(email=username, password=password) + user = crud.create_user(session=db, user_create=user_in) + + data = {"full_name": "Updated_full_name"} + r = client.patch( + f"{settings.API_V1_STR}/users/{user.id}", + headers=superuser_token_headers, + json=data, + ) + assert r.status_code == 200 + updated_user = r.json() + + assert updated_user["full_name"] == "Updated_full_name" + + user_query = select(User).where(User.email == username) + user_db = db.exec(user_query).first() + db.refresh(user_db) + assert user_db + assert user_db.full_name == "Updated_full_name" + + +def test_update_user_not_exists( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + data = {"full_name": "Updated_full_name"} + r = client.patch( + f"{settings.API_V1_STR}/users/{uuid.uuid4()}", + headers=superuser_token_headers, + json=data, + ) + assert r.status_code == 404 + assert r.json()["detail"] == "The user with this id does not exist in the system" + + +def test_update_user_email_exists( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + username = random_email() + password = random_lower_string() + user_in = UserCreate(email=username, password=password) + user = crud.create_user(session=db, user_create=user_in) + + username2 = random_email() + password2 = random_lower_string() + user_in2 = UserCreate(email=username2, password=password2) + user2 = crud.create_user(session=db, user_create=user_in2) + + data = {"email": user2.email} + r = client.patch( + f"{settings.API_V1_STR}/users/{user.id}", + headers=superuser_token_headers, + json=data, + ) + assert r.status_code == 409 + assert r.json()["detail"] == "User with this email already exists" + + +def test_delete_user_me(client: TestClient, db: Session) -> None: + username = random_email() + password = random_lower_string() + user_in = UserCreate(email=username, password=password) + user = crud.create_user(session=db, user_create=user_in) + user_id = user.id + + login_data = { + "username": username, + "password": password, + } + r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) + tokens = r.json() + a_token = tokens["access_token"] + headers = {"Authorization": f"Bearer {a_token}"} + + r = client.delete( + f"{settings.API_V1_STR}/users/me", + headers=headers, + ) + assert r.status_code == 200 + deleted_user = r.json() + assert deleted_user["message"] == "User deleted successfully" + result = db.exec(select(User).where(User.id == user_id)).first() + assert result is None + + user_query = select(User).where(User.id == user_id) + user_db = db.execute(user_query).first() + assert user_db is None + + +def test_delete_user_me_as_superuser( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + r = client.delete( + f"{settings.API_V1_STR}/users/me", + headers=superuser_token_headers, + ) + assert r.status_code == 403 + response = r.json() + assert response["detail"] == "Super users are not allowed to delete themselves" + + +def test_delete_user_super_user( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + username = random_email() + password = random_lower_string() + user_in = UserCreate(email=username, password=password) + user = crud.create_user(session=db, user_create=user_in) + user_id = user.id + r = client.delete( + f"{settings.API_V1_STR}/users/{user_id}", + headers=superuser_token_headers, + ) + assert r.status_code == 200 + deleted_user = r.json() + assert deleted_user["message"] == "User deleted successfully" + result = db.exec(select(User).where(User.id == user_id)).first() + assert result is None + + +def test_delete_user_not_found( + client: TestClient, superuser_token_headers: dict[str, str] +) -> None: + r = client.delete( + f"{settings.API_V1_STR}/users/{uuid.uuid4()}", + headers=superuser_token_headers, + ) + assert r.status_code == 404 + assert r.json()["detail"] == "User not found" + + +def test_delete_user_current_super_user_error( + client: TestClient, superuser_token_headers: dict[str, str], db: Session +) -> None: + super_user = crud.get_user_by_email(session=db, email=settings.FIRST_SUPERUSER) + assert super_user + user_id = super_user.id + + r = client.delete( + f"{settings.API_V1_STR}/users/{user_id}", + headers=superuser_token_headers, + ) + assert r.status_code == 403 + assert r.json()["detail"] == "Super users are not allowed to delete themselves" + + +def test_delete_user_without_privileges( + client: TestClient, normal_user_token_headers: dict[str, str], db: Session +) -> None: + username = random_email() + password = random_lower_string() + user_in = UserCreate(email=username, password=password) + user = crud.create_user(session=db, user_create=user_in) + + r = client.delete( + f"{settings.API_V1_STR}/users/{user.id}", + headers=normal_user_token_headers, + ) + assert r.status_code == 403 + assert r.json()["detail"] == "The user doesn't have enough privileges" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..8ddab7b --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,42 @@ +from collections.abc import Generator + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session, delete + +from app.core.config import settings +from app.core.db import engine, init_db +from app.main import app +from app.models import Item, User +from tests.utils.user import authentication_token_from_email +from tests.utils.utils import get_superuser_token_headers + + +@pytest.fixture(scope="session", autouse=True) +def db() -> Generator[Session, None, None]: + with Session(engine) as session: + init_db(session) + yield session + statement = delete(Item) + session.execute(statement) + statement = delete(User) + session.execute(statement) + session.commit() + + +@pytest.fixture(scope="module") +def client() -> Generator[TestClient, None, None]: + with TestClient(app) as c: + yield c + + +@pytest.fixture(scope="module") +def superuser_token_headers(client: TestClient) -> dict[str, str]: + return get_superuser_token_headers(client) + + +@pytest.fixture(scope="module") +def normal_user_token_headers(client: TestClient, db: Session) -> dict[str, str]: + return authentication_token_from_email( + client=client, email=settings.EMAIL_TEST_USER, db=db + ) diff --git a/backend/tests/crud/__init__.py b/backend/tests/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/crud/test_user.py b/backend/tests/crud/test_user.py new file mode 100644 index 0000000..3db77ef --- /dev/null +++ b/backend/tests/crud/test_user.py @@ -0,0 +1,130 @@ +from fastapi.encoders import jsonable_encoder +from pwdlib.hashers.bcrypt import BcryptHasher +from sqlmodel import Session + +from app import crud +from app.core.security import verify_password +from app.models import User, UserCreate, UserUpdate +from tests.utils.utils import random_email, random_lower_string + + +def test_create_user(db: Session) -> None: + email = random_email() + password = random_lower_string() + user_in = UserCreate(email=email, password=password) + user = crud.create_user(session=db, user_create=user_in) + assert user.email == email + assert hasattr(user, "hashed_password") + + +def test_authenticate_user(db: Session) -> None: + email = random_email() + password = random_lower_string() + user_in = UserCreate(email=email, password=password) + user = crud.create_user(session=db, user_create=user_in) + authenticated_user = crud.authenticate(session=db, email=email, password=password) + assert authenticated_user + assert user.email == authenticated_user.email + + +def test_not_authenticate_user(db: Session) -> None: + email = random_email() + password = random_lower_string() + user = crud.authenticate(session=db, email=email, password=password) + assert user is None + + +def test_check_if_user_is_active(db: Session) -> None: + email = random_email() + password = random_lower_string() + user_in = UserCreate(email=email, password=password) + user = crud.create_user(session=db, user_create=user_in) + assert user.is_active is True + + +def test_check_if_user_is_active_inactive(db: Session) -> None: + email = random_email() + password = random_lower_string() + user_in = UserCreate(email=email, password=password, is_active=False) + user = crud.create_user(session=db, user_create=user_in) + assert user.is_active is False + + +def test_check_if_user_is_superuser(db: Session) -> None: + email = random_email() + password = random_lower_string() + user_in = UserCreate(email=email, password=password, is_superuser=True) + user = crud.create_user(session=db, user_create=user_in) + assert user.is_superuser is True + + +def test_check_if_user_is_superuser_normal_user(db: Session) -> None: + username = random_email() + password = random_lower_string() + user_in = UserCreate(email=username, password=password) + user = crud.create_user(session=db, user_create=user_in) + assert user.is_superuser is False + + +def test_get_user(db: Session) -> None: + password = random_lower_string() + username = random_email() + user_in = UserCreate(email=username, password=password, is_superuser=True) + user = crud.create_user(session=db, user_create=user_in) + user_2 = db.get(User, user.id) + assert user_2 + assert user.email == user_2.email + assert jsonable_encoder(user) == jsonable_encoder(user_2) + + +def test_update_user(db: Session) -> None: + password = random_lower_string() + email = random_email() + user_in = UserCreate(email=email, password=password, is_superuser=True) + user = crud.create_user(session=db, user_create=user_in) + new_password = random_lower_string() + user_in_update = UserUpdate(password=new_password, is_superuser=True) + if user.id is not None: + crud.update_user(session=db, db_user=user, user_in=user_in_update) + user_2 = db.get(User, user.id) + assert user_2 + assert user.email == user_2.email + verified, _ = verify_password(new_password, user_2.hashed_password) + assert verified + + +def test_authenticate_user_with_bcrypt_upgrades_to_argon2(db: Session) -> None: + """Test that a user with bcrypt password hash gets upgraded to argon2 on login.""" + email = random_email() + password = random_lower_string() + + # Create a bcrypt hash directly (simulating legacy password) + bcrypt_hasher = BcryptHasher() + bcrypt_hash = bcrypt_hasher.hash(password) + assert bcrypt_hash.startswith("$2") # bcrypt hashes start with $2 + + # Create user with bcrypt hash directly in the database + user = User(email=email, hashed_password=bcrypt_hash) + db.add(user) + db.commit() + db.refresh(user) + + # Verify the hash is bcrypt before authentication + assert user.hashed_password.startswith("$2") + + # Authenticate - this should upgrade the hash to argon2 + authenticated_user = crud.authenticate(session=db, email=email, password=password) + assert authenticated_user + assert authenticated_user.email == email + + db.refresh(authenticated_user) + + # Verify the hash was upgraded to argon2 + assert authenticated_user.hashed_password.startswith("$argon2") + + verified, updated_hash = verify_password( + password, authenticated_user.hashed_password + ) + assert verified + # Should not need another update since it's already argon2 + assert updated_hash is None diff --git a/backend/tests/scripts/__init__.py b/backend/tests/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/scripts/test_backend_pre_start.py b/backend/tests/scripts/test_backend_pre_start.py new file mode 100644 index 0000000..e8f35c6 --- /dev/null +++ b/backend/tests/scripts/test_backend_pre_start.py @@ -0,0 +1,33 @@ +from unittest.mock import MagicMock, patch + +from sqlmodel import select + +from app.backend_pre_start import init, logger + + +def test_init_successful_connection() -> None: + engine_mock = MagicMock() + + session_mock = MagicMock() + session_mock.__enter__.return_value = session_mock + + select1 = select(1) + + with ( + patch("app.backend_pre_start.Session", return_value=session_mock), + patch("app.backend_pre_start.select", return_value=select1), + patch.object(logger, "info"), + patch.object(logger, "error"), + patch.object(logger, "warn"), + ): + try: + init(engine_mock) + connection_successful = True + except Exception: + connection_successful = False + + assert connection_successful, ( + "The database connection should be successful and not raise an exception." + ) + + session_mock.exec.assert_called_once_with(select1) diff --git a/backend/tests/scripts/test_test_pre_start.py b/backend/tests/scripts/test_test_pre_start.py new file mode 100644 index 0000000..180bdd5 --- /dev/null +++ b/backend/tests/scripts/test_test_pre_start.py @@ -0,0 +1,33 @@ +from unittest.mock import MagicMock, patch + +from sqlmodel import select + +from app.tests_pre_start import init, logger + + +def test_init_successful_connection() -> None: + engine_mock = MagicMock() + + session_mock = MagicMock() + session_mock.__enter__.return_value = session_mock + + select1 = select(1) + + with ( + patch("app.tests_pre_start.Session", return_value=session_mock), + patch("app.tests_pre_start.select", return_value=select1), + patch.object(logger, "info"), + patch.object(logger, "error"), + patch.object(logger, "warn"), + ): + try: + init(engine_mock) + connection_successful = True + except Exception: + connection_successful = False + + assert connection_successful, ( + "The database connection should be successful and not raise an exception." + ) + + session_mock.exec.assert_called_once_with(select1) diff --git a/backend/tests/utils/__init__.py b/backend/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/utils/item.py b/backend/tests/utils/item.py new file mode 100644 index 0000000..ee51b35 --- /dev/null +++ b/backend/tests/utils/item.py @@ -0,0 +1,16 @@ +from sqlmodel import Session + +from app import crud +from app.models import Item, ItemCreate +from tests.utils.user import create_random_user +from tests.utils.utils import random_lower_string + + +def create_random_item(db: Session) -> Item: + user = create_random_user(db) + owner_id = user.id + assert owner_id is not None + title = random_lower_string() + description = random_lower_string() + item_in = ItemCreate(title=title, description=description) + return crud.create_item(session=db, item_in=item_in, owner_id=owner_id) diff --git a/backend/tests/utils/user.py b/backend/tests/utils/user.py new file mode 100644 index 0000000..5867431 --- /dev/null +++ b/backend/tests/utils/user.py @@ -0,0 +1,49 @@ +from fastapi.testclient import TestClient +from sqlmodel import Session + +from app import crud +from app.core.config import settings +from app.models import User, UserCreate, UserUpdate +from tests.utils.utils import random_email, random_lower_string + + +def user_authentication_headers( + *, client: TestClient, email: str, password: str +) -> dict[str, str]: + data = {"username": email, "password": password} + + r = client.post(f"{settings.API_V1_STR}/login/access-token", data=data) + response = r.json() + auth_token = response["access_token"] + headers = {"Authorization": f"Bearer {auth_token}"} + return headers + + +def create_random_user(db: Session) -> User: + email = random_email() + password = random_lower_string() + user_in = UserCreate(email=email, password=password) + user = crud.create_user(session=db, user_create=user_in) + return user + + +def authentication_token_from_email( + *, client: TestClient, email: str, db: Session +) -> dict[str, str]: + """ + Return a valid token for the user with given email. + + If the user doesn't exist it is created first. + """ + password = random_lower_string() + user = crud.get_user_by_email(session=db, email=email) + if not user: + user_in_create = UserCreate(email=email, password=password) + user = crud.create_user(session=db, user_create=user_in_create) + else: + user_in_update = UserUpdate(password=password) + if not user.id: + raise Exception("User id not set") + user = crud.update_user(session=db, db_user=user, user_in=user_in_update) + + return user_authentication_headers(client=client, email=email, password=password) diff --git a/backend/tests/utils/utils.py b/backend/tests/utils/utils.py new file mode 100644 index 0000000..184bac4 --- /dev/null +++ b/backend/tests/utils/utils.py @@ -0,0 +1,26 @@ +import random +import string + +from fastapi.testclient import TestClient + +from app.core.config import settings + + +def random_lower_string() -> str: + return "".join(random.choices(string.ascii_lowercase, k=32)) + + +def random_email() -> str: + return f"{random_lower_string()}@{random_lower_string()}.com" + + +def get_superuser_token_headers(client: TestClient) -> dict[str, str]: + login_data = { + "username": settings.FIRST_SUPERUSER, + "password": settings.FIRST_SUPERUSER_PASSWORD, + } + r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) + tokens = r.json() + a_token = tokens["access_token"] + headers = {"Authorization": f"Bearer {a_token}"} + return headers diff --git a/frontend b/frontend deleted file mode 160000 index 79ab8e9..0000000 --- a/frontend +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 79ab8e9cfde08afc9daa73d0424634067b6520e9 diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..093ec6d --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +openapi.json + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..e9bae40 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,26 @@ +# Stage 0, "build-stage", based on Bun, to build and compile the frontend +FROM oven/bun:1 AS build-stage + +WORKDIR /app + +COPY package.json bun.lock /app/ + +COPY frontend/package.json /app/frontend/ + +WORKDIR /app/frontend + +RUN bun install + +COPY ./frontend /app/frontend +ARG VITE_API_URL + +RUN bun run build + + +# Stage 1, based on Nginx, to have only the compiled app, ready for production with Nginx +FROM nginx:1 + +COPY --from=build-stage /app/frontend/dist/ /usr/share/nginx/html + +COPY ./frontend/nginx.conf /etc/nginx/conf.d/default.conf +COPY ./frontend/nginx-backend-not-found.conf /etc/nginx/extra-conf.d/backend-not-found.conf diff --git a/frontend/Dockerfile.playwright b/frontend/Dockerfile.playwright new file mode 100644 index 0000000..0ae955c --- /dev/null +++ b/frontend/Dockerfile.playwright @@ -0,0 +1,21 @@ +FROM mcr.microsoft.com/playwright:v1.58.0-noble + +WORKDIR /app + +RUN apt-get update && apt-get install -y unzip \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL https://bun.sh/install | bash +ENV PATH="/root/.bun/bin:$PATH" + +COPY package.json bun.lock /app/ + +COPY frontend/package.json /app/frontend/ + +WORKDIR /app/frontend + +RUN bun install + +COPY ./frontend /app/frontend + +ARG VITE_API_URL diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..5c695fc --- /dev/null +++ b/frontend/README.md @@ -0,0 +1 @@ +# Fluksio Frontend \ No newline at end of file diff --git a/frontend/biome.json b/frontend/biome.json new file mode 100644 index 0000000..10b20a2 --- /dev/null +++ b/frontend/biome.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.12/schema.json", + "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "files": { + "includes": [ + "**", + "!**/dist/**/*", + "!**/node_modules/**/*", + "!**/src/routeTree.gen.ts", + "!**/src/client/**/*", + "!**/src/components/ui/**/*", + "!**/playwright-report", + "!**/playwright.config.ts" + ] + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "off", + "noArrayIndexKey": "off" + }, + "style": { + "noNonNullAssertion": "off", + "noParameterAssign": "error", + "useSelfClosingElements": "error", + "noUselessElse": "error" + } + } + }, + "formatter": { + "indentStyle": "space" + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "asNeeded" + } + }, + "css": { + "parser": { + "tailwindDirectives": true + } + } +} diff --git a/frontend/components.json b/frontend/components.json new file mode 100644 index 0000000..2b0833f --- /dev/null +++ b/frontend/components.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..57621a2 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + Full Stack FastAPI Project + + + +
+ + + diff --git a/frontend/nginx-backend-not-found.conf b/frontend/nginx-backend-not-found.conf new file mode 100644 index 0000000..f6fea66 --- /dev/null +++ b/frontend/nginx-backend-not-found.conf @@ -0,0 +1,9 @@ +location /api { + return 404; +} +location /docs { + return 404; +} +location /redoc { + return 404; +} diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..ba4d9aa --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,11 @@ +server { + listen 80; + + location / { + root /usr/share/nginx/html; + index index.html index.htm; + try_files $uri /index.html =404; + } + + include /etc/nginx/extra-conf.d/*.conf; +} diff --git a/frontend/openapi-ts.config.ts b/frontend/openapi-ts.config.ts new file mode 100644 index 0000000..b5a69e2 --- /dev/null +++ b/frontend/openapi-ts.config.ts @@ -0,0 +1,33 @@ +import { defineConfig } from "@hey-api/openapi-ts" + +export default defineConfig({ + input: "./openapi.json", + output: "./src/client", + + plugins: [ + "legacy/axios", + { + name: "@hey-api/sdk", + // NOTE: this doesn't allow tree-shaking + asClass: true, + operationId: true, + classNameBuilder: "{{name}}Service", + methodNameBuilder: (operation) => { + // @ts-expect-error + let name: string = operation.name + // @ts-expect-error + const service: string = operation.service + + if (service && name.toLowerCase().startsWith(service.toLowerCase())) { + name = name.slice(service.length) + } + + return name.charAt(0).toLowerCase() + name.slice(1) + }, + }, + { + name: "@hey-api/schemas", + type: "json", + }, + ], +}) diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..8d25f49 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,66 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -p tsconfig.build.json && vite build", + "lint": "biome check --write --unsafe --no-errors-on-unmatched --files-ignore-unknown=true ./", + "preview": "vite preview", + "generate-client": "openapi-ts", + "test": "bunx playwright test", + "test:ui": "bunx playwright test --ui" + }, + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-query": "^5.90.12", + "@tanstack/react-query-devtools": "^5.91.1", + "@tanstack/react-router": "^1.157.3", + "@tanstack/react-router-devtools": "^1.156.0", + "@tanstack/react-table": "^8.21.3", + "axios": "1.13.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "form-data": "4.0.5", + "lucide-react": "^0.562.0", + "next-themes": "^0.4.6", + "react": "^19.1.1", + "react-dom": "^19.2.3", + "react-error-boundary": "^6.0.0", + "react-hook-form": "^7.68.0", + "react-icons": "^5.5.0", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.17", + "zod": "^4.3.6" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.12", + "@hey-api/openapi-ts": "0.73.0", + "@playwright/test": "1.58.0", + "@tanstack/router-devtools": "^1.142.11", + "@tanstack/router-plugin": "^1.140.0", + "@types/node": "^25.0.10", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react-swc": "^4.2.2", + "dotenv": "^17.2.3", + "tw-animate-css": "^1.4.0", + "typescript": "^5.9.3", + "vite": "^7.3.0" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..36f03d9 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,91 @@ +import { defineConfig, devices } from '@playwright/test'; +import 'dotenv/config' + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI ? 'blob' : 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: 'http://localhost:5173', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { name: 'setup', testMatch: /.*\.setup\.ts/ }, + + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'playwright/.auth/user.json', + }, + dependencies: ['setup'], + }, + + // { + // name: 'firefox', + // use: { + // ...devices['Desktop Firefox'], + // storageState: 'playwright/.auth/user.json', + // }, + // dependencies: ['setup'], + // }, + + // { + // name: 'webkit', + // use: { + // ...devices['Desktop Safari'], + // storageState: 'playwright/.auth/user.json', + // }, + // dependencies: ['setup'], + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: 'bun run dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/frontend/public/assets/images/fastapi-icon-light.svg b/frontend/public/assets/images/fastapi-icon-light.svg new file mode 100644 index 0000000..d069c72 --- /dev/null +++ b/frontend/public/assets/images/fastapi-icon-light.svg @@ -0,0 +1,77 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/frontend/public/assets/images/fastapi-icon.svg b/frontend/public/assets/images/fastapi-icon.svg new file mode 100644 index 0000000..df93a70 --- /dev/null +++ b/frontend/public/assets/images/fastapi-icon.svg @@ -0,0 +1,77 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/frontend/public/assets/images/fastapi-logo-light.svg b/frontend/public/assets/images/fastapi-logo-light.svg new file mode 100644 index 0000000..1a84b98 --- /dev/null +++ b/frontend/public/assets/images/fastapi-logo-light.svg @@ -0,0 +1,83 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + diff --git a/frontend/public/assets/images/fastapi-logo.svg b/frontend/public/assets/images/fastapi-logo.svg new file mode 100644 index 0000000..c90d252 --- /dev/null +++ b/frontend/public/assets/images/fastapi-logo.svg @@ -0,0 +1,91 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/images/favicon.png b/frontend/public/assets/images/favicon.png new file mode 100644 index 0000000..e5b7c3a Binary files /dev/null and b/frontend/public/assets/images/favicon.png differ diff --git a/frontend/src/client/core/ApiError.ts b/frontend/src/client/core/ApiError.ts new file mode 100644 index 0000000..36675d2 --- /dev/null +++ b/frontend/src/client/core/ApiError.ts @@ -0,0 +1,21 @@ +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; + +export class ApiError extends Error { + public readonly url: string; + public readonly status: number; + public readonly statusText: string; + public readonly body: unknown; + public readonly request: ApiRequestOptions; + + constructor(request: ApiRequestOptions, response: ApiResult, message: string) { + super(message); + + this.name = 'ApiError'; + this.url = response.url; + this.status = response.status; + this.statusText = response.statusText; + this.body = response.body; + this.request = request; + } +} \ No newline at end of file diff --git a/frontend/src/client/core/ApiRequestOptions.ts b/frontend/src/client/core/ApiRequestOptions.ts new file mode 100644 index 0000000..939a0aa --- /dev/null +++ b/frontend/src/client/core/ApiRequestOptions.ts @@ -0,0 +1,21 @@ +export type ApiRequestOptions = { + readonly body?: any; + readonly cookies?: Record; + readonly errors?: Record; + readonly formData?: Record | any[] | Blob | File; + readonly headers?: Record; + readonly mediaType?: string; + readonly method: + | 'DELETE' + | 'GET' + | 'HEAD' + | 'OPTIONS' + | 'PATCH' + | 'POST' + | 'PUT'; + readonly path?: Record; + readonly query?: Record; + readonly responseHeader?: string; + readonly responseTransformer?: (data: unknown) => Promise; + readonly url: string; +}; \ No newline at end of file diff --git a/frontend/src/client/core/ApiResult.ts b/frontend/src/client/core/ApiResult.ts new file mode 100644 index 0000000..4c58e39 --- /dev/null +++ b/frontend/src/client/core/ApiResult.ts @@ -0,0 +1,7 @@ +export type ApiResult = { + readonly body: TData; + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly url: string; +}; \ No newline at end of file diff --git a/frontend/src/client/core/CancelablePromise.ts b/frontend/src/client/core/CancelablePromise.ts new file mode 100644 index 0000000..ccc082e --- /dev/null +++ b/frontend/src/client/core/CancelablePromise.ts @@ -0,0 +1,126 @@ +export class CancelError extends Error { + constructor(message: string) { + super(message); + this.name = 'CancelError'; + } + + public get isCancelled(): boolean { + return true; + } +} + +export interface OnCancel { + readonly isResolved: boolean; + readonly isRejected: boolean; + readonly isCancelled: boolean; + + (cancelHandler: () => void): void; +} + +export class CancelablePromise implements Promise { + private _isResolved: boolean; + private _isRejected: boolean; + private _isCancelled: boolean; + readonly cancelHandlers: (() => void)[]; + readonly promise: Promise; + private _resolve?: (value: T | PromiseLike) => void; + private _reject?: (reason?: unknown) => void; + + constructor( + executor: ( + resolve: (value: T | PromiseLike) => void, + reject: (reason?: unknown) => void, + onCancel: OnCancel + ) => void + ) { + this._isResolved = false; + this._isRejected = false; + this._isCancelled = false; + this.cancelHandlers = []; + this.promise = new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + + const onResolve = (value: T | PromiseLike): void => { + if (this._isResolved || this._isRejected || this._isCancelled) { + return; + } + this._isResolved = true; + if (this._resolve) this._resolve(value); + }; + + const onReject = (reason?: unknown): void => { + if (this._isResolved || this._isRejected || this._isCancelled) { + return; + } + this._isRejected = true; + if (this._reject) this._reject(reason); + }; + + const onCancel = (cancelHandler: () => void): void => { + if (this._isResolved || this._isRejected || this._isCancelled) { + return; + } + this.cancelHandlers.push(cancelHandler); + }; + + Object.defineProperty(onCancel, 'isResolved', { + get: (): boolean => this._isResolved, + }); + + Object.defineProperty(onCancel, 'isRejected', { + get: (): boolean => this._isRejected, + }); + + Object.defineProperty(onCancel, 'isCancelled', { + get: (): boolean => this._isCancelled, + }); + + return executor(onResolve, onReject, onCancel as OnCancel); + }); + } + + get [Symbol.toStringTag]() { + return "Cancellable Promise"; + } + + public then( + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onRejected?: ((reason: unknown) => TResult2 | PromiseLike) | null + ): Promise { + return this.promise.then(onFulfilled, onRejected); + } + + public catch( + onRejected?: ((reason: unknown) => TResult | PromiseLike) | null + ): Promise { + return this.promise.catch(onRejected); + } + + public finally(onFinally?: (() => void) | null): Promise { + return this.promise.finally(onFinally); + } + + public cancel(): void { + if (this._isResolved || this._isRejected || this._isCancelled) { + return; + } + this._isCancelled = true; + if (this.cancelHandlers.length) { + try { + for (const cancelHandler of this.cancelHandlers) { + cancelHandler(); + } + } catch (error) { + console.warn('Cancellation threw an error', error); + return; + } + } + this.cancelHandlers.length = 0; + if (this._reject) this._reject(new CancelError('Request aborted')); + } + + public get isCancelled(): boolean { + return this._isCancelled; + } +} \ No newline at end of file diff --git a/frontend/src/client/core/OpenAPI.ts b/frontend/src/client/core/OpenAPI.ts new file mode 100644 index 0000000..74f92b4 --- /dev/null +++ b/frontend/src/client/core/OpenAPI.ts @@ -0,0 +1,57 @@ +import type { AxiosRequestConfig, AxiosResponse } from 'axios'; +import type { ApiRequestOptions } from './ApiRequestOptions'; + +type Headers = Record; +type Middleware = (value: T) => T | Promise; +type Resolver = (options: ApiRequestOptions) => Promise; + +export class Interceptors { + _fns: Middleware[]; + + constructor() { + this._fns = []; + } + + eject(fn: Middleware): void { + const index = this._fns.indexOf(fn); + if (index !== -1) { + this._fns = [...this._fns.slice(0, index), ...this._fns.slice(index + 1)]; + } + } + + use(fn: Middleware): void { + this._fns = [...this._fns, fn]; + } +} + +export type OpenAPIConfig = { + BASE: string; + CREDENTIALS: 'include' | 'omit' | 'same-origin'; + ENCODE_PATH?: ((path: string) => string) | undefined; + HEADERS?: Headers | Resolver | undefined; + PASSWORD?: string | Resolver | undefined; + TOKEN?: string | Resolver | undefined; + USERNAME?: string | Resolver | undefined; + VERSION: string; + WITH_CREDENTIALS: boolean; + interceptors: { + request: Interceptors; + response: Interceptors; + }; +}; + +export const OpenAPI: OpenAPIConfig = { + BASE: '', + CREDENTIALS: 'include', + ENCODE_PATH: undefined, + HEADERS: undefined, + PASSWORD: undefined, + TOKEN: undefined, + USERNAME: undefined, + VERSION: '0.1.0', + WITH_CREDENTIALS: false, + interceptors: { + request: new Interceptors(), + response: new Interceptors(), + }, +}; \ No newline at end of file diff --git a/frontend/src/client/core/request.ts b/frontend/src/client/core/request.ts new file mode 100644 index 0000000..ecc2e39 --- /dev/null +++ b/frontend/src/client/core/request.ts @@ -0,0 +1,347 @@ +import axios from 'axios'; +import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; + +import { ApiError } from './ApiError'; +import type { ApiRequestOptions } from './ApiRequestOptions'; +import type { ApiResult } from './ApiResult'; +import { CancelablePromise } from './CancelablePromise'; +import type { OnCancel } from './CancelablePromise'; +import type { OpenAPIConfig } from './OpenAPI'; + +export const isString = (value: unknown): value is string => { + return typeof value === 'string'; +}; + +export const isStringWithValue = (value: unknown): value is string => { + return isString(value) && value !== ''; +}; + +export const isBlob = (value: any): value is Blob => { + return value instanceof Blob; +}; + +export const isFormData = (value: unknown): value is FormData => { + return value instanceof FormData; +}; + +export const isSuccess = (status: number): boolean => { + return status >= 200 && status < 300; +}; + +export const base64 = (str: string): string => { + try { + return btoa(str); + } catch (err) { + // @ts-ignore + return Buffer.from(str).toString('base64'); + } +}; + +export const getQueryString = (params: Record): string => { + const qs: string[] = []; + + const append = (key: string, value: unknown) => { + qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; + + const encodePair = (key: string, value: unknown) => { + if (value === undefined || value === null) { + return; + } + + if (value instanceof Date) { + append(key, value.toISOString()); + } else if (Array.isArray(value)) { + value.forEach(v => encodePair(key, v)); + } else if (typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => encodePair(`${key}[${k}]`, v)); + } else { + append(key, value); + } + }; + + Object.entries(params).forEach(([key, value]) => encodePair(key, value)); + + return qs.length ? `?${qs.join('&')}` : ''; +}; + +const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { + const encoder = config.ENCODE_PATH || encodeURI; + + const path = options.url + .replace('{api-version}', config.VERSION) + .replace(/{(.*?)}/g, (substring: string, group: string) => { + if (options.path?.hasOwnProperty(group)) { + return encoder(String(options.path[group])); + } + return substring; + }); + + const url = config.BASE + path; + return options.query ? url + getQueryString(options.query) : url; +}; + +export const getFormData = (options: ApiRequestOptions): FormData | undefined => { + if (options.formData) { + const formData = new FormData(); + + const process = (key: string, value: unknown) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + }; + + Object.entries(options.formData) + .filter(([, value]) => value !== undefined && value !== null) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => process(key, v)); + } else { + process(key, value); + } + }); + + return formData; + } + return undefined; +}; + +type Resolver = (options: ApiRequestOptions) => Promise; + +export const resolve = async (options: ApiRequestOptions, resolver?: T | Resolver): Promise => { + if (typeof resolver === 'function') { + return (resolver as Resolver)(options); + } + return resolver; +}; + +export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions): Promise> => { + const [token, username, password, additionalHeaders] = await Promise.all([ + // @ts-ignore + resolve(options, config.TOKEN), + // @ts-ignore + resolve(options, config.USERNAME), + // @ts-ignore + resolve(options, config.PASSWORD), + // @ts-ignore + resolve(options, config.HEADERS), + ]); + + const headers = Object.entries({ + Accept: 'application/json', + ...additionalHeaders, + ...options.headers, + }) + .filter(([, value]) => value !== undefined && value !== null) + .reduce((headers, [key, value]) => ({ + ...headers, + [key]: String(value), + }), {} as Record); + + if (isStringWithValue(token)) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers['Authorization'] = `Basic ${credentials}`; + } + + if (options.body !== undefined) { + if (options.mediaType) { + headers['Content-Type'] = options.mediaType; + } else if (isBlob(options.body)) { + headers['Content-Type'] = options.body.type || 'application/octet-stream'; + } else if (isString(options.body)) { + headers['Content-Type'] = 'text/plain'; + } else if (!isFormData(options.body)) { + headers['Content-Type'] = 'application/json'; + } + } else if (options.formData !== undefined) { + if (options.mediaType) { + headers['Content-Type'] = options.mediaType; + } + } + + return headers; +}; + +export const getRequestBody = (options: ApiRequestOptions): unknown => { + if (options.body) { + return options.body; + } + return undefined; +}; + +export const sendRequest = async ( + config: OpenAPIConfig, + options: ApiRequestOptions, + url: string, + body: unknown, + formData: FormData | undefined, + headers: Record, + onCancel: OnCancel, + axiosClient: AxiosInstance +): Promise> => { + const controller = new AbortController(); + + let requestConfig: AxiosRequestConfig = { + data: body ?? formData, + headers, + method: options.method, + signal: controller.signal, + url, + withCredentials: config.WITH_CREDENTIALS, + }; + + onCancel(() => controller.abort()); + + for (const fn of config.interceptors.request._fns) { + requestConfig = await fn(requestConfig); + } + + try { + return await axiosClient.request(requestConfig); + } catch (error) { + const axiosError = error as AxiosError; + if (axiosError.response) { + return axiosError.response; + } + throw error; + } +}; + +export const getResponseHeader = (response: AxiosResponse, responseHeader?: string): string | undefined => { + if (responseHeader) { + const content = response.headers[responseHeader]; + if (isString(content)) { + return content; + } + } + return undefined; +}; + +export const getResponseBody = (response: AxiosResponse): unknown => { + if (response.status !== 204) { + return response.data; + } + return undefined; +}; + +export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { + const errors: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 402: 'Payment Required', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 406: 'Not Acceptable', + 407: 'Proxy Authentication Required', + 408: 'Request Timeout', + 409: 'Conflict', + 410: 'Gone', + 411: 'Length Required', + 412: 'Precondition Failed', + 413: 'Payload Too Large', + 414: 'URI Too Long', + 415: 'Unsupported Media Type', + 416: 'Range Not Satisfiable', + 417: 'Expectation Failed', + 418: 'Im a teapot', + 421: 'Misdirected Request', + 422: 'Unprocessable Content', + 423: 'Locked', + 424: 'Failed Dependency', + 425: 'Too Early', + 426: 'Upgrade Required', + 428: 'Precondition Required', + 429: 'Too Many Requests', + 431: 'Request Header Fields Too Large', + 451: 'Unavailable For Legal Reasons', + 500: 'Internal Server Error', + 501: 'Not Implemented', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + 505: 'HTTP Version Not Supported', + 506: 'Variant Also Negotiates', + 507: 'Insufficient Storage', + 508: 'Loop Detected', + 510: 'Not Extended', + 511: 'Network Authentication Required', + ...options.errors, + } + + const error = errors[result.status]; + if (error) { + throw new ApiError(options, result, error); + } + + if (!result.ok) { + const errorStatus = result.status ?? 'unknown'; + const errorStatusText = result.statusText ?? 'unknown'; + const errorBody = (() => { + try { + return JSON.stringify(result.body, null, 2); + } catch (e) { + return undefined; + } + })(); + + throw new ApiError(options, result, + `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` + ); + } +}; + +/** + * Request method + * @param config The OpenAPI configuration object + * @param options The request options from the service + * @param axiosClient The axios client instance to use + * @returns CancelablePromise + * @throws ApiError + */ +export const request = (config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + try { + const url = getUrl(config, options); + const formData = getFormData(options); + const body = getRequestBody(options); + const headers = await getHeaders(config, options); + + if (!onCancel.isCancelled) { + let response = await sendRequest(config, options, url, body, formData, headers, onCancel, axiosClient); + + for (const fn of config.interceptors.response._fns) { + response = await fn(response); + } + + const responseBody = getResponseBody(response); + const responseHeader = getResponseHeader(response, options.responseHeader); + + let transformedBody = responseBody; + if (options.responseTransformer && isSuccess(response.status)) { + transformedBody = await options.responseTransformer(responseBody) + } + + const result: ApiResult = { + url, + ok: isSuccess(response.status), + status: response.status, + statusText: response.statusText, + body: responseHeader ?? transformedBody, + }; + + catchErrorCodes(options, result); + + resolve(result.body); + } + } catch (error) { + reject(error); + } + }); +}; \ No newline at end of file diff --git a/frontend/src/client/index.ts b/frontend/src/client/index.ts new file mode 100644 index 0000000..50a1dd7 --- /dev/null +++ b/frontend/src/client/index.ts @@ -0,0 +1,6 @@ +// This file is auto-generated by @hey-api/openapi-ts +export { ApiError } from './core/ApiError'; +export { CancelablePromise, CancelError } from './core/CancelablePromise'; +export { OpenAPI, type OpenAPIConfig } from './core/OpenAPI'; +export * from './sdk.gen'; +export * from './types.gen'; \ No newline at end of file diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts new file mode 100644 index 0000000..5c0c9c4 --- /dev/null +++ b/frontend/src/client/schemas.gen.ts @@ -0,0 +1,552 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export const Body_login_login_access_tokenSchema = { + properties: { + grant_type: { + anyOf: [ + { + type: 'string', + pattern: '^password$' + }, + { + type: 'null' + } + ], + title: 'Grant Type' + }, + username: { + type: 'string', + title: 'Username' + }, + password: { + type: 'string', + format: 'password', + title: 'Password' + }, + scope: { + type: 'string', + title: 'Scope', + default: '' + }, + client_id: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + title: 'Client Id' + }, + client_secret: { + anyOf: [ + { + type: 'string' + }, + { + type: 'null' + } + ], + format: 'password', + title: 'Client Secret' + } + }, + type: 'object', + required: ['username', 'password'], + title: 'Body_login-login_access_token' +} as const; + +export const HTTPValidationErrorSchema = { + properties: { + detail: { + items: { + '$ref': '#/components/schemas/ValidationError' + }, + type: 'array', + title: 'Detail' + } + }, + type: 'object', + title: 'HTTPValidationError' +} as const; + +export const ItemCreateSchema = { + properties: { + title: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Title' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Description' + } + }, + type: 'object', + required: ['title'], + title: 'ItemCreate' +} as const; + +export const ItemPublicSchema = { + properties: { + title: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Title' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + owner_id: { + type: 'string', + format: 'uuid', + title: 'Owner Id' + }, + created_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Created At' + } + }, + type: 'object', + required: ['title', 'id', 'owner_id'], + title: 'ItemPublic' +} as const; + +export const ItemUpdateSchema = { + properties: { + title: { + anyOf: [ + { + type: 'string', + maxLength: 255, + minLength: 1 + }, + { + type: 'null' + } + ], + title: 'Title' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Description' + } + }, + type: 'object', + title: 'ItemUpdate' +} as const; + +export const ItemsPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/ItemPublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'ItemsPublic' +} as const; + +export const MessageSchema = { + properties: { + message: { + type: 'string', + title: 'Message' + } + }, + type: 'object', + required: ['message'], + title: 'Message' +} as const; + +export const NewPasswordSchema = { + properties: { + token: { + type: 'string', + title: 'Token' + }, + new_password: { + type: 'string', + maxLength: 128, + minLength: 8, + title: 'New Password' + } + }, + type: 'object', + required: ['token', 'new_password'], + title: 'NewPassword' +} as const; + +export const PrivateUserCreateSchema = { + properties: { + email: { + type: 'string', + title: 'Email' + }, + password: { + type: 'string', + title: 'Password' + }, + full_name: { + type: 'string', + title: 'Full Name' + }, + is_verified: { + type: 'boolean', + title: 'Is Verified', + default: false + } + }, + type: 'object', + required: ['email', 'password', 'full_name'], + title: 'PrivateUserCreate' +} as const; + +export const TokenSchema = { + properties: { + access_token: { + type: 'string', + title: 'Access Token' + }, + token_type: { + type: 'string', + title: 'Token Type', + default: 'bearer' + } + }, + type: 'object', + required: ['access_token'], + title: 'Token' +} as const; + +export const UpdatePasswordSchema = { + properties: { + current_password: { + type: 'string', + maxLength: 128, + minLength: 8, + title: 'Current Password' + }, + new_password: { + type: 'string', + maxLength: 128, + minLength: 8, + title: 'New Password' + } + }, + type: 'object', + required: ['current_password', 'new_password'], + title: 'UpdatePassword' +} as const; + +export const UserCreateSchema = { + properties: { + email: { + type: 'string', + maxLength: 255, + format: 'email', + title: 'Email' + }, + is_active: { + type: 'boolean', + title: 'Is Active', + default: true + }, + is_superuser: { + type: 'boolean', + title: 'Is Superuser', + default: false + }, + full_name: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Full Name' + }, + password: { + type: 'string', + maxLength: 128, + minLength: 8, + title: 'Password' + } + }, + type: 'object', + required: ['email', 'password'], + title: 'UserCreate' +} as const; + +export const UserPublicSchema = { + properties: { + email: { + type: 'string', + maxLength: 255, + format: 'email', + title: 'Email' + }, + is_active: { + type: 'boolean', + title: 'Is Active', + default: true + }, + is_superuser: { + type: 'boolean', + title: 'Is Superuser', + default: false + }, + full_name: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Full Name' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + created_at: { + anyOf: [ + { + type: 'string', + format: 'date-time' + }, + { + type: 'null' + } + ], + title: 'Created At' + } + }, + type: 'object', + required: ['email', 'id'], + title: 'UserPublic' +} as const; + +export const UserRegisterSchema = { + properties: { + email: { + type: 'string', + maxLength: 255, + format: 'email', + title: 'Email' + }, + password: { + type: 'string', + maxLength: 128, + minLength: 8, + title: 'Password' + }, + full_name: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Full Name' + } + }, + type: 'object', + required: ['email', 'password'], + title: 'UserRegister' +} as const; + +export const UserUpdateSchema = { + properties: { + email: { + anyOf: [ + { + type: 'string', + maxLength: 255, + format: 'email' + }, + { + type: 'null' + } + ], + title: 'Email' + }, + is_active: { + type: 'boolean', + title: 'Is Active', + default: true + }, + is_superuser: { + type: 'boolean', + title: 'Is Superuser', + default: false + }, + full_name: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Full Name' + }, + password: { + anyOf: [ + { + type: 'string', + maxLength: 128, + minLength: 8 + }, + { + type: 'null' + } + ], + title: 'Password' + } + }, + type: 'object', + title: 'UserUpdate' +} as const; + +export const UserUpdateMeSchema = { + properties: { + full_name: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Full Name' + }, + email: { + anyOf: [ + { + type: 'string', + maxLength: 255, + format: 'email' + }, + { + type: 'null' + } + ], + title: 'Email' + } + }, + type: 'object', + title: 'UserUpdateMe' +} as const; + +export const UsersPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/UserPublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'UsersPublic' +} as const; + +export const ValidationErrorSchema = { + properties: { + loc: { + items: { + anyOf: [ + { + type: 'string' + }, + { + type: 'integer' + } + ] + }, + type: 'array', + title: 'Location' + }, + msg: { + type: 'string', + title: 'Message' + }, + type: { + type: 'string', + title: 'Error Type' + } + }, + type: 'object', + required: ['loc', 'msg', 'type'], + title: 'ValidationError' +} as const; \ No newline at end of file diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts new file mode 100644 index 0000000..ba79e3f --- /dev/null +++ b/frontend/src/client/sdk.gen.ts @@ -0,0 +1,468 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { CancelablePromise } from './core/CancelablePromise'; +import { OpenAPI } from './core/OpenAPI'; +import { request as __request } from './core/request'; +import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; + +export class ItemsService { + /** + * Read Items + * Retrieve items. + * @param data The data for the request. + * @param data.skip + * @param data.limit + * @returns ItemsPublic Successful Response + * @throws ApiError + */ + public static readItems(data: ItemsReadItemsData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/items/', + query: { + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Create Item + * Create new item. + * @param data The data for the request. + * @param data.requestBody + * @returns ItemPublic Successful Response + * @throws ApiError + */ + public static createItem(data: ItemsCreateItemData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/items/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Item + * Get item by ID. + * @param data The data for the request. + * @param data.id + * @returns ItemPublic Successful Response + * @throws ApiError + */ + public static readItem(data: ItemsReadItemData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/items/{id}', + path: { + id: data.id + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Item + * Update an item. + * @param data The data for the request. + * @param data.id + * @param data.requestBody + * @returns ItemPublic Successful Response + * @throws ApiError + */ + public static updateItem(data: ItemsUpdateItemData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/items/{id}', + path: { + id: data.id + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Item + * Delete an item. + * @param data The data for the request. + * @param data.id + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteItem(data: ItemsDeleteItemData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/items/{id}', + path: { + id: data.id + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + +export class LoginService { + /** + * Login Access Token + * OAuth2 compatible token login, get an access token for future requests + * @param data The data for the request. + * @param data.formData + * @returns Token Successful Response + * @throws ApiError + */ + public static loginAccessToken(data: LoginLoginAccessTokenData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/login/access-token', + formData: data.formData, + mediaType: 'application/x-www-form-urlencoded', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Test Token + * Test access token + * @returns UserPublic Successful Response + * @throws ApiError + */ + public static testToken(): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/login/test-token' + }); + } + + /** + * Recover Password + * Password Recovery + * @param data The data for the request. + * @param data.email + * @returns Message Successful Response + * @throws ApiError + */ + public static recoverPassword(data: LoginRecoverPasswordData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/password-recovery/{email}', + path: { + email: data.email + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Reset Password + * Reset password + * @param data The data for the request. + * @param data.requestBody + * @returns Message Successful Response + * @throws ApiError + */ + public static resetPassword(data: LoginResetPasswordData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/reset-password/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Recover Password Html Content + * HTML Content for Password Recovery + * @param data The data for the request. + * @param data.email + * @returns string Successful Response + * @throws ApiError + */ + public static recoverPasswordHtmlContent(data: LoginRecoverPasswordHtmlContentData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/password-recovery-html-content/{email}', + path: { + email: data.email + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + +export class PrivateService { + /** + * Create User + * Create a new user. + * @param data The data for the request. + * @param data.requestBody + * @returns UserPublic Successful Response + * @throws ApiError + */ + public static createUser(data: PrivateCreateUserData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/private/users/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } +} + +export class UsersService { + /** + * Read Users + * Retrieve users. + * @param data The data for the request. + * @param data.skip + * @param data.limit + * @returns UsersPublic Successful Response + * @throws ApiError + */ + public static readUsers(data: UsersReadUsersData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/users/', + query: { + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Create User + * Create new user. + * @param data The data for the request. + * @param data.requestBody + * @returns UserPublic Successful Response + * @throws ApiError + */ + public static createUser(data: UsersCreateUserData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/users/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read User Me + * Get current user. + * @returns UserPublic Successful Response + * @throws ApiError + */ + public static readUserMe(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/users/me' + }); + } + + /** + * Delete User Me + * Delete own user. + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteUserMe(): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/users/me' + }); + } + + /** + * Update User Me + * Update own user. + * @param data The data for the request. + * @param data.requestBody + * @returns UserPublic Successful Response + * @throws ApiError + */ + public static updateUserMe(data: UsersUpdateUserMeData): CancelablePromise { + return __request(OpenAPI, { + method: 'PATCH', + url: '/api/v1/users/me', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Password Me + * Update own password. + * @param data The data for the request. + * @param data.requestBody + * @returns Message Successful Response + * @throws ApiError + */ + public static updatePasswordMe(data: UsersUpdatePasswordMeData): CancelablePromise { + return __request(OpenAPI, { + method: 'PATCH', + url: '/api/v1/users/me/password', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Register User + * Create new user without the need to be logged in. + * @param data The data for the request. + * @param data.requestBody + * @returns UserPublic Successful Response + * @throws ApiError + */ + public static registerUser(data: UsersRegisterUserData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/users/signup', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read User By Id + * Get a specific user by id. + * @param data The data for the request. + * @param data.userId + * @returns UserPublic Successful Response + * @throws ApiError + */ + public static readUserById(data: UsersReadUserByIdData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/users/{user_id}', + path: { + user_id: data.userId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update User + * Update a user. + * @param data The data for the request. + * @param data.userId + * @param data.requestBody + * @returns UserPublic Successful Response + * @throws ApiError + */ + public static updateUser(data: UsersUpdateUserData): CancelablePromise { + return __request(OpenAPI, { + method: 'PATCH', + url: '/api/v1/users/{user_id}', + path: { + user_id: data.userId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete User + * Delete a user. + * @param data The data for the request. + * @param data.userId + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteUser(data: UsersDeleteUserData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/users/{user_id}', + path: { + user_id: data.userId + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + +export class UtilsService { + /** + * Test Email + * Test emails. + * @param data The data for the request. + * @param data.emailTo + * @returns Message Successful Response + * @throws ApiError + */ + public static testEmail(data: UtilsTestEmailData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/utils/test-email/', + query: { + email_to: data.emailTo + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Health Check + * @returns boolean Successful Response + * @throws ApiError + */ + public static healthCheck(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/utils/health-check/' + }); + } +} \ No newline at end of file diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts new file mode 100644 index 0000000..e62b56c --- /dev/null +++ b/frontend/src/client/types.gen.ts @@ -0,0 +1,236 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type Body_login_login_access_token = { + grant_type?: (string | null); + username: string; + password: string; + scope?: string; + client_id?: (string | null); + client_secret?: (string | null); +}; + +export type HTTPValidationError = { + detail?: Array; +}; + +export type ItemCreate = { + title: string; + description?: (string | null); +}; + +export type ItemPublic = { + title: string; + description?: (string | null); + id: string; + owner_id: string; + created_at?: (string | null); +}; + +export type ItemsPublic = { + data: Array; + count: number; +}; + +export type ItemUpdate = { + title?: (string | null); + description?: (string | null); +}; + +export type Message = { + message: string; +}; + +export type NewPassword = { + token: string; + new_password: string; +}; + +export type PrivateUserCreate = { + email: string; + password: string; + full_name: string; + is_verified?: boolean; +}; + +export type Token = { + access_token: string; + token_type?: string; +}; + +export type UpdatePassword = { + current_password: string; + new_password: string; +}; + +export type UserCreate = { + email: string; + is_active?: boolean; + is_superuser?: boolean; + full_name?: (string | null); + password: string; +}; + +export type UserPublic = { + email: string; + is_active?: boolean; + is_superuser?: boolean; + full_name?: (string | null); + id: string; + created_at?: (string | null); +}; + +export type UserRegister = { + email: string; + password: string; + full_name?: (string | null); +}; + +export type UsersPublic = { + data: Array; + count: number; +}; + +export type UserUpdate = { + email?: (string | null); + is_active?: boolean; + is_superuser?: boolean; + full_name?: (string | null); + password?: (string | null); +}; + +export type UserUpdateMe = { + full_name?: (string | null); + email?: (string | null); +}; + +export type ValidationError = { + loc: Array<(string | number)>; + msg: string; + type: string; +}; + +export type ItemsReadItemsData = { + limit?: number; + skip?: number; +}; + +export type ItemsReadItemsResponse = (ItemsPublic); + +export type ItemsCreateItemData = { + requestBody: ItemCreate; +}; + +export type ItemsCreateItemResponse = (ItemPublic); + +export type ItemsReadItemData = { + id: string; +}; + +export type ItemsReadItemResponse = (ItemPublic); + +export type ItemsUpdateItemData = { + id: string; + requestBody: ItemUpdate; +}; + +export type ItemsUpdateItemResponse = (ItemPublic); + +export type ItemsDeleteItemData = { + id: string; +}; + +export type ItemsDeleteItemResponse = (Message); + +export type LoginLoginAccessTokenData = { + formData: Body_login_login_access_token; +}; + +export type LoginLoginAccessTokenResponse = (Token); + +export type LoginTestTokenResponse = (UserPublic); + +export type LoginRecoverPasswordData = { + email: string; +}; + +export type LoginRecoverPasswordResponse = (Message); + +export type LoginResetPasswordData = { + requestBody: NewPassword; +}; + +export type LoginResetPasswordResponse = (Message); + +export type LoginRecoverPasswordHtmlContentData = { + email: string; +}; + +export type LoginRecoverPasswordHtmlContentResponse = (string); + +export type PrivateCreateUserData = { + requestBody: PrivateUserCreate; +}; + +export type PrivateCreateUserResponse = (UserPublic); + +export type UsersReadUsersData = { + limit?: number; + skip?: number; +}; + +export type UsersReadUsersResponse = (UsersPublic); + +export type UsersCreateUserData = { + requestBody: UserCreate; +}; + +export type UsersCreateUserResponse = (UserPublic); + +export type UsersReadUserMeResponse = (UserPublic); + +export type UsersDeleteUserMeResponse = (Message); + +export type UsersUpdateUserMeData = { + requestBody: UserUpdateMe; +}; + +export type UsersUpdateUserMeResponse = (UserPublic); + +export type UsersUpdatePasswordMeData = { + requestBody: UpdatePassword; +}; + +export type UsersUpdatePasswordMeResponse = (Message); + +export type UsersRegisterUserData = { + requestBody: UserRegister; +}; + +export type UsersRegisterUserResponse = (UserPublic); + +export type UsersReadUserByIdData = { + userId: string; +}; + +export type UsersReadUserByIdResponse = (UserPublic); + +export type UsersUpdateUserData = { + requestBody: UserUpdate; + userId: string; +}; + +export type UsersUpdateUserResponse = (UserPublic); + +export type UsersDeleteUserData = { + userId: string; +}; + +export type UsersDeleteUserResponse = (Message); + +export type UtilsTestEmailData = { + emailTo: string; +}; + +export type UtilsTestEmailResponse = (Message); + +export type UtilsHealthCheckResponse = (boolean); \ No newline at end of file diff --git a/frontend/src/components/Admin/AddUser.tsx b/frontend/src/components/Admin/AddUser.tsx new file mode 100644 index 0000000..a0b534b --- /dev/null +++ b/frontend/src/components/Admin/AddUser.tsx @@ -0,0 +1,238 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { Plus } from "lucide-react" +import { useState } from "react" +import { useForm } from "react-hook-form" +import { z } from "zod" + +import { type UserCreate, UsersService } from "@/client" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { LoadingButton } from "@/components/ui/loading-button" +import useCustomToast from "@/hooks/useCustomToast" +import { handleError } from "@/utils" + +const formSchema = z + .object({ + email: z.email({ message: "Invalid email address" }), + full_name: z.string().optional(), + password: z + .string() + .min(1, { message: "Password is required" }) + .min(8, { message: "Password must be at least 8 characters" }), + confirm_password: z + .string() + .min(1, { message: "Please confirm your password" }), + is_superuser: z.boolean(), + is_active: z.boolean(), + }) + .refine((data) => data.password === data.confirm_password, { + message: "The passwords don't match", + path: ["confirm_password"], + }) + +type FormData = z.infer + +const AddUser = () => { + const [isOpen, setIsOpen] = useState(false) + const queryClient = useQueryClient() + const { showSuccessToast, showErrorToast } = useCustomToast() + + const form = useForm({ + resolver: zodResolver(formSchema), + mode: "onBlur", + criteriaMode: "all", + defaultValues: { + email: "", + full_name: "", + password: "", + confirm_password: "", + is_superuser: false, + is_active: false, + }, + }) + + const mutation = useMutation({ + mutationFn: (data: UserCreate) => + UsersService.createUser({ requestBody: data }), + onSuccess: () => { + showSuccessToast("User created successfully") + form.reset() + setIsOpen(false) + }, + onError: handleError.bind(showErrorToast), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["users"] }) + }, + }) + + const onSubmit = (data: FormData) => { + mutation.mutate(data) + } + + return ( + + + + + + + Add User + + Fill in the form below to add a new user to the system. + + +
+ +
+ ( + + + Email * + + + + + + + )} + /> + + ( + + Full Name + + + + + + )} + /> + + ( + + + Set Password * + + + + + + + )} + /> + + ( + + + Confirm Password{" "} + * + + + + + + + )} + /> + + ( + + + + + Is superuser? + + )} + /> + + ( + + + + + Is active? + + )} + /> +
+ + + + + + + Save + + +
+ +
+
+ ) +} + +export default AddUser diff --git a/frontend/src/components/Admin/DeleteUser.tsx b/frontend/src/components/Admin/DeleteUser.tsx new file mode 100644 index 0000000..4ffd023 --- /dev/null +++ b/frontend/src/components/Admin/DeleteUser.tsx @@ -0,0 +1,95 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { Trash2 } from "lucide-react" +import { useState } from "react" +import { useForm } from "react-hook-form" + +import { UsersService } from "@/client" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { DropdownMenuItem } from "@/components/ui/dropdown-menu" +import { LoadingButton } from "@/components/ui/loading-button" +import useCustomToast from "@/hooks/useCustomToast" +import { handleError } from "@/utils" + +interface DeleteUserProps { + id: string + onSuccess: () => void +} + +const DeleteUser = ({ id, onSuccess }: DeleteUserProps) => { + const [isOpen, setIsOpen] = useState(false) + const queryClient = useQueryClient() + const { showSuccessToast, showErrorToast } = useCustomToast() + const { handleSubmit } = useForm() + + const deleteUser = async (id: string) => { + await UsersService.deleteUser({ userId: id }) + } + + const mutation = useMutation({ + mutationFn: deleteUser, + onSuccess: () => { + showSuccessToast("The user was deleted successfully") + setIsOpen(false) + onSuccess() + }, + onError: handleError.bind(showErrorToast), + onSettled: () => { + queryClient.invalidateQueries() + }, + }) + + const onSubmit = async () => { + mutation.mutate(id) + } + + return ( + + e.preventDefault()} + onClick={() => setIsOpen(true)} + > + + Delete User + + +
+ + Delete User + + All items associated with this user will also be{" "} + permanently deleted. Are you sure? You will not + be able to undo this action. + + + + + + + + + Delete + + +
+
+
+ ) +} + +export default DeleteUser diff --git a/frontend/src/components/Admin/EditUser.tsx b/frontend/src/components/Admin/EditUser.tsx new file mode 100644 index 0000000..172904f --- /dev/null +++ b/frontend/src/components/Admin/EditUser.tsx @@ -0,0 +1,239 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { Pencil } from "lucide-react" +import { useState } from "react" +import { useForm } from "react-hook-form" +import { z } from "zod" + +import { type UserPublic, UsersService } from "@/client" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { DropdownMenuItem } from "@/components/ui/dropdown-menu" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { LoadingButton } from "@/components/ui/loading-button" +import useCustomToast from "@/hooks/useCustomToast" +import { handleError } from "@/utils" + +const formSchema = z + .object({ + email: z.email({ message: "Invalid email address" }), + full_name: z.string().optional(), + password: z + .string() + .min(8, { message: "Password must be at least 8 characters" }) + .optional() + .or(z.literal("")), + confirm_password: z.string().optional(), + is_superuser: z.boolean().optional(), + is_active: z.boolean().optional(), + }) + .refine((data) => !data.password || data.password === data.confirm_password, { + message: "The passwords don't match", + path: ["confirm_password"], + }) + +type FormData = z.infer + +interface EditUserProps { + user: UserPublic + onSuccess: () => void +} + +const EditUser = ({ user, onSuccess }: EditUserProps) => { + const [isOpen, setIsOpen] = useState(false) + const queryClient = useQueryClient() + const { showSuccessToast, showErrorToast } = useCustomToast() + + const form = useForm({ + resolver: zodResolver(formSchema), + mode: "onBlur", + criteriaMode: "all", + defaultValues: { + email: user.email, + full_name: user.full_name ?? undefined, + is_superuser: user.is_superuser, + is_active: user.is_active, + }, + }) + + const mutation = useMutation({ + mutationFn: (data: FormData) => + UsersService.updateUser({ userId: user.id, requestBody: data }), + onSuccess: () => { + showSuccessToast("User updated successfully") + setIsOpen(false) + onSuccess() + }, + onError: handleError.bind(showErrorToast), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["users"] }) + }, + }) + + const onSubmit = (data: FormData) => { + // exclude confirm_password from submission data and remove password if empty + const { confirm_password: _, ...submitData } = data + if (!submitData.password) { + delete submitData.password + } + mutation.mutate(submitData) + } + + return ( + + e.preventDefault()} + onClick={() => setIsOpen(true)} + > + + Edit User + + +
+ + + Edit User + + Update the user details below. + + +
+ ( + + + Email * + + + + + + + )} + /> + + ( + + Full Name + + + + + + )} + /> + + ( + + Set Password + + + + + + )} + /> + + ( + + Confirm Password + + + + + + )} + /> + + ( + + + + + Is superuser? + + )} + /> + + ( + + + + + Is active? + + )} + /> +
+ + + + + + + Save + + +
+ +
+
+ ) +} + +export default EditUser diff --git a/frontend/src/components/Admin/UserActionsMenu.tsx b/frontend/src/components/Admin/UserActionsMenu.tsx new file mode 100644 index 0000000..01f71cb --- /dev/null +++ b/frontend/src/components/Admin/UserActionsMenu.tsx @@ -0,0 +1,40 @@ +import { EllipsisVertical } from "lucide-react" +import { useState } from "react" + +import type { UserPublic } from "@/client" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import useAuth from "@/hooks/useAuth" +import DeleteUser from "./DeleteUser" +import EditUser from "./EditUser" + +interface UserActionsMenuProps { + user: UserPublic +} + +export const UserActionsMenu = ({ user }: UserActionsMenuProps) => { + const [open, setOpen] = useState(false) + const { user: currentUser } = useAuth() + + if (user.id === currentUser?.id) { + return null + } + + return ( + + + + + + setOpen(false)} /> + setOpen(false)} /> + + + ) +} diff --git a/frontend/src/components/Admin/columns.tsx b/frontend/src/components/Admin/columns.tsx new file mode 100644 index 0000000..8b0fa13 --- /dev/null +++ b/frontend/src/components/Admin/columns.tsx @@ -0,0 +1,76 @@ +import type { ColumnDef } from "@tanstack/react-table" + +import type { UserPublic } from "@/client" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" +import { UserActionsMenu } from "./UserActionsMenu" + +export type UserTableData = UserPublic & { + isCurrentUser: boolean +} + +export const columns: ColumnDef[] = [ + { + accessorKey: "full_name", + header: "Full Name", + cell: ({ row }) => { + const fullName = row.original.full_name + return ( +
+ + {fullName || "N/A"} + + {row.original.isCurrentUser && ( + + You + + )} +
+ ) + }, + }, + { + accessorKey: "email", + header: "Email", + cell: ({ row }) => ( + {row.original.email} + ), + }, + { + accessorKey: "is_superuser", + header: "Role", + cell: ({ row }) => ( + + {row.original.is_superuser ? "Superuser" : "User"} + + ), + }, + { + accessorKey: "is_active", + header: "Status", + cell: ({ row }) => ( +
+ + + {row.original.is_active ? "Active" : "Inactive"} + +
+ ), + }, + { + id: "actions", + header: () => Actions, + cell: ({ row }) => ( +
+ +
+ ), + }, +] diff --git a/frontend/src/components/Common/Appearance.tsx b/frontend/src/components/Common/Appearance.tsx new file mode 100644 index 0000000..1c56f6c --- /dev/null +++ b/frontend/src/components/Common/Appearance.tsx @@ -0,0 +1,105 @@ +import { Monitor, Moon, Sun } from "lucide-react" + +import { type Theme, useTheme } from "@/components/theme-provider" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar" + +type LucideIcon = React.FC> + +const ICON_MAP: Record = { + system: Monitor, + light: Sun, + dark: Moon, +} + +export const SidebarAppearance = () => { + const { isMobile } = useSidebar() + const { setTheme, theme } = useTheme() + const Icon = ICON_MAP[theme] + + return ( + + + + + + Appearance + Toggle theme + + + + setTheme("light")} + > + + Light + + setTheme("dark")} + > + + Dark + + setTheme("system")}> + + System + + + + + ) +} + +export const Appearance = () => { + const { setTheme } = useTheme() + + return ( +
+ + + + + + setTheme("light")} + > + + Light + + setTheme("dark")} + > + + Dark + + setTheme("system")}> + + System + + + +
+ ) +} diff --git a/frontend/src/components/Common/AuthLayout.tsx b/frontend/src/components/Common/AuthLayout.tsx new file mode 100644 index 0000000..4551610 --- /dev/null +++ b/frontend/src/components/Common/AuthLayout.tsx @@ -0,0 +1,26 @@ +import { Appearance } from "@/components/Common/Appearance" +import { Logo } from "@/components/Common/Logo" +import { Footer } from "./Footer" + +interface AuthLayoutProps { + children: React.ReactNode +} + +export function AuthLayout({ children }: AuthLayoutProps) { + return ( +
+
+ +
+
+
+ +
+
+
{children}
+
+
+
+
+ ) +} diff --git a/frontend/src/components/Common/DataTable.tsx b/frontend/src/components/Common/DataTable.tsx new file mode 100644 index 0000000..e5bf2ae --- /dev/null +++ b/frontend/src/components/Common/DataTable.tsx @@ -0,0 +1,194 @@ +import { + type ColumnDef, + flexRender, + getCoreRowModel, + getPaginationRowModel, + useReactTable, +} from "@tanstack/react-table" +import { + ChevronLeft, + ChevronRight, + ChevronsLeft, + ChevronsRight, +} from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +interface DataTableProps { + columns: ColumnDef[] + data: TData[] +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }) + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ) + })} + + ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No results found. + + + )} + +
+ + {table.getPageCount() > 1 && ( +
+
+
+ Showing{" "} + {table.getState().pagination.pageIndex * + table.getState().pagination.pageSize + + 1}{" "} + to{" "} + {Math.min( + (table.getState().pagination.pageIndex + 1) * + table.getState().pagination.pageSize, + data.length, + )}{" "} + of{" "} + {data.length}{" "} + entries +
+
+

Rows per page

+ +
+
+ +
+
+ Page + + {table.getState().pagination.pageIndex + 1} + + of + + {table.getPageCount()} + +
+ +
+ + + + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/components/Common/ErrorComponent.tsx b/frontend/src/components/Common/ErrorComponent.tsx new file mode 100644 index 0000000..e4a97d2 --- /dev/null +++ b/frontend/src/components/Common/ErrorComponent.tsx @@ -0,0 +1,29 @@ +import { Link } from "@tanstack/react-router" +import { Button } from "@/components/ui/button" + +const ErrorComponent = () => { + return ( +
+
+
+ + Error + + Oops! +
+
+ +

+ Something went wrong. Please try again. +

+ + + +
+ ) +} + +export default ErrorComponent diff --git a/frontend/src/components/Common/Footer.tsx b/frontend/src/components/Common/Footer.tsx new file mode 100644 index 0000000..279e1e7 --- /dev/null +++ b/frontend/src/components/Common/Footer.tsx @@ -0,0 +1,44 @@ +import { FaGithub, FaLinkedinIn } from "react-icons/fa" +import { FaXTwitter } from "react-icons/fa6" + +const socialLinks = [ + { + icon: FaGithub, + href: "https://github.com/fastapi/fastapi", + label: "GitHub", + }, + { icon: FaXTwitter, href: "https://x.com/fastapi", label: "X" }, + { + icon: FaLinkedinIn, + href: "https://linkedin.com/company/fastapi", + label: "LinkedIn", + }, +] + +export function Footer() { + const currentYear = new Date().getFullYear() + + return ( +
+
+

+ Full Stack FastAPI Template - {currentYear} +

+
+ {socialLinks.map(({ icon: Icon, href, label }) => ( + + + + ))} +
+
+
+ ) +} diff --git a/frontend/src/components/Common/Logo.tsx b/frontend/src/components/Common/Logo.tsx new file mode 100644 index 0000000..05c299f --- /dev/null +++ b/frontend/src/components/Common/Logo.tsx @@ -0,0 +1,60 @@ +import { Link } from "@tanstack/react-router" + +import { useTheme } from "@/components/theme-provider" +import { cn } from "@/lib/utils" +import icon from "/assets/images/fastapi-icon.svg" +import iconLight from "/assets/images/fastapi-icon-light.svg" +import logo from "/assets/images/fastapi-logo.svg" +import logoLight from "/assets/images/fastapi-logo-light.svg" + +interface LogoProps { + variant?: "full" | "icon" | "responsive" + className?: string + asLink?: boolean +} + +export function Logo({ + variant = "full", + className, + asLink = true, +}: LogoProps) { + const { resolvedTheme } = useTheme() + const isDark = resolvedTheme === "dark" + + const fullLogo = isDark ? logoLight : logo + const iconLogo = isDark ? iconLight : icon + + const content = + variant === "responsive" ? ( + <> + FastAPI + + + ) : ( + FastAPI + ) + + if (!asLink) { + return content + } + + return {content} +} diff --git a/frontend/src/components/Common/NotFound.tsx b/frontend/src/components/Common/NotFound.tsx new file mode 100644 index 0000000..04f42b8 --- /dev/null +++ b/frontend/src/components/Common/NotFound.tsx @@ -0,0 +1,31 @@ +import { Link } from "@tanstack/react-router" +import { Button } from "@/components/ui/button" + +const NotFound = () => { + return ( +
+
+
+ + 404 + + Oops! +
+
+ +

+ The page you are looking for was not found. +

+
+ + + +
+
+ ) +} + +export default NotFound diff --git a/frontend/src/components/Items/AddItem.tsx b/frontend/src/components/Items/AddItem.tsx new file mode 100644 index 0000000..7c7c10c --- /dev/null +++ b/frontend/src/components/Items/AddItem.tsx @@ -0,0 +1,144 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { Plus } from "lucide-react" +import { useState } from "react" +import { useForm } from "react-hook-form" +import { z } from "zod" + +import { type ItemCreate, ItemsService } from "@/client" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { LoadingButton } from "@/components/ui/loading-button" +import useCustomToast from "@/hooks/useCustomToast" +import { handleError } from "@/utils" + +const formSchema = z.object({ + title: z.string().min(1, { message: "Title is required" }), + description: z.string().optional(), +}) + +type FormData = z.infer + +const AddItem = () => { + const [isOpen, setIsOpen] = useState(false) + const queryClient = useQueryClient() + const { showSuccessToast, showErrorToast } = useCustomToast() + + const form = useForm({ + resolver: zodResolver(formSchema), + mode: "onBlur", + criteriaMode: "all", + defaultValues: { + title: "", + description: "", + }, + }) + + const mutation = useMutation({ + mutationFn: (data: ItemCreate) => + ItemsService.createItem({ requestBody: data }), + onSuccess: () => { + showSuccessToast("Item created successfully") + form.reset() + setIsOpen(false) + }, + onError: handleError.bind(showErrorToast), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["items"] }) + }, + }) + + const onSubmit = (data: FormData) => { + mutation.mutate(data) + } + + return ( + + + + + + + Add Item + + Fill in the details to add a new item. + + +
+ +
+ ( + + + Title * + + + + + + + )} + /> + + ( + + Description + + + + + + )} + /> +
+ + + + + + + Save + + +
+ +
+
+ ) +} + +export default AddItem diff --git a/frontend/src/components/Items/DeleteItem.tsx b/frontend/src/components/Items/DeleteItem.tsx new file mode 100644 index 0000000..9e61c34 --- /dev/null +++ b/frontend/src/components/Items/DeleteItem.tsx @@ -0,0 +1,94 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { Trash2 } from "lucide-react" +import { useState } from "react" +import { useForm } from "react-hook-form" + +import { ItemsService } from "@/client" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { DropdownMenuItem } from "@/components/ui/dropdown-menu" +import { LoadingButton } from "@/components/ui/loading-button" +import useCustomToast from "@/hooks/useCustomToast" +import { handleError } from "@/utils" + +interface DeleteItemProps { + id: string + onSuccess: () => void +} + +const DeleteItem = ({ id, onSuccess }: DeleteItemProps) => { + const [isOpen, setIsOpen] = useState(false) + const queryClient = useQueryClient() + const { showSuccessToast, showErrorToast } = useCustomToast() + const { handleSubmit } = useForm() + + const deleteItem = async (id: string) => { + await ItemsService.deleteItem({ id: id }) + } + + const mutation = useMutation({ + mutationFn: deleteItem, + onSuccess: () => { + showSuccessToast("The item was deleted successfully") + setIsOpen(false) + onSuccess() + }, + onError: handleError.bind(showErrorToast), + onSettled: () => { + queryClient.invalidateQueries() + }, + }) + + const onSubmit = async () => { + mutation.mutate(id) + } + + return ( + + e.preventDefault()} + onClick={() => setIsOpen(true)} + > + + Delete Item + + +
+ + Delete Item + + This item will be permanently deleted. Are you sure? You will not + be able to undo this action. + + + + + + + + + Delete + + +
+
+
+ ) +} + +export default DeleteItem diff --git a/frontend/src/components/Items/EditItem.tsx b/frontend/src/components/Items/EditItem.tsx new file mode 100644 index 0000000..3d57f55 --- /dev/null +++ b/frontend/src/components/Items/EditItem.tsx @@ -0,0 +1,145 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { Pencil } from "lucide-react" +import { useState } from "react" +import { useForm } from "react-hook-form" +import { z } from "zod" + +import { type ItemPublic, ItemsService } from "@/client" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { DropdownMenuItem } from "@/components/ui/dropdown-menu" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { LoadingButton } from "@/components/ui/loading-button" +import useCustomToast from "@/hooks/useCustomToast" +import { handleError } from "@/utils" + +const formSchema = z.object({ + title: z.string().min(1, { message: "Title is required" }), + description: z.string().optional(), +}) + +type FormData = z.infer + +interface EditItemProps { + item: ItemPublic + onSuccess: () => void +} + +const EditItem = ({ item, onSuccess }: EditItemProps) => { + const [isOpen, setIsOpen] = useState(false) + const queryClient = useQueryClient() + const { showSuccessToast, showErrorToast } = useCustomToast() + + const form = useForm({ + resolver: zodResolver(formSchema), + mode: "onBlur", + criteriaMode: "all", + defaultValues: { + title: item.title, + description: item.description ?? undefined, + }, + }) + + const mutation = useMutation({ + mutationFn: (data: FormData) => + ItemsService.updateItem({ id: item.id, requestBody: data }), + onSuccess: () => { + showSuccessToast("Item updated successfully") + setIsOpen(false) + onSuccess() + }, + onError: handleError.bind(showErrorToast), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["items"] }) + }, + }) + + const onSubmit = (data: FormData) => { + mutation.mutate(data) + } + + return ( + + e.preventDefault()} + onClick={() => setIsOpen(true)} + > + + Edit Item + + +
+ + + Edit Item + + Update the item details below. + + +
+ ( + + + Title * + + + + + + + )} + /> + + ( + + Description + + + + + + )} + /> +
+ + + + + + + Save + + +
+ +
+
+ ) +} + +export default EditItem diff --git a/frontend/src/components/Items/ItemActionsMenu.tsx b/frontend/src/components/Items/ItemActionsMenu.tsx new file mode 100644 index 0000000..1efe7bf --- /dev/null +++ b/frontend/src/components/Items/ItemActionsMenu.tsx @@ -0,0 +1,34 @@ +import { EllipsisVertical } from "lucide-react" +import { useState } from "react" + +import type { ItemPublic } from "@/client" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import DeleteItem from "../Items/DeleteItem" +import EditItem from "../Items/EditItem" + +interface ItemActionsMenuProps { + item: ItemPublic +} + +export const ItemActionsMenu = ({ item }: ItemActionsMenuProps) => { + const [open, setOpen] = useState(false) + + return ( + + + + + + setOpen(false)} /> + setOpen(false)} /> + + + ) +} diff --git a/frontend/src/components/Items/columns.tsx b/frontend/src/components/Items/columns.tsx new file mode 100644 index 0000000..b41be2a --- /dev/null +++ b/frontend/src/components/Items/columns.tsx @@ -0,0 +1,73 @@ +import type { ColumnDef } from "@tanstack/react-table" +import { Check, Copy } from "lucide-react" + +import type { ItemPublic } from "@/client" +import { Button } from "@/components/ui/button" +import { useCopyToClipboard } from "@/hooks/useCopyToClipboard" +import { cn } from "@/lib/utils" +import { ItemActionsMenu } from "./ItemActionsMenu" + +function CopyId({ id }: { id: string }) { + const [copiedText, copy] = useCopyToClipboard() + const isCopied = copiedText === id + + return ( +
+ {id} + +
+ ) +} + +export const columns: ColumnDef[] = [ + { + accessorKey: "id", + header: "ID", + cell: ({ row }) => , + }, + { + accessorKey: "title", + header: "Title", + cell: ({ row }) => ( + {row.original.title} + ), + }, + { + accessorKey: "description", + header: "Description", + cell: ({ row }) => { + const description = row.original.description + return ( + + {description || "No description"} + + ) + }, + }, + { + id: "actions", + header: () => Actions, + cell: ({ row }) => ( +
+ +
+ ), + }, +] diff --git a/frontend/src/components/Pending/PendingItems.tsx b/frontend/src/components/Pending/PendingItems.tsx new file mode 100644 index 0000000..9658335 --- /dev/null +++ b/frontend/src/components/Pending/PendingItems.tsx @@ -0,0 +1,46 @@ +import { Skeleton } from "@/components/ui/skeleton" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +const PendingItems = () => ( + + + + ID + Title + Description + + Actions + + + + + {Array.from({ length: 5 }).map((_, index) => ( + + + + + + + + + + + +
+ +
+
+
+ ))} +
+
+) + +export default PendingItems diff --git a/frontend/src/components/Pending/PendingUsers.tsx b/frontend/src/components/Pending/PendingUsers.tsx new file mode 100644 index 0000000..85af2b1 --- /dev/null +++ b/frontend/src/components/Pending/PendingUsers.tsx @@ -0,0 +1,53 @@ +import { Skeleton } from "@/components/ui/skeleton" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +const PendingUsers = () => ( + + + + Full Name + Email + Role + Status + + Actions + + + + + {Array.from({ length: 5 }).map((_, index) => ( + + + + + + + + + + + +
+ + +
+
+ +
+ +
+
+
+ ))} +
+
+) + +export default PendingUsers diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx new file mode 100644 index 0000000..8502bcb --- /dev/null +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -0,0 +1,43 @@ +import { Briefcase, Home, Users } from "lucide-react" + +import { SidebarAppearance } from "@/components/Common/Appearance" +import { Logo } from "@/components/Common/Logo" +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, +} from "@/components/ui/sidebar" +import useAuth from "@/hooks/useAuth" +import { type Item, Main } from "./Main" +import { User } from "./User" + +const baseItems: Item[] = [ + { icon: Home, title: "Dashboard", path: "/" }, + { icon: Briefcase, title: "Items", path: "/items" }, +] + +export function AppSidebar() { + const { user: currentUser } = useAuth() + + const items = currentUser?.is_superuser + ? [...baseItems, { icon: Users, title: "Admin", path: "/admin" }] + : baseItems + + return ( + + + + + +
+ + + + + + + ) +} + +export default AppSidebar diff --git a/frontend/src/components/Sidebar/Main.tsx b/frontend/src/components/Sidebar/Main.tsx new file mode 100644 index 0000000..db4d7bc --- /dev/null +++ b/frontend/src/components/Sidebar/Main.tsx @@ -0,0 +1,60 @@ +import { Link as RouterLink, useRouterState } from "@tanstack/react-router" +import type { LucideIcon } from "lucide-react" + +import { + SidebarGroup, + SidebarGroupContent, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar" + +export type Item = { + icon: LucideIcon + title: string + path: string +} + +interface MainProps { + items: Item[] +} + +export function Main({ items }: MainProps) { + const { isMobile, setOpenMobile } = useSidebar() + const router = useRouterState() + const currentPath = router.location.pathname + + const handleMenuClick = () => { + if (isMobile) { + setOpenMobile(false) + } + } + + return ( + + + + {items.map((item) => { + const isActive = currentPath === item.path + + return ( + + + + + {item.title} + + + + ) + })} + + + + ) +} diff --git a/frontend/src/components/Sidebar/User.tsx b/frontend/src/components/Sidebar/User.tsx new file mode 100644 index 0000000..12c6362 --- /dev/null +++ b/frontend/src/components/Sidebar/User.tsx @@ -0,0 +1,97 @@ +import { Link as RouterLink } from "@tanstack/react-router" +import { ChevronsUpDown, LogOut, Settings } from "lucide-react" + +import { Avatar, AvatarFallback } from "@/components/ui/avatar" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar" +import useAuth from "@/hooks/useAuth" +import { getInitials } from "@/utils" + +interface UserInfoProps { + fullName?: string + email?: string +} + +function UserInfo({ fullName, email }: UserInfoProps) { + return ( +
+ + + {getInitials(fullName || "User")} + + +
+

{fullName}

+

{email}

+
+
+ ) +} + +export function User({ user }: { user: any }) { + const { logout } = useAuth() + const { isMobile, setOpenMobile } = useSidebar() + + if (!user) return null + + const handleMenuClick = () => { + if (isMobile) { + setOpenMobile(false) + } + } + const handleLogout = async () => { + logout() + } + + return ( + + + + + + + + + + + + + + + + + + User Settings + + + + + Log Out + + + + + + ) +} diff --git a/frontend/src/components/UserSettings/ChangePassword.tsx b/frontend/src/components/UserSettings/ChangePassword.tsx new file mode 100644 index 0000000..aeb8537 --- /dev/null +++ b/frontend/src/components/UserSettings/ChangePassword.tsx @@ -0,0 +1,146 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { useMutation } from "@tanstack/react-query" +import { useForm } from "react-hook-form" +import { z } from "zod" + +import { type UpdatePassword, UsersService } from "@/client" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { LoadingButton } from "@/components/ui/loading-button" +import { PasswordInput } from "@/components/ui/password-input" +import useCustomToast from "@/hooks/useCustomToast" +import { handleError } from "@/utils" + +const formSchema = z + .object({ + current_password: z + .string() + .min(1, { message: "Password is required" }) + .min(8, { message: "Password must be at least 8 characters" }), + new_password: z + .string() + .min(1, { message: "Password is required" }) + .min(8, { message: "Password must be at least 8 characters" }), + confirm_password: z + .string() + .min(1, { message: "Password confirmation is required" }), + }) + .refine((data) => data.new_password === data.confirm_password, { + message: "The passwords don't match", + path: ["confirm_password"], + }) + +type FormData = z.infer + +const ChangePassword = () => { + const { showSuccessToast, showErrorToast } = useCustomToast() + const form = useForm({ + resolver: zodResolver(formSchema), + mode: "onSubmit", + criteriaMode: "all", + defaultValues: { + current_password: "", + new_password: "", + confirm_password: "", + }, + }) + + const mutation = useMutation({ + mutationFn: (data: UpdatePassword) => + UsersService.updatePasswordMe({ requestBody: data }), + onSuccess: () => { + showSuccessToast("Password updated successfully") + form.reset() + }, + onError: handleError.bind(showErrorToast), + }) + + const onSubmit = async (data: FormData) => { + mutation.mutate(data) + } + + return ( +
+

Change Password

+
+ + ( + + Current Password + + + + + + )} + /> + + ( + + New Password + + + + + + )} + /> + + ( + + Confirm Password + + + + + + )} + /> + + + Update Password + + + +
+ ) +} + +export default ChangePassword diff --git a/frontend/src/components/UserSettings/DeleteAccount.tsx b/frontend/src/components/UserSettings/DeleteAccount.tsx new file mode 100644 index 0000000..7b9e895 --- /dev/null +++ b/frontend/src/components/UserSettings/DeleteAccount.tsx @@ -0,0 +1,15 @@ +import DeleteConfirmation from "./DeleteConfirmation" + +const DeleteAccount = () => { + return ( +
+

Delete Account

+

+ Permanently delete your account and all associated data. +

+ +
+ ) +} + +export default DeleteAccount diff --git a/frontend/src/components/UserSettings/DeleteConfirmation.tsx b/frontend/src/components/UserSettings/DeleteConfirmation.tsx new file mode 100644 index 0000000..06d76d9 --- /dev/null +++ b/frontend/src/components/UserSettings/DeleteConfirmation.tsx @@ -0,0 +1,82 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useForm } from "react-hook-form" + +import { UsersService } from "@/client" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { LoadingButton } from "@/components/ui/loading-button" +import useAuth from "@/hooks/useAuth" +import useCustomToast from "@/hooks/useCustomToast" +import { handleError } from "@/utils" + +const DeleteConfirmation = () => { + const queryClient = useQueryClient() + const { showSuccessToast, showErrorToast } = useCustomToast() + const { handleSubmit } = useForm() + const { logout } = useAuth() + + const mutation = useMutation({ + mutationFn: () => UsersService.deleteUserMe(), + onSuccess: () => { + showSuccessToast("Your account has been successfully deleted") + logout() + }, + onError: handleError.bind(showErrorToast), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["currentUser"] }) + }, + }) + + const onSubmit = async () => { + mutation.mutate() + } + + return ( + + + + + +
+ + Confirmation Required + + All your account data will be{" "} + permanently deleted. If you are sure, please + click "Confirm" to proceed. This action cannot be + undone. + + + + + + + + + Delete + + +
+
+
+ ) +} + +export default DeleteConfirmation diff --git a/frontend/src/components/UserSettings/UserInformation.tsx b/frontend/src/components/UserSettings/UserInformation.tsx new file mode 100644 index 0000000..4bfaf60 --- /dev/null +++ b/frontend/src/components/UserSettings/UserInformation.tsx @@ -0,0 +1,171 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useState } from "react" +import { useForm } from "react-hook-form" +import { z } from "zod" + +import { UsersService, type UserUpdateMe } from "@/client" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { LoadingButton } from "@/components/ui/loading-button" +import useAuth from "@/hooks/useAuth" +import useCustomToast from "@/hooks/useCustomToast" +import { cn } from "@/lib/utils" +import { handleError } from "@/utils" + +const formSchema = z.object({ + full_name: z.string().max(30).optional(), + email: z.email({ message: "Invalid email address" }), +}) + +type FormData = z.infer + +const UserInformation = () => { + const queryClient = useQueryClient() + const { showSuccessToast, showErrorToast } = useCustomToast() + const [editMode, setEditMode] = useState(false) + const { user: currentUser } = useAuth() + + const form = useForm({ + resolver: zodResolver(formSchema), + mode: "onBlur", + criteriaMode: "all", + defaultValues: { + full_name: currentUser?.full_name ?? undefined, + email: currentUser?.email, + }, + }) + + const toggleEditMode = () => { + setEditMode(!editMode) + } + + const mutation = useMutation({ + mutationFn: (data: UserUpdateMe) => + UsersService.updateUserMe({ requestBody: data }), + onSuccess: () => { + showSuccessToast("User updated successfully") + toggleEditMode() + }, + onError: handleError.bind(showErrorToast), + onSettled: () => { + queryClient.invalidateQueries() + }, + }) + + const onSubmit = (data: FormData) => { + const updateData: UserUpdateMe = {} + + // only include fields that have changed + if (data.full_name !== currentUser?.full_name) { + updateData.full_name = data.full_name + } + if (data.email !== currentUser?.email) { + updateData.email = data.email + } + + mutation.mutate(updateData) + } + + const onCancel = () => { + form.reset() + toggleEditMode() + } + + return ( +
+

User Information

+
+ + + editMode ? ( + + Full name + + + + + + ) : ( + + Full name +

+ {field.value || "N/A"} +

+
+ ) + } + /> + + + editMode ? ( + + Email + + + + + + ) : ( + + Email +

{field.value}

+
+ ) + } + /> + +
+ {editMode ? ( + <> + + Save + + + + ) : ( + + )} +
+ + +
+ ) +} + +export default UserInformation diff --git a/frontend/src/components/theme-provider.tsx b/frontend/src/components/theme-provider.tsx new file mode 100644 index 0000000..a582b28 --- /dev/null +++ b/frontend/src/components/theme-provider.tsx @@ -0,0 +1,115 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react" + +export type Theme = "dark" | "light" | "system" + +type ThemeProviderProps = { + children: React.ReactNode + defaultTheme?: Theme + storageKey?: string +} + +type ThemeProviderState = { + theme: Theme + resolvedTheme: "dark" | "light" + setTheme: (theme: Theme) => void +} + +const initialState: ThemeProviderState = { + theme: "system", + resolvedTheme: "light", + setTheme: () => null, +} + +const ThemeProviderContext = createContext(initialState) + +export function ThemeProvider({ + children, + defaultTheme = "system", + storageKey = "vite-ui-theme", + ...props +}: ThemeProviderProps) { + const [theme, setTheme] = useState( + () => (localStorage.getItem(storageKey) as Theme) || defaultTheme, + ) + + const getResolvedTheme = useCallback((theme: Theme): "dark" | "light" => { + if (theme === "system") { + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light" + } + return theme + }, []) + + const [resolvedTheme, setResolvedTheme] = useState<"dark" | "light">(() => + getResolvedTheme(theme), + ) + + const updateTheme = useCallback((newTheme: Theme) => { + const root = window.document.documentElement + + root.classList.remove("light", "dark") + + if (newTheme === "system") { + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") + .matches + ? "dark" + : "light" + + root.classList.add(systemTheme) + return + } + + root.classList.add(newTheme) + }, []) + + useEffect(() => { + updateTheme(theme) + setResolvedTheme(getResolvedTheme(theme)) + + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + + const handleChange = () => { + if (theme === "system") { + updateTheme("system") + setResolvedTheme(getResolvedTheme("system")) + } + } + + mediaQuery.addEventListener("change", handleChange) + + return () => { + mediaQuery.removeEventListener("change", handleChange) + } + }, [theme, updateTheme, getResolvedTheme]) + + const value = { + theme, + resolvedTheme, + setTheme: (theme: Theme) => { + localStorage.setItem(storageKey, theme) + setTheme(theme) + }, + } + + return ( + + {children} + + ) +} + +export const useTheme = () => { + const context = useContext(ThemeProviderContext) + + if (context === undefined) + throw new Error("useTheme must be used within a ThemeProvider") + + return context +} diff --git a/frontend/src/components/ui/alert.tsx b/frontend/src/components/ui/alert.tsx new file mode 100644 index 0000000..1421354 --- /dev/null +++ b/frontend/src/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/frontend/src/components/ui/avatar.tsx b/frontend/src/components/ui/avatar.tsx new file mode 100644 index 0000000..b7224f0 --- /dev/null +++ b/frontend/src/components/ui/avatar.tsx @@ -0,0 +1,51 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..fd3a406 --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/frontend/src/components/ui/button-group.tsx b/frontend/src/components/ui/button-group.tsx new file mode 100644 index 0000000..8600af0 --- /dev/null +++ b/frontend/src/components/ui/button-group.tsx @@ -0,0 +1,83 @@ +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Separator } from "@/components/ui/separator" + +const buttonGroupVariants = cva( + "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2", + { + variants: { + orientation: { + horizontal: + "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none", + vertical: + "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none", + }, + }, + defaultVariants: { + orientation: "horizontal", + }, + } +) + +function ButtonGroup({ + className, + orientation, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function ButtonGroupText({ + className, + asChild = false, + ...props +}: React.ComponentProps<"div"> & { + asChild?: boolean +}) { + const Comp = asChild ? Slot : "div" + + return ( + + ) +} + +function ButtonGroupSeparator({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, + buttonGroupVariants, +} diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..21409a0 --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,60 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx new file mode 100644 index 0000000..681ad98 --- /dev/null +++ b/frontend/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/frontend/src/components/ui/checkbox.tsx b/frontend/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..0e2a6cd --- /dev/null +++ b/frontend/src/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { CheckIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..6cb123b --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,141 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..dcd9026 --- /dev/null +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,257 @@ +"use client" + +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/frontend/src/components/ui/form.tsx b/frontend/src/components/ui/form.tsx new file mode 100644 index 0000000..7d7474c --- /dev/null +++ b/frontend/src/components/ui/form.tsx @@ -0,0 +1,165 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + FormProvider, + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState } = useFormContext() + const formState = useFormState({ name: fieldContext.name }) + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue +) + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId() + + return ( + +
+ + ) +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { error, formItemId } = useFormField() + + return ( +