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):