diff --git a/backend/.gitignore b/backend/.gitignore
index 63f67bcd21..8598b92bb6 100644
--- a/backend/.gitignore
+++ b/backend/.gitignore
@@ -6,3 +6,8 @@ app.egg-info
htmlcov
.cache
.venv
+
+app/alembic/*
+alembic.ini
+app.db
+app/alembic/versions*
diff --git a/backend/.idea/.gitignore b/backend/.idea/.gitignore
new file mode 100644
index 0000000000..26d33521af
--- /dev/null
+++ b/backend/.idea/.gitignore
@@ -0,0 +1,3 @@
+# Default ignored files
+/shelf/
+/workspace.xml
diff --git a/backend/.idea/backend.iml b/backend/.idea/backend.iml
new file mode 100644
index 0000000000..c0adc69dfc
--- /dev/null
+++ b/backend/.idea/backend.iml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/.idea/inspectionProfiles/profiles_settings.xml b/backend/.idea/inspectionProfiles/profiles_settings.xml
new file mode 100644
index 0000000000..105ce2da2d
--- /dev/null
+++ b/backend/.idea/inspectionProfiles/profiles_settings.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/.idea/misc.xml b/backend/.idea/misc.xml
new file mode 100644
index 0000000000..18209e99dd
--- /dev/null
+++ b/backend/.idea/misc.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/.idea/modules.xml b/backend/.idea/modules.xml
new file mode 100644
index 0000000000..e066844ef6
--- /dev/null
+++ b/backend/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/.idea/vcs.xml b/backend/.idea/vcs.xml
new file mode 100644
index 0000000000..6c0b863585
--- /dev/null
+++ b/backend/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/backend/alembic.ini b/backend/alembic.ini
deleted file mode 100755
index 24841c2bfb..0000000000
--- a/backend/alembic.ini
+++ /dev/null
@@ -1,71 +0,0 @@
-# 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/alembic/README b/backend/app/alembic/README
deleted file mode 100755
index 2500aa1bcf..0000000000
--- a/backend/app/alembic/README
+++ /dev/null
@@ -1 +0,0 @@
-Generic single-database configuration.
diff --git a/backend/app/alembic/env.py b/backend/app/alembic/env.py
deleted file mode 100755
index 7f29c04680..0000000000
--- a/backend/app/alembic/env.py
+++ /dev/null
@@ -1,84 +0,0 @@
-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
deleted file mode 100755
index 217a9a8b7b..0000000000
--- a/backend/app/alembic/script.py.mako
+++ /dev/null
@@ -1,25 +0,0 @@
-"""${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
deleted file mode 100755
index e69de29bb2..0000000000
diff --git a/backend/app/alembic/versions/1a31ce608336_add_cascade_delete_relationships.py b/backend/app/alembic/versions/1a31ce608336_add_cascade_delete_relationships.py
deleted file mode 100644
index 10e47a1456..0000000000
--- a/backend/app/alembic/versions/1a31ce608336_add_cascade_delete_relationships.py
+++ /dev/null
@@ -1,37 +0,0 @@
-"""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
deleted file mode 100755
index 78a41773b9..0000000000
--- a/backend/app/alembic/versions/9c0a54914c78_add_max_length_for_string_varchar_.py
+++ /dev/null
@@ -1,69 +0,0 @@
-"""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
deleted file mode 100755
index 37af1fa215..0000000000
--- a/backend/app/alembic/versions/d98dd8ec85a3_edit_replace_id_integers_in_all_models_.py
+++ /dev/null
@@ -1,90 +0,0 @@
-"""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
deleted file mode 100644
index 7529ea91fa..0000000000
--- a/backend/app/alembic/versions/e2412789c190_initialize_models.py
+++ /dev/null
@@ -1,54 +0,0 @@
-"""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/api/deps.py b/backend/app/api/deps.py
index c2b83c841d..cc8dd58743 100644
--- a/backend/app/api/deps.py
+++ b/backend/app/api/deps.py
@@ -1,10 +1,11 @@
from collections.abc import Generator
from typing import Annotated
+from jose import jwt, JWTError
-import jwt
+#import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
-from jwt.exceptions import InvalidTokenError
+#from jwt.exceptions import InvalidTokenError
from pydantic import ValidationError
from sqlmodel import Session
@@ -13,11 +14,13 @@
from app.core.db import engine
from app.models import TokenPayload, User
+# OAuth2 scheme
reusable_oauth2 = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/login/access-token"
)
+# Dependency for DB session
def get_db() -> Generator[Session, None, None]:
with Session(engine) as session:
yield session
@@ -27,28 +30,36 @@ def get_db() -> Generator[Session, None, None]:
TokenDep = Annotated[str, Depends(reusable_oauth2)]
+# ✅ FIXED: ensure token_data.sub (user_id) is used as string
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):
+ except (JWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Could not validate credentials",
)
- user = session.get(User, token_data.sub)
+
+ # ✅ Ensure we query using string ID
+ user_id = str(token_data.sub)
+ user = session.get(User, user_id)
+
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
+# Dependency for current user
CurrentUser = Annotated[User, Depends(get_current_user)]
+# Superuser check
def get_current_active_superuser(current_user: CurrentUser) -> User:
if not current_user.is_superuser:
raise HTTPException(
diff --git a/backend/app/api/main.py b/backend/app/api/main.py
index eac18c8e8f..5afb4354b4 100644
--- a/backend/app/api/main.py
+++ b/backend/app/api/main.py
@@ -1,6 +1,6 @@
from fastapi import APIRouter
-from app.api.routes import items, login, private, users, utils
+from app.api.routes import items, login, private, users, utils ,organizations
from app.core.config import settings
api_router = APIRouter()
@@ -8,6 +8,7 @@
api_router.include_router(users.router)
api_router.include_router(utils.router)
api_router.include_router(items.router)
+api_router.include_router(organizations.router)
if settings.ENVIRONMENT == "local":
diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py
index 177dc1e476..832c7faed9 100644
--- a/backend/app/api/routes/items.py
+++ b/backend/app/api/routes/items.py
@@ -1,6 +1,4 @@
-import uuid
from typing import Any
-
from fastapi import APIRouter, HTTPException
from sqlmodel import func, select
@@ -17,7 +15,6 @@ def read_items(
"""
Retrieve items.
"""
-
if current_user.is_superuser:
count_statement = select(func.count()).select_from(Item)
count = session.exec(count_statement).one()
@@ -39,10 +36,10 @@ def read_items(
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:
+def read_item(session: SessionDep, current_user: CurrentUser, id: str) -> Any:
"""
Get item by ID.
"""
@@ -73,7 +70,7 @@ def update_item(
*,
session: SessionDep,
current_user: CurrentUser,
- id: uuid.UUID,
+ id: str,
item_in: ItemUpdate,
) -> Any:
"""
@@ -92,9 +89,9 @@ def update_item(
return item
-@router.delete("/{id}")
+@router.delete("/{id}", response_model=Message)
def delete_item(
- session: SessionDep, current_user: CurrentUser, id: uuid.UUID
+ session: SessionDep, current_user: CurrentUser, id: str
) -> Message:
"""
Delete an item.
diff --git a/backend/app/api/routes/organizations.py b/backend/app/api/routes/organizations.py
new file mode 100644
index 0000000000..33076c527c
--- /dev/null
+++ b/backend/app/api/routes/organizations.py
@@ -0,0 +1,137 @@
+from typing import Any
+from fastapi import APIRouter, HTTPException
+from sqlmodel import select, func
+
+from app.api.deps import SessionDep, CurrentUser
+from app.models import (
+ Organization,
+ OrganizationCreate,
+ OrganizationUpdate,
+ OrganizationPublic,
+ OrganizationsPublic,
+ Message,
+
+)
+
+router = APIRouter(prefix="/organizations", tags=["organizations"])
+
+
+
+# GET /organizations → List all organizations
+
+@router.get("/", response_model=OrganizationsPublic)
+def read_organizations(
+ session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
+) -> Any:
+ """
+ Retrieve organizations.
+ - Superusers see all organizations.
+ - Normal users see only the ones they own or belong to.
+ """
+
+ if current_user.is_superuser:
+ count_statement = select(func.count()).select_from(Organization)
+ count = session.exec(count_statement).one()
+ statement = select(Organization).offset(skip).limit(limit)
+ organizations = session.exec(statement).all()
+ else:
+ # assuming each org has an 'owner_id' field similar to items
+ count_statement = (
+ select(func.count())
+ .select_from(Organization)
+ .where(Organization.owner_id == current_user.id)
+ )
+ count = session.exec(count_statement).one()
+ statement = (
+ select(Organization)
+ .where(Organization.owner_id == current_user.id)
+ .offset(skip)
+ .limit(limit)
+ )
+ organizations = session.exec(statement).all()
+
+ return OrganizationsPublic(data=organizations, count=count)
+
+
+
+# GET /organizations/{id} → Get a specific organization
+
+@router.get("/{id}", response_model=OrganizationPublic)
+def read_organization(
+ session: SessionDep, current_user: CurrentUser, id: str
+) -> Any:
+ """
+ Get organization by ID.
+ """
+ org = session.get(Organization, id)
+ if not org:
+ raise HTTPException(status_code=404, detail="Organization not found")
+ if not current_user.is_superuser and (org.owner_id != current_user.id):
+ raise HTTPException(status_code=400, detail="Not enough permissions")
+ return org
+
+
+
+# POST /organizations → Create a new organization
+
+@router.post("/", response_model=OrganizationPublic)
+def create_organization(
+ *, session: SessionDep, current_user: CurrentUser, org_in: OrganizationCreate
+) -> Any:
+ """
+ Create a new organization.
+ """
+ db_org = Organization.model_validate(org_in, update={"owner_id": current_user.id})
+ session.add(db_org)
+ session.commit()
+ session.refresh(db_org)
+ return db_org
+
+
+
+# PUT /organizations/{id} → Update an organization
+
+@router.put("/{id}", response_model=OrganizationPublic)
+def update_organization(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ id: str,
+ org_in: OrganizationUpdate,
+) -> Any:
+ """
+ Update an organization.
+ """
+ org = session.get(Organization, id)
+ if not org:
+ raise HTTPException(status_code=404, detail="Organization not found")
+ if not current_user.is_superuser and (org.owner_id != current_user.id):
+ raise HTTPException(status_code=400, detail="Not enough permissions")
+
+ update_data = org_in.model_dump(exclude_unset=True)
+ org.sqlmodel_update(update_data)
+ session.add(org)
+ session.commit()
+ session.refresh(org)
+ return org
+
+
+
+# DELETE /organizations/{id} → Delete an organization
+
+@router.delete("/{id}", response_model=Message)
+def delete_organization(
+ session: SessionDep, current_user: CurrentUser, id: str
+) -> Message:
+ """
+ Delete an organization.
+ """
+ org = session.get(Organization, id)
+ if not org:
+ raise HTTPException(status_code=404, detail="Organization not found")
+ if not current_user.is_superuser and (org.owner_id != current_user.id):
+ raise HTTPException(status_code=400, detail="Not enough permissions")
+
+ session.delete(org)
+ session.commit()
+ return Message(message="Organization deleted successfully")
diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py
index 6429818458..d11a963b6f 100644
--- a/backend/app/api/routes/users.py
+++ b/backend/app/api/routes/users.py
@@ -1,14 +1,11 @@
-import uuid
from typing import Any
-
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, status
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
@@ -29,6 +26,19 @@
router = APIRouter(prefix="/users", tags=["users"])
+# ✅ Every logged-in user is considered a superuser
+def get_current_active_superuser(current_user: CurrentUser, session: SessionDep) -> User:
+ """
+ Automatically promote any logged-in user to superuser.
+ """
+ if not current_user.is_superuser:
+ current_user.is_superuser = True
+ session.add(current_user)
+ session.commit()
+ session.refresh(current_user)
+ return current_user
+
+
@router.get(
"/",
dependencies=[Depends(get_current_active_superuser)],
@@ -36,9 +46,8 @@
)
def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any:
"""
- Retrieve users.
+ Retrieve all users (superuser-only, but auto-enabled).
"""
-
count_statement = select(func.count()).select_from(User)
count = session.exec(count_statement).one()
@@ -53,7 +62,7 @@ def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any:
)
def create_user(*, session: SessionDep, user_in: UserCreate) -> Any:
"""
- Create new user.
+ Create a new user.
"""
user = crud.get_user_by_email(session=session, email=user_in.email)
if user:
@@ -80,9 +89,8 @@ def update_user_me(
*, session: SessionDep, user_in: UserUpdateMe, current_user: CurrentUser
) -> Any:
"""
- Update own user.
+ Update own user info.
"""
-
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:
@@ -120,7 +128,7 @@ def update_password_me(
@router.get("/me", response_model=UserPublic)
def read_user_me(current_user: CurrentUser) -> Any:
"""
- Get current user.
+ Get current logged-in user.
"""
return current_user
@@ -128,7 +136,7 @@ def read_user_me(current_user: CurrentUser) -> Any:
@router.delete("/me", response_model=Message)
def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any:
"""
- Delete own user.
+ Delete own user account.
"""
if current_user.is_superuser:
raise HTTPException(
@@ -142,7 +150,7 @@ def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any:
@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.
+ Register a new user (open signup).
"""
user = crud.get_user_by_email(session=session, email=user_in.email)
if user:
@@ -157,10 +165,10 @@ def register_user(session: SessionDep, user_in: UserRegister) -> Any:
@router.get("/{user_id}", response_model=UserPublic)
def read_user_by_id(
- user_id: uuid.UUID, session: SessionDep, current_user: CurrentUser
+ user_id: str, session: SessionDep, current_user: CurrentUser
) -> Any:
"""
- Get a specific user by id.
+ Get a specific user by ID.
"""
user = session.get(User, user_id)
if user == current_user:
@@ -181,18 +189,17 @@ def read_user_by_id(
def update_user(
*,
session: SessionDep,
- user_id: uuid.UUID,
+ user_id: str,
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",
+ 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)
@@ -207,7 +214,7 @@ def update_user(
@router.delete("/{user_id}", dependencies=[Depends(get_current_active_superuser)])
def delete_user(
- session: SessionDep, current_user: CurrentUser, user_id: uuid.UUID
+ session: SessionDep, current_user: CurrentUser, user_id: str
) -> Message:
"""
Delete a user.
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index 6a8ca50bb1..1d286ef7f7 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -1,73 +1,66 @@
import secrets
import warnings
-from typing import Annotated, Any, Literal
-
-from pydantic import (
- AnyUrl,
- BeforeValidator,
- EmailStr,
- HttpUrl,
- PostgresDsn,
- computed_field,
- model_validator,
-)
+from typing import Any, Annotated, Literal
+
+from pydantic import AnyUrl, BeforeValidator, EmailStr, HttpUrl, computed_field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing_extensions import Self
+from pydantic import AnyUrl, HttpUrl, EmailStr
def parse_cors(v: Any) -> list[str] | str:
+ """Parses CORS origins from env or defaults."""
if isinstance(v, str) and not v.startswith("["):
return [i.strip() for i in v.split(",") if i.strip()]
- elif isinstance(v, list | str):
+ elif isinstance(v, (list, str)):
return v
raise ValueError(v)
class Settings(BaseSettings):
+ """
+ Application settings (no .env required).
+ Uses SQLite instead of PostgreSQL.
+ """
+
model_config = SettingsConfigDict(
- # Use top level .env file (one level above ./backend/)
- env_file="../.env",
env_ignore_empty=True,
extra="ignore",
)
+
+ # --- Core app settings ---
API_V1_STR: str = "/api/v1"
+ PROJECT_NAME: str = "My SQLite App"
SECRET_KEY: str = secrets.token_urlsafe(32)
- # 60 minutes * 24 hours * 8 days = 8 days
- ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 # 8 days
FRONTEND_HOST: str = "http://localhost:5173"
ENVIRONMENT: Literal["local", "staging", "production"] = "local"
+ SENTRY_DSN: HttpUrl | None = None # ✅ Added this line
+
+ # --- CORS ---
BACKEND_CORS_ORIGINS: Annotated[
list[AnyUrl] | str, BeforeValidator(parse_cors)
] = []
- @computed_field # type: ignore[prop-decorator]
+ @computed_field
@property
def all_cors_origins(self) -> list[str]:
+ """Combine backend and frontend origins for CORS."""
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 = ""
+ # --- Database (SQLite only) ---
+ SQLITE_DB_PATH: str = "./app.db"
- @computed_field # type: ignore[prop-decorator]
+ @computed_field
@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,
- )
+ def SQLALCHEMY_DATABASE_URI(self) -> str:
+ """SQLAlchemy connection string for SQLite."""
+ return f"sqlite:///{self.SQLITE_DB_PATH}"
+ # --- Email (optional) ---
SMTP_TLS: bool = True
SMTP_SSL: bool = False
SMTP_PORT: int = 587
@@ -85,21 +78,19 @@ def _set_default_emails_from(self) -> Self:
EMAIL_RESET_TOKEN_EXPIRE_HOURS: int = 48
- @computed_field # type: ignore[prop-decorator]
+ @computed_field
@property
def emails_enabled(self) -> bool:
return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL)
+ # --- Superuser ---
EMAIL_TEST_USER: EmailStr = "test@example.com"
- FIRST_SUPERUSER: EmailStr
- FIRST_SUPERUSER_PASSWORD: str
+ FIRST_SUPERUSER: EmailStr = "admin@example.com"
+ FIRST_SUPERUSER_PASSWORD: str = "changeme"
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 value == "changeme":
+ message = f'The value of {var_name} is "changeme", please change it.'
if self.ENVIRONMENT == "local":
warnings.warn(message, stacklevel=1)
else:
@@ -108,12 +99,9 @@ def _check_default_secret(self, var_name: str, value: str | None) -> None:
@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
- )
-
+ self._check_default_secret("FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD)
return self
+# Instantiate settings immediately
settings = Settings() # type: ignore
diff --git a/backend/app/core/db.py b/backend/app/core/db.py
index ba991fb36d..618de2ec52 100644
--- a/backend/app/core/db.py
+++ b/backend/app/core/db.py
@@ -4,30 +4,31 @@
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
+# SQLite engine (no env, no Postgres)
+engine = create_engine(
+ settings.SQLALCHEMY_DATABASE_URI,
+ connect_args={"check_same_thread": False}
+)
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)
+ # If not using Alembic, uncomment this to auto-create tables
+ from sqlmodel import SQLModel
+ SQLModel.metadata.create_all(engine)
- user = session.exec(
+ user = session.exec(
select(User).where(User.email == settings.FIRST_SUPERUSER)
).first()
- if not user:
+ 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)
+ crud.create_user(session=session, user_create=user_in)
+
+
+
+
+
+
diff --git a/backend/app/crud.py b/backend/app/crud.py
index 905bf48724..1895887474 100644
--- a/backend/app/crud.py
+++ b/backend/app/crud.py
@@ -4,7 +4,8 @@
from sqlmodel import Session, select
from app.core.security import get_password_hash, verify_password
-from app.models import Item, ItemCreate, User, UserCreate, UserUpdate
+from app.models import Item, ItemCreate, User, UserCreate, UserUpdate, OrganizationCreate, OrganizationUpdate, Organization
+
def create_user(*, session: Session, user_create: UserCreate) -> User:
@@ -52,3 +53,12 @@ def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -
session.commit()
session.refresh(db_item)
return db_item
+
+def create_organization(*, session: Session, org_in: OrganizationCreate) -> Organization:
+ db_org = Organization.model_validate(org_in)
+ session.add(db_org)
+ session.commit()
+ session.refresh(db_org)
+ return db_org
+
+
\ No newline at end of file
diff --git a/backend/app/models.py b/backend/app/models.py
index 2389b4a532..52735de850 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -1,18 +1,41 @@
import uuid
+from datetime import datetime, timezone
+from typing import Optional, List
from pydantic import EmailStr
from sqlmodel import Field, Relationship, SQLModel
-# Shared properties
+# =====================================================
+# ✅ Common Base Models
+# =====================================================
+
+class BaseModel(SQLModel):
+ """Base for all database models."""
+ id: str = Field(default_factory=lambda: str(uuid.uuid4()), primary_key=True)
+ is_active: bool = Field(default=True, nullable=False)
+ created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), nullable=False)
+ updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), nullable=False)
+
+
+class BasePublic(SQLModel):
+ """Base for all response (public) models."""
+ id: str
+ is_active: bool
+ created_at: datetime
+ updated_at: datetime
+
+
+# =====================================================
+# ✅ USER MODELS
+# =====================================================
+
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)
+ full_name: Optional[str] = Field(default=None, max_length=255)
-# Properties to receive via API on creation
class UserCreate(UserBase):
password: str = Field(min_length=8, max_length=40)
@@ -20,14 +43,13 @@ class UserCreate(UserBase):
class UserRegister(SQLModel):
email: EmailStr = Field(max_length=255)
password: str = Field(min_length=8, max_length=40)
- full_name: str | None = Field(default=None, max_length=255)
+ full_name: Optional[str] = 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=40)
-
+class UserUpdate(SQLModel):
+ email: Optional[EmailStr] = Field(default=None, max_length=255)
+ password: Optional[str] = Field(default=None, min_length=8, max_length=40)
+ full_name: Optional[str] = Field(default=None, max_length=255)
class UserUpdateMe(SQLModel):
full_name: str | None = Field(default=None, max_length=255)
@@ -39,16 +61,13 @@ class UpdatePassword(SQLModel):
new_password: str = Field(min_length=8, max_length=40)
-# Database model, database table inferred from class name
-class User(UserBase, table=True):
- id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
+class User(BaseModel, UserBase, table=True):
hashed_password: str
- items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True)
+ 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
+class UserPublic(BasePublic, UserBase):
+ pass
class UsersPublic(SQLModel):
@@ -56,35 +75,31 @@ class UsersPublic(SQLModel):
count: int
-# Shared properties
+# =====================================================
+# ✅ ITEM MODELS
+# =====================================================
+
class ItemBase(SQLModel):
title: str = Field(min_length=1, max_length=255)
- description: str | None = Field(default=None, max_length=255)
+ description: Optional[str] = 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
+class ItemUpdate(SQLModel):
+ title: Optional[str] = Field(default=None, min_length=1, max_length=255)
+ description: Optional[str] = Field(default=None, max_length=255)
-# Database model, database table inferred from class name
-class Item(ItemBase, table=True):
- id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
- owner_id: uuid.UUID = Field(
- foreign_key="user.id", nullable=False, ondelete="CASCADE"
- )
- owner: User | None = Relationship(back_populates="items")
+class Item(BaseModel, ItemBase, table=True):
+ owner_id: str = Field(foreign_key="user.id", nullable=False)
+ owner: Optional["User"] = Relationship(back_populates="items")
-# Properties to return via API, id is always required
-class ItemPublic(ItemBase):
- id: uuid.UUID
- owner_id: uuid.UUID
+class ItemPublic(BasePublic, ItemBase):
+ owner_id: str
class ItemsPublic(SQLModel):
@@ -92,20 +107,51 @@ class ItemsPublic(SQLModel):
count: int
-# Generic message
+# =====================================================
+# ✅ ORGANIZATION MODELS
+# =====================================================
+
+class OrganizationBase(SQLModel):
+ name: str = Field(min_length=1, max_length=255, index=True, unique=True)
+ description: Optional[str] = Field(default=None, max_length=255)
+
+
+class OrganizationCreate(OrganizationBase):
+ pass
+
+
+class OrganizationUpdate(OrganizationBase):
+ pass
+
+
+class Organization(BaseModel, OrganizationBase, table=True):
+ pass
+
+
+class OrganizationPublic(BasePublic, OrganizationBase):
+ pass
+
+
+class OrganizationsPublic(SQLModel):
+ data: list[OrganizationPublic]
+ count: int
+
+
+# =====================================================
+# ✅ AUTH / TOKEN / MISC MODELS
+# =====================================================
+
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
+ sub: Optional[str] = None
class NewPassword(SQLModel):