Skip to content
Closed

Graeme #1423

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,4 @@ SENTRY_DSN=

# Configure these with your own Docker registry images
DOCKER_IMAGE_BACKEND=backend
DOCKER_IMAGE_FRONTEND=frontend
DOCKER_IMAGE_FRONTEND=frontend
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<a href="https://github.com/fastapi/full-stack-fastapi-template/actions?query=workflow%3ATest" target="_blank"><img src="https://github.com/fastapi/full-stack-fastapi-template/workflows/Test/badge.svg" alt="Test"></a>
<a href="https://coverage-badge.samuelcolvin.workers.dev/redirect/fastapi/full-stack-fastapi-template" target="_blank"><img src="https://coverage-badge.samuelcolvin.workers.dev/fastapi/full-stack-fastapi-template.svg" alt="Coverage"></a>

## Technology Stack and Features
## Technology Stack and Features getit

- ⚡ [**FastAPI**](https://fastapi.tiangolo.com) for the Python backend API.
- 🧰 [SQLModel](https://sqlmodel.tiangolo.com) for the Python SQL database interactions (ORM).
Expand Down
10 changes: 9 additions & 1 deletion backend/app/api/main.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
# This script sets up the main API router for the FastAPI application, organizing and including various sub-routers to handle specific sections of the API. Each sub-router, defined in separate route modules (items, login, users, utils, and geospatial), corresponds to different functional areas of the application, such as user management, item handling, utility functions, and geospatial data handling. By consolidating these routers under the main api_router, this script provides a centralized routing structure, enabling modular API section management and streamlined route access for the application’s endpoints.

# Import APIRouter from FastAPI and the other route modules
from fastapi import APIRouter

from app.api.routes import items, login, users, utils
# Import the other route modules
from app.api.routes import items, login, users, utils, geospatial

# Initialize the main API router
api_router = APIRouter()

# Include routers for different API sections
api_router.include_router(login.router, tags=["login"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(utils.router, prefix="/utils", tags=["utils"])
api_router.include_router(items.router, prefix="/items", tags=["items"])
api_router.include_router(geospatial.router, prefix="/geospatial", tags=["geospatial"])
79 changes: 79 additions & 0 deletions backend/app/api/routes/geospatial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException
from sqlalchemy.orm import Session
from typing import List
from uuid import UUID
import os # Import os to handle file path and directory creation

from app import crud
from app.models import GeoFile
from app.api import deps
from app.models import User

router = APIRouter()

# Dependency to get the current authenticated user
# This uses the get_current_user function from deps.py
# that decodes the JWT token, fetches the user from the database,
# and ensures the user is active.
@router.post("/upload", response_model=GeoFile)
def upload_geofile(
file: UploadFile = File(...), # Accepts an uploaded file
description: str = None, # Optional description for the file
db: Session = Depends(deps.get_db), # Database session dependency
current_user: User = Depends(deps.get_current_user), # Get the current authenticated user
):
"""
Endpoint to upload a geospatial file. The file is saved to a designated directory,
and a record is created in the database associating the file with the current user.
"""
# Directory where files will be stored
upload_dir = "uploads"
os.makedirs(upload_dir, exist_ok=True) # Create directory if it doesn't exist

# Construct file path
file_location = os.path.join(upload_dir, file.filename)

# Write the file to the storage path
with open(file_location, "wb+") as file_object:
file_object.write(file.file.read())

# Create geofile record in the database with user ownership
geofile = crud.create_geofile(
db=db,
owner_id=current_user.id,
file_name=file.filename,
file_path=file_location,
description=description,
)
return geofile # Return the created geofile record


# Dependency to get a list of geofiles uploaded by the current user
@router.get("/", response_model=List[GeoFile])
def list_geofiles(
db: Session = Depends(deps.get_db), # Database session dependency
current_user: User = Depends(deps.get_current_user), # Get the current authenticated user
):
"""
Retrieve a list of geofiles uploaded by the current user.
"""
return crud.get_user_geofiles(db=db, user_id=current_user.id)


# Dependency to retrieve details of a specific geofile by its ID.
# Access to geofile is restricted to the file's owner.
@router.get("/{geofile_id}", response_model=GeoFile)
def get_geofile(
geofile_id: UUID, # Unique identifier for the geofile
db: Session = Depends(deps.get_db), # Database session dependency
current_user: User = Depends(deps.get_current_user), # Get the current authenticated user
):
"""
Retrieve details of a specific geofile by its ID. Access is restricted to the file's owner.
"""
geofile = crud.get_geofile(db=db, geofile_id=geofile_id)

# Check if geofile exists and if the user is the owner
if not geofile or geofile.owner_id != current_user.id:
raise HTTPException(status_code=404, detail="Geofile not found")
return geofile # Return geofile details if access is allowed
71 changes: 37 additions & 34 deletions backend/app/api/routes/items.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,31 @@
import uuid
from typing import Any

from fastapi import APIRouter, HTTPException
from sqlmodel import func, select
from app.api.deps import CurrentUser, SessionDep # Custom dependencies for user and session
from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message # Importing models

from app.api.deps import CurrentUser, SessionDep
from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message

router = APIRouter()
router = APIRouter() # Create an APIRouter instance for routing


@router.get("/", response_model=ItemsPublic)
def read_items(
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
) -> Any:
"""
Retrieve items.
Retrieve items for the logged-in user. If the user is a superuser, they can see all items.
Otherwise, they can only see their own items.
"""

# Check if the current user is a superuser
if current_user.is_superuser:
# Count all items in the database
count_statement = select(func.count()).select_from(Item)
count = session.exec(count_statement).one()
# Retrieve items with pagination
statement = select(Item).offset(skip).limit(limit)
items = session.exec(statement).all()
else:
# For non-superusers, only retrieve items owned by the logged-in user
count_statement = (
select(func.count())
.select_from(Item)
Expand All @@ -38,19 +40,19 @@ def read_items(
)
items = session.exec(statement).all()

return ItemsPublic(data=items, count=count)
return ItemsPublic(data=items, count=count) # Return items and total count


@router.get("/{id}", response_model=ItemPublic)
def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
"""
Get item by ID.
Get a single item by ID. Users can only access their own items unless they are a superuser.
"""
item = session.get(Item, id)
item = session.get(Item, id) # Retrieve item by ID
if not item:
raise HTTPException(status_code=404, detail="Item not found")
raise HTTPException(status_code=404, detail="Item not found") # Item not found
if not current_user.is_superuser and (item.owner_id != current_user.id):
raise HTTPException(status_code=400, detail="Not enough permissions")
raise HTTPException(status_code=400, detail="Not enough permissions") # Permission check
return item


Expand All @@ -59,13 +61,14 @@ def create_item(
*, session: SessionDep, current_user: CurrentUser, item_in: ItemCreate
) -> Any:
"""
Create new item.
Create a new item. The item will be associated with the logged-in user.
"""
# Create a new Item object, validating and assigning the owner (current user)
item = Item.model_validate(item_in, update={"owner_id": current_user.id})
session.add(item)
session.commit()
session.refresh(item)
return item
session.add(item) # Add the new item to the session
session.commit() # Commit the transaction to the database
session.refresh(item) # Refresh the item object to get the updated values
return item # Return the newly created item


@router.put("/{id}", response_model=ItemPublic)
Expand All @@ -77,33 +80,33 @@ def update_item(
item_in: ItemUpdate,
) -> Any:
"""
Update an item.
Update an existing item. Users can only update their own items unless they are superusers.
"""
item = session.get(Item, id)
item = session.get(Item, id) # Retrieve item by ID
if not item:
raise HTTPException(status_code=404, detail="Item not found")
raise HTTPException(status_code=404, detail="Item not found") # Item not found
if not current_user.is_superuser and (item.owner_id != current_user.id):
raise HTTPException(status_code=400, detail="Not enough permissions")
update_dict = item_in.model_dump(exclude_unset=True)
item.sqlmodel_update(update_dict)
session.add(item)
session.commit()
session.refresh(item)
return item
raise HTTPException(status_code=400, detail="Not enough permissions") # Permission check
update_dict = item_in.model_dump(exclude_unset=True) # Prepare update data
item.sqlmodel_update(update_dict) # Apply updates to the item
session.add(item) # Add updated item to session
session.commit() # Commit the transaction
session.refresh(item) # Refresh the item object to get the updated values
return item # Return the updated item


@router.delete("/{id}")
def delete_item(
session: SessionDep, current_user: CurrentUser, id: uuid.UUID
) -> Message:
"""
Delete an item.
Delete an item. Users can only delete their own items unless they are superusers.
"""
item = session.get(Item, id)
item = session.get(Item, id) # Retrieve item by ID
if not item:
raise HTTPException(status_code=404, detail="Item not found")
raise HTTPException(status_code=404, detail="Item not found") # Item not found
if not current_user.is_superuser and (item.owner_id != current_user.id):
raise HTTPException(status_code=400, detail="Not enough permissions")
session.delete(item)
session.commit()
return Message(message="Item deleted successfully")
raise HTTPException(status_code=400, detail="Not enough permissions") # Permission check
session.delete(item) # Delete the item from the session
session.commit() # Commit the transaction
return Message(message="Item deleted successfully") # Return a success message
19 changes: 13 additions & 6 deletions backend/app/api/routes/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from fastapi.responses import HTMLResponse
from fastapi.security import OAuth2PasswordRequestForm

# Import necessary modules and dependencies
from app import crud
from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser
from app.core import security
Expand All @@ -18,6 +19,7 @@
verify_password_reset_token,
)

# Initialize the APIRouter for defining login and password-related endpoints
router = APIRouter()


Expand All @@ -26,7 +28,8 @@ def login_access_token(
session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
) -> Token:
"""
OAuth2 compatible token login, get an access token for future requests
OAuth2 compatible token login, get an access token for future requests.
Validates user credentials and returns a JWT access token if successful.
"""
user = crud.authenticate(
session=session, email=form_data.username, password=form_data.password
Expand All @@ -46,15 +49,17 @@ def login_access_token(
@router.post("/login/test-token", response_model=UserPublic)
def test_token(current_user: CurrentUser) -> Any:
"""
Test access token
Test access token.
Validates the current access token and returns user details if valid.
"""
return current_user


@router.post("/password-recovery/{email}")
def recover_password(email: str, session: SessionDep) -> Message:
"""
Password Recovery
Password recovery.
Generates and sends a password reset token to the user's email if they exist in the system.
"""
user = crud.get_user_by_email(session=session, email=email)

Expand All @@ -78,7 +83,8 @@ def recover_password(email: str, session: SessionDep) -> Message:
@router.post("/reset-password/")
def reset_password(session: SessionDep, body: NewPassword) -> Message:
"""
Reset password
Reset password.
Verifies the provided token, checks if the user exists and is active, then updates their password.
"""
email = verify_password_reset_token(token=body.token)
if not email:
Expand All @@ -105,7 +111,8 @@ def reset_password(session: SessionDep, body: NewPassword) -> Message:
)
def recover_password_html_content(email: str, session: SessionDep) -> Any:
"""
HTML Content for Password Recovery
HTML content for password recovery.
Generates HTML content of the password recovery email for preview, accessible only to superusers.
"""
user = crud.get_user_by_email(session=session, email=email)

Expand All @@ -121,4 +128,4 @@ def recover_password_html_content(email: str, session: SessionDep) -> Any:

return HTMLResponse(
content=email_data.html_content, headers={"subject:": email_data.subject}
)
)
30 changes: 30 additions & 0 deletions backend/app/crud.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# This script is a set of utility functions that manage user and item creation, updates, and authentication within a FastAPI application using SQLModel.
# Each function interacts with the database through a Session object, performing standard CRUD operations and handling security concerns like password hashing and verification.
# Database interaction functions (CRUD operations) for data. CRUD stands for Create, Read, Update, and Delete
import uuid
from typing import Any

Expand All @@ -6,6 +9,7 @@
from app.core.security import get_password_hash, verify_password
from app.models import Item, ItemCreate, User, UserCreate, UserUpdate

# create_user: Creates a new user in the database, hashing the user's password before saving it.

def create_user(*, session: Session, user_create: UserCreate) -> User:
db_obj = User.model_validate(
Expand All @@ -16,6 +20,7 @@ def create_user(*, session: Session, user_create: UserCreate) -> User:
session.refresh(db_obj)
return db_obj

# update_user: Updates an existing user's information in the database, including re-hashing the password if it’s updated.

def update_user(*, session: Session, db_user: User, user_in: UserUpdate) -> Any:
user_data = user_in.model_dump(exclude_unset=True)
Expand All @@ -30,12 +35,14 @@ def update_user(*, session: Session, db_user: User, user_in: UserUpdate) -> Any:
session.refresh(db_user)
return db_user

# get_user_by_email: Retrieves a user from the database based on their email address.

def get_user_by_email(*, session: Session, email: str) -> User | None:
statement = select(User).where(User.email == email)
session_user = session.exec(statement).first()
return session_user

# authenticate: Authenticates a user by verifying their email and password, returning the user if the credentials match.

def authenticate(*, session: Session, email: str, password: str) -> User | None:
db_user = get_user_by_email(session=session, email=email)
Expand All @@ -45,10 +52,33 @@ def authenticate(*, session: Session, email: str, password: str) -> User | None:
return None
return db_user

# create_item: Creates a new item in the database, associating it with the owner (a specific user).

def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item:
db_item = Item.model_validate(item_in, update={"owner_id": owner_id})
session.add(db_item)
session.commit()
session.refresh(db_item)
return db_item

from app.models import GeoFile # Import the GeoFile model

# create_geofile: Adds a new GeoFile record to the database.

def create_geofile(session: Session, file_name: str, file_path: str, description: str = None) -> GeoFile:
geofile = GeoFile(file_name=file_name, file_path=file_path, description=description)
session.add(geofile) # Add the new GeoFile instance to the session.
session.commit() # Commit the transaction to save the GeoFile in the database.
session.refresh(geofile) # Refresh the instance to load any updated database state.
return geofile # Return the created GeoFile object.

# get_geofile: Retrieves a GeoFile record by its unique ID.

def get_geofile(session: Session, geofile_id: uuid.UUID) -> GeoFile | None:
return session.get(GeoFile, geofile_id) # Get the GeoFile by its ID, or None if not found.

# list_geofiles: Retrieves all GeoFile records from the database.

def list_geofiles(session: Session) -> list[GeoFile]:
statement = select(GeoFile) # Prepare a query to select all GeoFile records.
return session.exec(statement).all() # Execute the query and return all results as a list.
Loading
Loading