diff --git a/fablednetmon/migrations/__init__.py b/fablednetmon/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fablednetmon/migrations/env.py b/fablednetmon/migrations/env.py new file mode 100644 index 0000000..e4b91ce --- /dev/null +++ b/fablednetmon/migrations/env.py @@ -0,0 +1,59 @@ +import asyncio +from logging.config import fileConfig +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config +from alembic import context +from fablednetmon.models.base import Base + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def get_url() -> str: + import os, yaml + cfg_path = os.environ.get("FABLEDNETMON_CONFIG", "config.yaml") + try: + with open(cfg_path) as f: + cfg = yaml.safe_load(f) or {} + url = cfg.get("database", {}).get("url", "") + except FileNotFoundError: + url = "" + return os.environ.get("FABLEDNETMON_DATABASE__URL", url) + + +def run_migrations_offline() -> None: + context.configure( + url=get_url(), target_metadata=target_metadata, + literal_binds=True, dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + cfg = config.get_section(config.config_ini_section, {}) + cfg["sqlalchemy.url"] = get_url() + connectable = async_engine_from_config(cfg, prefix="sqlalchemy.", poolclass=pool.NullPool) + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + await connectable.dispose() + + +def run_migrations_online() -> None: + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/fablednetmon/migrations/script.py.mako b/fablednetmon/migrations/script.py.mako new file mode 100644 index 0000000..751bb55 --- /dev/null +++ b/fablednetmon/migrations/script.py.mako @@ -0,0 +1,23 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/fablednetmon/migrations/versions/0001_core_initial.py b/fablednetmon/migrations/versions/0001_core_initial.py new file mode 100644 index 0000000..b3a7149 --- /dev/null +++ b/fablednetmon/migrations/versions/0001_core_initial.py @@ -0,0 +1,36 @@ +"""Core initial schema: users table + +Revision ID: 0001_core_initial +Revises: +Create Date: 2026-03-16 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "0001_core_initial" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("username", sa.String(64), nullable=False, unique=True), + sa.Column("email", sa.String(255), nullable=False, unique=True), + sa.Column("password_hash", sa.String(255), nullable=False), + sa.Column( + "role", + sa.Enum("admin", "operator", "viewer", name="userrole"), + nullable=False, + ), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("is_active", sa.Boolean, nullable=False, default=True), + ) + + +def downgrade() -> None: + op.drop_table("users") + op.execute("DROP TYPE IF EXISTS userrole") diff --git a/fablednetmon/migrations/versions/__init__.py b/fablednetmon/migrations/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fablednetmon/models/users.py b/fablednetmon/models/users.py index f49237c..d998487 100644 --- a/fablednetmon/models/users.py +++ b/fablednetmon/models/users.py @@ -1,2 +1,27 @@ -# fablednetmon/models/users.py — stub, filled in Task 4 +from __future__ import annotations +import enum +import uuid +from datetime import datetime, timezone +from sqlalchemy import Boolean, DateTime, Enum, String +from sqlalchemy.orm import Mapped, mapped_column from .base import Base + + +class UserRole(str, enum.Enum): + admin = "admin" + operator = "operator" + viewer = "viewer" + + +class User(Base): + __tablename__ = "users" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[UserRole] = mapped_column(Enum(UserRole), nullable=False, default=UserRole.viewer) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) + ) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..52d02e0 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,12 @@ +import pytest +from fablednetmon.models.users import User, UserRole + + +def test_user_model_fields(): + """User model has all required fields.""" + cols = {c.key for c in User.__table__.columns} + assert cols == {"id", "username", "email", "password_hash", "role", "created_at", "is_active"} + + +def test_user_role_enum(): + assert set(UserRole) == {UserRole.admin, UserRole.operator, UserRole.viewer}