feat: add location crud
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
"""add location table
|
||||
|
||||
Revision ID: 0b117fab4208
|
||||
Revises: fe56fa70289e
|
||||
Create Date: 2026-03-12 11:07:24.856505
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel.sql.sqltypes
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0b117fab4208'
|
||||
down_revision = 'fe56fa70289e'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('location',
|
||||
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
|
||||
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
|
||||
sa.Column('id', sa.Uuid(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('owner_id', sa.Uuid(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('location')
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.routes import items, login, private, users, utils
|
||||
from app.api.routes import items, locations, login, private, users, utils
|
||||
from app.core.config import settings
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -8,7 +8,9 @@ api_router.include_router(login.router)
|
||||
api_router.include_router(users.router)
|
||||
api_router.include_router(utils.router)
|
||||
api_router.include_router(items.router)
|
||||
api_router.include_router(locations.router)
|
||||
|
||||
|
||||
if settings.ENVIRONMENT == "local":
|
||||
api_router.include_router(private.router)
|
||||
|
||||
|
||||
119
backend/app/api/routes/locations.py
Normal file
119
backend/app/api/routes/locations.py
Normal file
@@ -0,0 +1,119 @@
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from sqlmodel import col, func, select
|
||||
|
||||
from app.api.deps import CurrentUser, SessionDep
|
||||
from app.models import (
|
||||
Location,
|
||||
LocationCreate,
|
||||
LocationPublic,
|
||||
LocationsPublic,
|
||||
LocationUpdate,
|
||||
Message,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/locations", tags=["locations"])
|
||||
|
||||
|
||||
@router.get("/", response_model=LocationsPublic)
|
||||
def read_locations(
|
||||
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
|
||||
) -> Any:
|
||||
"""
|
||||
Retrieve locations.
|
||||
"""
|
||||
|
||||
if current_user.is_superuser:
|
||||
count_statement = select(func.count()).select_from(Location)
|
||||
count = session.exec(count_statement).one()
|
||||
statement = (
|
||||
select(Location).order_by(col(Location.created_at).desc()).offset(skip).limit(limit)
|
||||
)
|
||||
locations = session.exec(statement).all()
|
||||
else:
|
||||
count_statement = (
|
||||
select(func.count())
|
||||
.select_from(Location)
|
||||
.where(Location.owner_id == current_user.id)
|
||||
)
|
||||
count = session.exec(count_statement).one()
|
||||
statement = (
|
||||
select(Location)
|
||||
.where(Location.owner_id == current_user.id)
|
||||
.order_by(col(Location.created_at).desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
locations = session.exec(statement).all()
|
||||
|
||||
return LocationsPublic(data=locations, count=count)
|
||||
|
||||
|
||||
@router.get("/{id}", response_model=LocationPublic)
|
||||
def read_location(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
|
||||
"""
|
||||
Get location by ID.
|
||||
"""
|
||||
location = session.get(Location, id)
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
if not current_user.is_superuser and (location.owner_id != current_user.id):
|
||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||
return location
|
||||
|
||||
|
||||
@router.post("/", response_model=LocationPublic)
|
||||
def create_location(
|
||||
*, session: SessionDep, current_user: CurrentUser, location_in: LocationCreate
|
||||
) -> Any:
|
||||
"""
|
||||
Create new location.
|
||||
"""
|
||||
location = Location.model_validate(location_in, update={"owner_id": current_user.id})
|
||||
session.add(location)
|
||||
session.commit()
|
||||
session.refresh(location)
|
||||
return location
|
||||
|
||||
|
||||
@router.put("/{id}", response_model=LocationPublic)
|
||||
def update_location(
|
||||
*,
|
||||
session: SessionDep,
|
||||
current_user: CurrentUser,
|
||||
id: uuid.UUID,
|
||||
location_in: LocationUpdate,
|
||||
) -> Any:
|
||||
"""
|
||||
Update a location.
|
||||
"""
|
||||
location = session.get(Location, id)
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
if not current_user.is_superuser and (location.owner_id != current_user.id):
|
||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||
update_dict = location_in.model_dump(exclude_unset=True)
|
||||
location.sqlmodel_update(update_dict)
|
||||
session.add(location)
|
||||
session.commit()
|
||||
session.refresh(location)
|
||||
return location
|
||||
|
||||
|
||||
@router.delete("/{id}")
|
||||
def delete_location(
|
||||
session: SessionDep, current_user: CurrentUser, id: uuid.UUID
|
||||
) -> Message:
|
||||
"""
|
||||
Delete a location.
|
||||
"""
|
||||
location = session.get(Location, id)
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
if not current_user.is_superuser and (location.owner_id != current_user.id):
|
||||
raise HTTPException(status_code=403, detail="Not enough permissions")
|
||||
session.delete(location)
|
||||
session.commit()
|
||||
return Message(message="Location deleted successfully")
|
||||
@@ -4,7 +4,15 @@ 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
|
||||
from app.models import (
|
||||
Item,
|
||||
ItemCreate,
|
||||
Location,
|
||||
LocationCreate,
|
||||
User,
|
||||
UserCreate,
|
||||
UserUpdate,
|
||||
)
|
||||
|
||||
|
||||
def create_user(*, session: Session, user_create: UserCreate) -> User:
|
||||
@@ -66,3 +74,11 @@ def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -
|
||||
session.commit()
|
||||
session.refresh(db_item)
|
||||
return db_item
|
||||
|
||||
|
||||
def create_location(*, session: Session, location_in: LocationCreate, owner_id: uuid.UUID) -> Location:
|
||||
db_location = Location.model_validate(location_in, update={"owner_id": owner_id})
|
||||
session.add(db_location)
|
||||
session.commit()
|
||||
session.refresh(db_location)
|
||||
return db_location
|
||||
|
||||
@@ -54,6 +54,7 @@ class User(UserBase, table=True):
|
||||
sa_type=DateTime(timezone=True), # type: ignore
|
||||
)
|
||||
items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True)
|
||||
locations: list["Location"] = Relationship(back_populates="owner", cascade_delete=True)
|
||||
|
||||
|
||||
# Properties to return via API, id is always required
|
||||
@@ -108,6 +109,47 @@ class ItemsPublic(SQLModel):
|
||||
count: int
|
||||
|
||||
|
||||
# Shared properties
|
||||
class LocationBase(SQLModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
# Properties to receive on location creation
|
||||
class LocationCreate(LocationBase):
|
||||
pass
|
||||
|
||||
|
||||
# Properties to receive on location update
|
||||
class LocationUpdate(LocationBase):
|
||||
title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore
|
||||
|
||||
|
||||
# Database model, database table inferred from class name
|
||||
class Location(LocationBase, 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="locations")
|
||||
|
||||
|
||||
# Properties to return via API, id is always required
|
||||
class LocationPublic(LocationBase):
|
||||
id: uuid.UUID
|
||||
owner_id: uuid.UUID
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class LocationsPublic(SQLModel):
|
||||
data: list[LocationPublic]
|
||||
count: int
|
||||
|
||||
|
||||
# Generic message
|
||||
class Message(SQLModel):
|
||||
message: str
|
||||
|
||||
164
backend/tests/api/routes/test_locations.py
Normal file
164
backend/tests/api/routes/test_locations.py
Normal file
@@ -0,0 +1,164 @@
|
||||
import uuid
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from tests.utils.location import create_random_location
|
||||
|
||||
|
||||
def test_create_location(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
data = {"title": "Test Location", "description": "A test location"}
|
||||
response = client.post(
|
||||
f"{settings.API_V1_STR}/locations/",
|
||||
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_location(
|
||||
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
location = create_random_location(db)
|
||||
response = client.get(
|
||||
f"{settings.API_V1_STR}/locations/{location.id}",
|
||||
headers=superuser_token_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["title"] == location.title
|
||||
assert content["description"] == location.description
|
||||
assert content["id"] == str(location.id)
|
||||
assert content["owner_id"] == str(location.owner_id)
|
||||
|
||||
|
||||
def test_read_location_not_found(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
response = client.get(
|
||||
f"{settings.API_V1_STR}/locations/{uuid.uuid4()}",
|
||||
headers=superuser_token_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
content = response.json()
|
||||
assert content["detail"] == "Location not found"
|
||||
|
||||
|
||||
def test_read_location_not_enough_permissions(
|
||||
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
location = create_random_location(db)
|
||||
response = client.get(
|
||||
f"{settings.API_V1_STR}/locations/{location.id}",
|
||||
headers=normal_user_token_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
content = response.json()
|
||||
assert content["detail"] == "Not enough permissions"
|
||||
|
||||
|
||||
def test_read_locations(
|
||||
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
create_random_location(db)
|
||||
create_random_location(db)
|
||||
response = client.get(
|
||||
f"{settings.API_V1_STR}/locations/",
|
||||
headers=superuser_token_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["data"]) >= 2
|
||||
|
||||
|
||||
def test_update_location(
|
||||
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
location = create_random_location(db)
|
||||
data = {"title": "Updated title", "description": "Updated description"}
|
||||
response = client.put(
|
||||
f"{settings.API_V1_STR}/locations/{location.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(location.id)
|
||||
assert content["owner_id"] == str(location.owner_id)
|
||||
|
||||
|
||||
def test_update_location_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}/locations/{uuid.uuid4()}",
|
||||
headers=superuser_token_headers,
|
||||
json=data,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
content = response.json()
|
||||
assert content["detail"] == "Location not found"
|
||||
|
||||
|
||||
def test_update_location_not_enough_permissions(
|
||||
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
location = create_random_location(db)
|
||||
data = {"title": "Updated title", "description": "Updated description"}
|
||||
response = client.put(
|
||||
f"{settings.API_V1_STR}/locations/{location.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_location(
|
||||
client: TestClient, superuser_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
location = create_random_location(db)
|
||||
response = client.delete(
|
||||
f"{settings.API_V1_STR}/locations/{location.id}",
|
||||
headers=superuser_token_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["message"] == "Location deleted successfully"
|
||||
|
||||
|
||||
def test_delete_location_not_found(
|
||||
client: TestClient, superuser_token_headers: dict[str, str]
|
||||
) -> None:
|
||||
response = client.delete(
|
||||
f"{settings.API_V1_STR}/locations/{uuid.uuid4()}",
|
||||
headers=superuser_token_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
content = response.json()
|
||||
assert content["detail"] == "Location not found"
|
||||
|
||||
|
||||
def test_delete_location_not_enough_permissions(
|
||||
client: TestClient, normal_user_token_headers: dict[str, str], db: Session
|
||||
) -> None:
|
||||
location = create_random_location(db)
|
||||
response = client.delete(
|
||||
f"{settings.API_V1_STR}/locations/{location.id}",
|
||||
headers=normal_user_token_headers,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
content = response.json()
|
||||
assert content["detail"] == "Not enough permissions"
|
||||
@@ -7,7 +7,7 @@ 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 app.models import Item, Location, User
|
||||
from tests.utils.user import authentication_token_from_email
|
||||
from tests.utils.utils import get_superuser_token_headers
|
||||
|
||||
@@ -19,6 +19,8 @@ def db() -> Generator[Session, None, None]:
|
||||
yield session
|
||||
statement = delete(Item)
|
||||
session.execute(statement)
|
||||
statement = delete(Location)
|
||||
session.execute(statement)
|
||||
statement = delete(User)
|
||||
session.execute(statement)
|
||||
session.commit()
|
||||
|
||||
16
backend/tests/utils/location.py
Normal file
16
backend/tests/utils/location.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from sqlmodel import Session
|
||||
|
||||
from app import crud
|
||||
from app.models import Location, LocationCreate
|
||||
from tests.utils.user import create_random_user
|
||||
from tests.utils.utils import random_lower_string
|
||||
|
||||
|
||||
def create_random_location(db: Session) -> Location:
|
||||
user = create_random_user(db)
|
||||
owner_id = user.id
|
||||
assert owner_id is not None
|
||||
title = random_lower_string()
|
||||
description = random_lower_string()
|
||||
location_in = LocationCreate(title=title, description=description)
|
||||
return crud.create_location(session=db, location_in=location_in, owner_id=owner_id)
|
||||
Reference in New Issue
Block a user