diff --git a/.env b/.env index 98c8196862..4cc0da44b8 100644 --- a/.env +++ b/.env @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index afe124f3fb..1c7e31fc09 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Test Coverage -## 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). diff --git a/backend/app/api/main.py b/backend/app/api/main.py index 09e0663fc3..8f20c24969 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -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"]) \ No newline at end of file diff --git a/backend/app/api/routes/geospatial.py b/backend/app/api/routes/geospatial.py new file mode 100644 index 0000000000..59e8086c9d --- /dev/null +++ b/backend/app/api/routes/geospatial.py @@ -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 \ No newline at end of file diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py index 67196c2366..e118d16d9a 100644 --- a/backend/app/api/routes/items.py +++ b/backend/app/api/routes/items.py @@ -1,13 +1,11 @@ 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) @@ -15,15 +13,19 @@ 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) @@ -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 @@ -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) @@ -77,19 +80,19 @@ 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}") @@ -97,13 +100,13 @@ 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 diff --git a/backend/app/api/routes/login.py b/backend/app/api/routes/login.py index fe7e94d5c1..8273ef8f8d 100644 --- a/backend/app/api/routes/login.py +++ b/backend/app/api/routes/login.py @@ -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 @@ -18,6 +19,7 @@ verify_password_reset_token, ) +# Initialize the APIRouter for defining login and password-related endpoints router = APIRouter() @@ -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 @@ -46,7 +49,8 @@ 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 @@ -54,7 +58,8 @@ def test_token(current_user: CurrentUser) -> Any: @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) @@ -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: @@ -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) @@ -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} - ) + ) \ No newline at end of file diff --git a/backend/app/crud.py b/backend/app/crud.py index 905bf48724..f0f419850d 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -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 @@ -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( @@ -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) @@ -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) @@ -45,6 +52,7 @@ 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}) @@ -52,3 +60,25 @@ def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) - 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. \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index 4c252a1722..cf3ba40a57 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,5 @@ +#This file initializes and configures the main FastAPI application. It sets up error tracking with Sentry, custom route ID generation, and CORS middleware for secure frontend-backend communication. The primary API router, which includes all route modules, is also attached here, allowing the app to serve endpoints under a unified API prefix. This centralized setup keeps core configurations and middleware management in one place, while routing and business logic remain modular in separate files. + import sentry_sdk from fastapi import FastAPI from fastapi.routing import APIRoute @@ -11,16 +13,18 @@ def custom_generate_unique_id(route: APIRoute) -> str: return f"{route.tags[0]}-{route.name}" +# Initialize Sentry if DSN is set and environment is not local if settings.SENTRY_DSN and settings.ENVIRONMENT != "local": sentry_sdk.init(dsn=str(settings.SENTRY_DSN), enable_tracing=True) +# Initialize FastAPI app app = FastAPI( title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json", generate_unique_id_function=custom_generate_unique_id, ) -# Set all CORS enabled origins +# Set up CORS middleware for frontend-backend communication if settings.BACKEND_CORS_ORIGINS: app.add_middleware( CORSMiddleware, @@ -32,4 +36,5 @@ def custom_generate_unique_id(route: APIRoute) -> str: allow_headers=["*"], ) -app.include_router(api_router, prefix=settings.API_V1_STR) +# Include main API router +app.include_router(api_router, prefix=settings.API_V1_STR) \ No newline at end of file diff --git a/backend/app/models.py b/backend/app/models.py index 90ef5559e3..839e3a00e3 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,10 +1,28 @@ import uuid - +from datetime import datetime from pydantic import EmailStr from sqlmodel import Field, Relationship, SQLModel - -# Shared properties +# Define a new database model called GeoFile, which will create a table in the database +class GeoFile(SQLModel, table=True): + # Unique identifier for each record, generated using UUIDs + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + + # The name of the file as it was uploaded + file_name: str = Field(max_length=255) + + # The date and time when the file was uploaded, set to the current time by default + upload_date: datetime = Field(default_factory=datetime.utcnow) + + # Path where the file is stored on the server + file_path: str = Field(max_length=255) + + # Optional field for a description of the file, with a maximum length of 500 characters + description: str | None = Field(default=None, max_length=500) + +# UserBase: A base class that defines shared properties for the User model. +# It includes fields like email, full_name, is_active, and is_superuser. +# This class is used as a base for other models related to users. class UserBase(SQLModel): email: EmailStr = Field(unique=True, index=True, max_length=255) is_active: bool = True @@ -12,67 +30,83 @@ class UserBase(SQLModel): full_name: str | None = Field(default=None, max_length=255) -# Properties to receive via API on creation +# UserCreate: Inherits from UserBase and adds a password field. +# This model is used when creating a new user via the API. class UserCreate(UserBase): password: str = Field(min_length=8, max_length=40) +# UserRegister: Similar to UserCreate but used specifically for user registration. +# Includes fields for email, password, and optional full_name. 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) -# Properties to receive via API on update, all are optional +# UserUpdate: Used for updating existing user details. +# It allows partial updates by making all fields 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) +# UserUpdateMe: A model for updating the current user's details. +# It allows users to update their own email and full name. class UserUpdateMe(SQLModel): full_name: str | None = Field(default=None, max_length=255) email: EmailStr | None = Field(default=None, max_length=255) +# UpdatePassword: A model for updating a user's password. +# It includes fields for the current and new passwords. class UpdatePassword(SQLModel): current_password: str = Field(min_length=8, max_length=40) new_password: str = Field(min_length=8, max_length=40) -# Database model, database table inferred from class name +# User: Represents the User table in the database. +# Inherits from UserBase and adds fields for id, hashed_password, and relationships. class User(UserBase, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) hashed_password: str items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True) -# Properties to return via API, id is always required +# UserPublic: A model for returning public user data via the API. +# It excludes sensitive information like the hashed password. class UserPublic(UserBase): id: uuid.UUID +# UsersPublic: A model for returning a list of public user data. +# It includes the user data and a count of how many users are returned. class UsersPublic(SQLModel): data: list[UserPublic] count: int -# Shared properties +# ItemBase: A base class that defines shared properties for the Item model. +# It includes fields like title and description, which are used in other item-related models. class ItemBase(SQLModel): title: str = Field(min_length=1, max_length=255) description: str | None = Field(default=None, max_length=255) -# Properties to receive on item creation +# ItemCreate: Inherits from ItemBase and is used when creating a new item. +# It allows the creation of an item with the basic fields defined in ItemBase. class ItemCreate(ItemBase): pass -# Properties to receive on item update +# ItemUpdate: Used for updating an existing item. +# It allows partial updates by making all fields optional. class ItemUpdate(ItemBase): title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore -# Database model, database table inferred from class name +# Item: Represents the Item table in the database. +# Inherits from ItemBase and adds fields for id, owner_id, and relationships. class Item(ItemBase, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) title: str = Field(max_length=255) @@ -82,33 +116,41 @@ class Item(ItemBase, table=True): owner: User | None = Relationship(back_populates="items") -# Properties to return via API, id is always required +# ItemPublic: A model for returning public item data via the API. +# It includes the item's id and owner_id but excludes sensitive information. class ItemPublic(ItemBase): id: uuid.UUID owner_id: uuid.UUID +# ItemsPublic: A model for returning a list of public item data. +# It includes the item data and a count of how many items are returned. class ItemsPublic(SQLModel): data: list[ItemPublic] count: int -# Generic message +# Message: A generic model used to return simple messages via the API. +# Useful for operations that need to return a success or error message. class Message(SQLModel): message: str -# JSON payload containing access token +# Token: A model used to return an access token as a JSON payload. +# Includes the access token and its type (typically "bearer"). class Token(SQLModel): access_token: str token_type: str = "bearer" -# Contents of JWT token +# TokenPayload: Represents the contents of a JWT token. +# It is used internally to decode and validate tokens. class TokenPayload(SQLModel): sub: str | None = None +# NewPassword: A model for handling password reset requests. +# Includes the reset token and the new password. class NewPassword(SQLModel): token: str - new_password: str = Field(min_length=8, max_length=40) + new_password: str = Field(min_length=8, max_length=40) \ No newline at end of file diff --git a/backend/poetry.lock b/backend/poetry.lock index f56ce480f0..fa3a1c257b 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -461,6 +461,24 @@ docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1 testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"] typing = ["typing-extensions (>=4.8)"] +[[package]] +name = "geoalchemy2" +version = "0.15.2" +description = "Using SQLAlchemy with Spatial Databases" +optional = false +python-versions = ">=3.7" +files = [ + {file = "GeoAlchemy2-0.15.2-py3-none-any.whl", hash = "sha256:546455dc39f5bcdfc5b871e57d3f7546c8a6f798eb364c474200f488ace6fd32"}, + {file = "geoalchemy2-0.15.2.tar.gz", hash = "sha256:3af0272db927373e74ee3b064cdc9464ba08defdb945c51745db1b841482f5dc"}, +] + +[package.dependencies] +packaging = "*" +SQLAlchemy = ">=1.4" + +[package.extras] +shapely = ["Shapely (>=1.7)"] + [[package]] name = "greenlet" version = "3.0.3" @@ -2081,4 +2099,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "7ec220bee66b5bc207f9a8b2f4ca9100da0213bb9d0a407b51cac3dc8201e97c" +content-hash = "1f03ecea4f81784139fa92435abac0cebcb6c54f0b995618ad09f93f53d1cc45" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 671a864645..be02845c83 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -26,6 +26,7 @@ bcrypt = "4.0.1" pydantic-settings = "^2.2.1" sentry-sdk = {extras = ["fastapi"], version = "^1.40.6"} pyjwt = "^2.8.0" +geoalchemy2 = "^0.15.2" [tool.poetry.group.dev.dependencies] pytest = "^7.4.3" diff --git a/docker-compose.yml b/docker-compose.yml index d614942cbd..1506f6f0df 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: db: - image: postgres:12 + image: postgis/postgis:12-3.1 restart: always volumes: - app-db-data:/var/lib/postgresql/data/pgdata diff --git a/frontend/package.json b/frontend/package.json index b9b2e3b51a..dbd0332064 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,9 +23,12 @@ "framer-motion": "10.16.16", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-dropzone": "^14.2.3", "react-error-boundary": "^4.0.13", "react-hook-form": "7.49.3", - "react-icons": "5.0.1" + "react-icons": "5.0.1", + "react-leaflet": "^4.2.1", + "leaflet": "^1.9.4" }, "devDependencies": { "@biomejs/biome": "1.6.1", @@ -39,6 +42,7 @@ "@vitejs/plugin-react-swc": "^3.5.0", "dotenv": "^16.4.5", "typescript": "^5.2.2", - "vite": "^5.0.13" + "vite": "^5.0.13", + "@types/leaflet": "^1.9.3" } -} +} \ No newline at end of file diff --git a/frontend/src/components/Geospatial/LeafletMap.tsx b/frontend/src/components/Geospatial/LeafletMap.tsx new file mode 100644 index 0000000000..2c03982768 --- /dev/null +++ b/frontend/src/components/Geospatial/LeafletMap.tsx @@ -0,0 +1,116 @@ +import React, { useEffect, useRef } from "react"; +import { + MapContainer, + GeoJSON, + TileLayer, + LayersControl, + Marker, + Popup, + ScaleControl, + ZoomControl, +} from "react-leaflet"; +import L, { Map, LatLngTuple } from "leaflet"; // Import Leaflet for map and layers handling +import "./leaflet.css"; // Import CSS for the map styling +import markerIconPng from "./icons/placeholder.png"; // Import a custom marker icon for the map + +// Define initial map center coordinates with explicit type LatLngTuple +const center: LatLngTuple = [51.505, -0.09]; + +// Define a custom marker icon for the map +const icon = new L.Icon({ + iconUrl: markerIconPng, // Path to the icon image + iconSize: [25, 41], // Size of the icon + iconAnchor: [12, 41], // Position of the icon anchor point (bottom of the icon) + popupAnchor: [1, -34], // Popup anchor relative to the icon + shadowSize: [41, 41], // Size of the shadow for the marker icon +}); + +interface LeafletMapComponentProps { + geojson: any; // GeoJSON data passed to the component to display on the map +} + +// LeafletMapComponent renders a Leaflet map with custom layers and geoJSON data +const LeafletMapComponent: React.FC = ({ geojson }) => { + const mapRef = useRef(null); // Reference to store the map instance with Map type + + // useEffect hook to handle geoJSON data updates and adjust map bounds + useEffect(() => { + console.log("LeafletMapComponent: geojson received", geojson); + + if (geojson && mapRef.current) { + const map = mapRef.current; + const geojsonLayer = L.geoJSON(geojson); // Create a geoJSON layer from the received data + const bounds = geojsonLayer.getBounds(); // Get the bounding box of the geoJSON layer + + console.log("GeoJSON Bounds:", bounds); + + // Adjust map bounds to fit the geoJSON data on the map + if (bounds.isValid()) { + map.fitBounds(bounds); + } else { + console.error("Invalid bounds:", bounds); + } + } + }, [geojson]); // Only runs when the geojson data changes + + // Function to define what happens when each feature in the geoJSON layer is interacted with + const onEachFeature = (feature: any, layer: L.Layer) => { + if (feature.properties) { + layer.bindPopup( + Object.keys(feature.properties) + .map((k) => `${k}: ${feature.properties[k]}`) + .join("
"), + { + maxHeight: 200, // Maximum height for the popup + } + ); + } + }; + + return ( + // MapContainer initializes the Leaflet map container + { + if (mapInstance && !mapRef.current) { + mapRef.current = mapInstance; // Store map instance in ref once created + } + }} + > + + {/* LayersControl to manage different map layers */} + + + + + + + + + {/* Render the geoJSON layer if geojson data is available */} + {geojson && } + + {/* Render a custom marker at the center position */} + + A simple popup. {/* Popup content for the marker */} + + + {/* Add a scale control to the map */} + + + {/* Add zoom control to the bottom left corner */} + + + ); +}; + +export default LeafletMapComponent; \ No newline at end of file diff --git a/frontend/src/components/Geospatial/MapApp.tsx b/frontend/src/components/Geospatial/MapApp.tsx new file mode 100644 index 0000000000..c7dbbc7630 --- /dev/null +++ b/frontend/src/components/Geospatial/MapApp.tsx @@ -0,0 +1,77 @@ +import React, { useEffect } from "react"; +import { + MapContainer, + TileLayer, + LayersControl, + GeoJSON, + ScaleControl, + ZoomControl, + useMap, +} from "react-leaflet"; +import L, { LatLngTuple } from "leaflet"; // Explicit import of Leaflet +import "leaflet/dist/leaflet.css"; // Ensure Leaflet CSS is imported + +const center: LatLngTuple = [51.505, -0.09]; + +interface LeafletMapComponentProps { + geojson: any; +} + +// Hook to adjust map bounds when geoJSON changes +function FitBounds({ geojson }: { geojson: any }) { + const map = useMap(); // This hook provides access to the map instance + + useEffect(() => { + if (geojson && map) { + const geojsonLayer = L.geoJSON(geojson); // Add geoJSON to the map + const bounds = geojsonLayer.getBounds(); // Get the bounds of the geoJSON layer + + if (bounds.isValid()) { + map.fitBounds(bounds); // Fit the map to the geoJSON layer bounds + } + } + }, [geojson, map]); // Re-run when geojson or map changes + + return null; +} + +const LeafletMapComponent: React.FC = ({ geojson }) => { + return ( + + + {/* LayersControl to manage different map layers */} + + + + + + + + + {/* Render the geoJSON layer if geojson data is available */} + {geojson && } + + {/* Fit bounds based on geoJSON */} + + + {/* Add a scale control to the map */} + + + {/* Add zoom control to the bottom left corner */} + + + ); +}; + +export default LeafletMapComponent; \ No newline at end of file diff --git a/frontend/src/components/Geospatial/MapSwitcher.js b/frontend/src/components/Geospatial/MapSwitcher.js new file mode 100644 index 0000000000..034e6dcb18 --- /dev/null +++ b/frontend/src/components/Geospatial/MapSwitcher.js @@ -0,0 +1,46 @@ +import React from 'react'; +import { LayersControl, TileLayer } from 'react-leaflet'; + +const basemaps = { + OpenStreetMap: { + name: "OpenStreetMap", + url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + attribution: '© OpenStreetMap contributors' + }, + GoogleSatellite: { + name: "Google Satellite", + url: "http://{s}.google.com/vt/lyrs=s&x={x}&y={y}&z={z}", + attribution: '© Google Maps', + subdomains: ['mt0', 'mt1', 'mt2', 'mt3'] + }, + GoogleTerrain: { + name: "Google Terrain", + url: "http://{s}.google.com/vt/lyrs=p&x={x}&y={y}&z={z}", + attribution: '© Google Maps', + subdomains: ['mt0', 'mt1', 'mt2', 'mt3'] + }, + GoogleHybrid: { + name: "Google Hybrid", + url: "http://{s}.google.com/vt/lyrs=y&x={x}&y={y}&z={z}", + attribution: '© Google Maps', + subdomains: ['mt0', 'mt1', 'mt2', 'mt3'] + } +}; + +const MapSwitcher = () => { + return ( + + {Object.keys(basemaps).map((key, index) => ( + + + + ))} + + ); +}; + +export default MapSwitcher; \ No newline at end of file diff --git a/frontend/src/components/Geospatial/ModelSelector.tsx b/frontend/src/components/Geospatial/ModelSelector.tsx new file mode 100644 index 0000000000..b3d81e2f92 --- /dev/null +++ b/frontend/src/components/Geospatial/ModelSelector.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { Select, Box } from "@chakra-ui/react"; + +// Define the expected props for the ModelSelector component +interface ModelSelectorProps { + models: string[]; // Array of model names to display in the dropdown + onSelectModel: (model: string) => void; // Callback function to handle the selected model +} + +// ModelSelector component +const ModelSelector: React.FC = ({ models, onSelectModel }) => { + // handleChange function: Called when a new model is selected from the dropdown. + const handleChange = (event: React.ChangeEvent) => { + onSelectModel(event.target.value); // Pass the selected model value to the onSelectModel callback + }; + + return ( + // Box component for layout styling, used to wrap the Select component + + {/* Chakra UI's Select component for the dropdown menu */} + + + ); +}; + +export default ModelSelector; \ No newline at end of file diff --git a/frontend/src/components/Geospatial/ParameterMenu.tsx b/frontend/src/components/Geospatial/ParameterMenu.tsx new file mode 100644 index 0000000000..f49b3aa988 --- /dev/null +++ b/frontend/src/components/Geospatial/ParameterMenu.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { useDropzone } from "react-dropzone"; +import { + Box, + Text, + Input, + VStack, + FormControl, + FormLabel, + Heading, +} from "@chakra-ui/react"; + +// FileInput component for handling file uploads with drag-and-drop functionality +const FileInput: React.FC = () => { + // Callback function triggered when files are dropped onto the dropzone + const onDrop = React.useCallback((acceptedFiles: File[]) => { + console.log(acceptedFiles); // Logs the accepted files for further handling + }, []); + + // Destructure functions and states from useDropzone to handle drag-and-drop + const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop }); + + return ( + // Chakra UI Box component styled as a dropzone area + + + {/* Display text changes based on drag state */} + {isDragActive ? "Drop the files here ..." : "Drag 'n' drop or click to select files"} + + ); +}; + +// ParameterMenu component for general parameter settings +const ParameterMenu: React.FC = () => { + return ( + // Chakra VStack for vertically stacked form controls with spacing + + Parameter Settings + + Parameter 1 + {/* Using FileInput component for file upload */} + + + Parameter 2 + + + + Parameter 3 + + + + Parameter 4 + {/* Text input for parameter value */} + + + Parameter 5 + + + + ); +}; + +// PeakLocalDetect_ParameterMenu component for peak detection-specific parameters +const PeakLocalDetect_ParameterMenu: React.FC = () => { + return ( + // Chakra VStack provides vertical alignment and spacing for each control + + Peak Detection Settings + + Landcover Map + + + + Digital Elevation Model + + + + Digital Surface Model + + + + Minimum Tree Height + {/* Input for numeric parameter */} + + + Parameter 5 + + + + ); +}; + +export { ParameterMenu, PeakLocalDetect_ParameterMenu }; \ No newline at end of file diff --git a/frontend/src/components/Geospatial/RasterFileUpload.js b/frontend/src/components/Geospatial/RasterFileUpload.js new file mode 100644 index 0000000000..f81ce4cf3d --- /dev/null +++ b/frontend/src/components/Geospatial/RasterFileUpload.js @@ -0,0 +1,31 @@ +import React from 'react'; +import { useDropzone } from 'react-dropzone'; +import { fromArrayBuffer } from 'geotiff'; + +const RasterFileUpload = ({ onFileUpload }) => { + const onDrop = (acceptedFiles) => { + const file = acceptedFiles[0]; + if (file) { + const reader = new FileReader(); + reader.onload = async (e) => { + const arrayBuffer = e.target.result; + const tiff = await fromArrayBuffer(arrayBuffer); + const image = await tiff.getImage(); + const data = await image.readRasters(); + onFileUpload(data); + }; + reader.readAsArrayBuffer(file); + } + }; + + const { getRootProps, getInputProps } = useDropzone({ onDrop, accept: '.tif,.tiff' }); + + return ( +
+ +

Drag 'n' drop some raster files here, or click to select files

+
+ ); +}; + +export default RasterFileUpload; \ No newline at end of file diff --git a/frontend/src/components/Geospatial/VectorFileUpload.tsx b/frontend/src/components/Geospatial/VectorFileUpload.tsx new file mode 100644 index 0000000000..fa4c658fd3 --- /dev/null +++ b/frontend/src/components/Geospatial/VectorFileUpload.tsx @@ -0,0 +1,57 @@ +import React, { useState } from 'react'; +import { useDropzone } from 'react-dropzone'; +import axios from 'axios'; +import { Box, Text } from '@chakra-ui/react'; + +interface VectorFileUploadProps { + onFileUpload: (geojson: any) => void; +} + +const VectorFileUpload: React.FC = ({ onFileUpload }) => { + const [uploadSuccess, setUploadSuccess] = useState(false); + + // onDrop: Triggered when a file is selected or dropped + const onDrop = (acceptedFiles: File[]) => { + const file = acceptedFiles[0]; + if (file) { + console.log("Uploading file:", file.name); + const formData = new FormData(); + formData.append('file', file); + + // Make a POST request to upload the shapefile to the backend + axios.post('http://127.0.0.1:8000/files/upload_shapefile', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) + .then((response) => { + const geojson = response.data.geojson; // GeoJSON data received from the backend + onFileUpload(JSON.parse(geojson)); // Pass parsed GeoJSON to parent component + console.log("GeoJSON data loaded..."); + setUploadSuccess(true); + }) + .catch((error) => { + console.error("Error uploading file:", error); + setUploadSuccess(false); + }); + } else { + console.error("No file selected"); + } + }; + + const { getRootProps, getInputProps } = useDropzone({ + onDrop, + accept: { 'application/zip': ['.zip'] }, // Use object notation for MIME type + }); + + return ( + + + + {uploadSuccess ? "Change Area Of Interest?" : "Drag 'n' drop a .zip file containing shapefiles here, or click to select a file"} + + + ); +}; + +export default VectorFileUpload; \ No newline at end of file diff --git a/frontend/src/components/Geospatial/icons/Logo1_Graeme.png b/frontend/src/components/Geospatial/icons/Logo1_Graeme.png new file mode 100644 index 0000000000..7676802eb9 Binary files /dev/null and b/frontend/src/components/Geospatial/icons/Logo1_Graeme.png differ diff --git a/frontend/src/components/Geospatial/icons/placeholder.png b/frontend/src/components/Geospatial/icons/placeholder.png new file mode 100644 index 0000000000..60cc167d63 Binary files /dev/null and b/frontend/src/components/Geospatial/icons/placeholder.png differ diff --git a/frontend/src/components/Geospatial/leaflet.css b/frontend/src/components/Geospatial/leaflet.css new file mode 100644 index 0000000000..38ae565539 --- /dev/null +++ b/frontend/src/components/Geospatial/leaflet.css @@ -0,0 +1,15 @@ +/* leaflet.css */ +.leaflet-container { + height: 100%; + width: 100%; + position: relative; + z-index: 0; +} + +/* Ensure the map section fills the container's available space */ +.map-section { + flex: 1; + height: 100%; + overflow: hidden; + position: relative; +} \ No newline at end of file diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index afc904538b..9d6f72f554 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,27 +1,44 @@ -import { ChakraProvider } from "@chakra-ui/react" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import { RouterProvider, createRouter } from "@tanstack/react-router" -import ReactDOM from "react-dom/client" -import { routeTree } from "./routeTree.gen" +// This file is the main entry point for the React application. It sets up +// providers for Chakra UI (for UI components and theming), React Query (for +// data fetching and caching), and Tanstack Router (for routing). It also +// configures the OpenAPI client with a base URL and token, and renders +// the main component tree to the DOM. -import { StrictMode } from "react" -import { OpenAPI } from "./client" -import theme from "./theme" +import { ChakraProvider } from "@chakra-ui/react" // Chakra UI for theme and UI components +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" // React Query for data fetching and caching +import { RouterProvider, createRouter } from "@tanstack/react-router" // Tanstack Router for routing management +import ReactDOM from "react-dom/client" // React DOM to render the component tree to the DOM +import { routeTree } from "./routeTree.gen" // Pre-defined routes generated from route config +import { StrictMode } from "react" // Enforces best practices in React development +import { OpenAPI } from "./client" // OpenAPI client for API requests +import theme from "./theme" // Custom Chakra UI theme + +// Set the base URL for the OpenAPI client from environment variables OpenAPI.BASE = import.meta.env.VITE_API_URL + +// Configure the OpenAPI client to use the access token from local storage OpenAPI.TOKEN = async () => { return localStorage.getItem("access_token") || "" } +// Initialize a new React Query client for caching and managing server state const queryClient = new QueryClient() +// Create the router using the pre-defined route tree const router = createRouter({ routeTree }) + +// Extend Tanstack Router's module to recognize the new router type declare module "@tanstack/react-router" { interface Register { router: typeof router } } +// Render the application to the root DOM element +// ChakraProvider wraps the app with Chakra UI's theme and styles +// QueryClientProvider supplies the React Query client for data fetching +// RouterProvider supplies routing capabilities to the application ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index 5da6383f2a..7aaf35c374 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -1,34 +1,41 @@ +// This file defines the main route configuration for the application using +// TanStack's router, including the devtools integration for development and a +// "Not Found" component for undefined routes. + import { Outlet, createRootRoute } from "@tanstack/react-router" import React, { Suspense } from "react" import NotFound from "../components/Common/NotFound" +// Lazy-load the devtools for development mode only, optimizing the production build const loadDevtools = () => Promise.all([ - import("@tanstack/router-devtools"), - import("@tanstack/react-query-devtools"), + import("@tanstack/router-devtools"), // Router devtools for inspecting routes + import("@tanstack/react-query-devtools"), // React Query devtools for inspecting queries ]).then(([routerDevtools, reactQueryDevtools]) => { return { default: () => ( <> - - + // Render Router devtools component + // Render React Query devtools component ), } }) +// Define a lazy-loaded component for TanStackDevtools, which renders only in development mode const TanStackDevtools = process.env.NODE_ENV === "production" ? () => null : React.lazy(loadDevtools) +// Create the root route with the main layout and devtools export const Route = createRootRoute({ component: () => ( <> - + {/* Render child routes inside this main route */} - + {/* Suspense wrapper for lazy-loaded devtools */} ), - notFoundComponent: () => , -}) + notFoundComponent: () => , // Render NotFound component for unmatched routes +}) \ No newline at end of file diff --git a/frontend/src/routes/_layout.tsx b/frontend/src/routes/_layout.tsx index 9a6cfa3b81..230c9d78cf 100644 --- a/frontend/src/routes/_layout.tsx +++ b/frontend/src/routes/_layout.tsx @@ -33,3 +33,4 @@ function Layout() { ) } +export default Layout \ No newline at end of file diff --git a/frontend/src/routes/_layout/admin.tsx b/frontend/src/routes/_layout/admin.tsx index 644653ff79..4b7b2315fd 100644 --- a/frontend/src/routes/_layout/admin.tsx +++ b/frontend/src/routes/_layout/admin.tsx @@ -1,3 +1,11 @@ +// **Admin User Management Page (admin.tsx)** +// This file sets up the admin user management page under the '/_layout/admin' route. +// It includes a paginated list of users, with options to view user details, +// update user status, and manage actions (like edit or delete). +// The page utilizes React Query for data fetching, Zod for validation, +// and Chakra UI for the UI components such as tables and buttons. +// The page is part of a larger admin panel for managing users. + import { Badge, Box, @@ -13,56 +21,64 @@ import { Th, Thead, Tr, -} from "@chakra-ui/react" -import { useQuery, useQueryClient } from "@tanstack/react-query" -import { createFileRoute, useNavigate } from "@tanstack/react-router" -import { useEffect } from "react" -import { z } from "zod" +} from "@chakra-ui/react" // Chakra UI components for UI structure +import { useQuery, useQueryClient } from "@tanstack/react-query" // React Query hooks for fetching data +import { createFileRoute, useNavigate } from "@tanstack/react-router" // Router hooks for route management +import { useEffect } from "react" // React's effect hook for side-effects +import { z } from "zod" // Zod for schema validation -import { type UserPublic, UsersService } from "../../client" -import AddUser from "../../components/Admin/AddUser" -import ActionsMenu from "../../components/Common/ActionsMenu" -import Navbar from "../../components/Common/Navbar" +// Importing user API and UI components +import { type UserPublic, UsersService } from "../../client" // UsersService handles the API for fetching users +import AddUser from "../../components/Admin/AddUser" // AddUser component for adding new users +import ActionsMenu from "../../components/Common/ActionsMenu" // Menu for user actions (edit, delete) +import Navbar from "../../components/Common/Navbar" // Navbar for the admin layout +// Zod schema to validate the page number query parameter in the URL const usersSearchSchema = z.object({ - page: z.number().catch(1), + page: z.number().catch(1), // Default to page 1 if not provided }) +// Define the route for the admin section under the "/_layout/admin" path export const Route = createFileRoute("/_layout/admin")({ - component: Admin, - validateSearch: (search) => usersSearchSchema.parse(search), + component: Admin, // The component to render for this route + validateSearch: (search) => usersSearchSchema.parse(search), // Validating search query parameters }) -const PER_PAGE = 5 +const PER_PAGE = 5 // Number of users per page for pagination +// Function to build query options for fetching users function getUsersQueryOptions({ page }: { page: number }) { return { queryFn: () => - UsersService.readUsers({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }), - queryKey: ["users", { page }], + UsersService.readUsers({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }), // Fetch users with pagination + queryKey: ["users", { page }], // Query key for caching and managing requests } } +// Component to display the users table with pagination function UsersTable() { - const queryClient = useQueryClient() - const currentUser = queryClient.getQueryData(["currentUser"]) - const { page } = Route.useSearch() - const navigate = useNavigate({ from: Route.fullPath }) + const queryClient = useQueryClient() // React Query's client for managing data caching + const currentUser = queryClient.getQueryData(["currentUser"]) // Get the current user data + const { page } = Route.useSearch() // Get the current page from the route's search parameters + const navigate = useNavigate({ from: Route.fullPath }) // Hook to navigate within the app const setPage = (page: number) => - navigate({ search: (prev) => ({ ...prev, page }) }) + navigate({ search: (prev) => ({ ...prev, page }) }) // Navigate to the next/previous page with updated query + // Fetching the user data using React Query const { - data: users, - isPending, - isPlaceholderData, + data: users, // The fetched user data + isPending, // Whether the data is still being fetched + isPlaceholderData, // Whether the data is just a placeholder while fetching } = useQuery({ - ...getUsersQueryOptions({ page }), - placeholderData: (prevData) => prevData, + ...getUsersQueryOptions({ page }), // The query options + placeholderData: (prevData) => prevData, // Placeholder data while the query is in progress }) + // Flags for checking pagination availability const hasNextPage = !isPlaceholderData && users?.data.length === PER_PAGE const hasPreviousPage = page > 1 + // Prefetch the next page if available (for smoother user experience) useEffect(() => { if (hasNextPage) { queryClient.prefetchQuery(getUsersQueryOptions({ page: page + 1 })) @@ -83,6 +99,7 @@ function UsersTable() { {isPending ? ( + // Show a loading skeleton while the data is being fetched {new Array(4).fill(null).map((_, index) => ( @@ -93,6 +110,7 @@ function UsersTable() { ) : ( + // Show the user data once it's loaded {users?.data.map((user) => ( @@ -103,14 +121,13 @@ function UsersTable() { > {user.full_name || "N/A"} {currentUser?.id === user.id && ( + // If this is the current user, display a "You" badge You )} - - {user.email} - + {user.email} {user.is_superuser ? "Superuser" : "User"} @@ -137,6 +154,7 @@ function UsersTable() { )} + {/* Pagination controls */} Users Management - + {/* Navbar and Add User button */} ) -} +} \ No newline at end of file diff --git a/frontend/src/routes/_layout/index.tsx b/frontend/src/routes/_layout/index.tsx index 80cc934083..8212881d52 100644 --- a/frontend/src/routes/_layout/index.tsx +++ b/frontend/src/routes/_layout/index.tsx @@ -1,23 +1,40 @@ -import { Box, Container, Text } from "@chakra-ui/react" -import { createFileRoute } from "@tanstack/react-router" +// **Dashboard Page (index.tsx)** +// This file sets up the dashboard route at the root of the "/_layout" path. +// It uses Chakra UI for layout and styling, and a custom `useAuth` hook to fetch the current user's data. +// The dashboard displays a personalized greeting based on the user's full name or email. +// The map and model selection section is added for the geospatial components. -import useAuth from "../../hooks/useAuth" +import { Box, Container, Text } from "@chakra-ui/react" // Chakra UI components for layout and text styling +import { createFileRoute } from "@tanstack/react-router" // React Router to define the file route +import useAuth from "../../hooks/useAuth" // Custom hook for accessing the current user's authentication data +import MainContent from "../../components/Geospatial/MapApp" // Import the MainContent component from your MapApp.tsx +// Define the route for the dashboard, which renders the Dashboard component export const Route = createFileRoute("/_layout/")({ - component: Dashboard, + component: Dashboard, // The component to render for this route }) +// The Dashboard component displays a personalized greeting for the logged-in user +// and includes the map, model selection, and parameter menus for the geospatial features. function Dashboard() { - const { user: currentUser } = useAuth() + const { user: currentUser } = useAuth() // Using custom hook to access current user data return ( <> - + + {/* Dashboard Greeting Section */} Hi, {currentUser?.full_name || currentUser?.email} 👋🏼 - Welcome back, nice to see you again! + Welcome back, nice to see you again! {/* A welcome message */} + + + {/* Map and Model Selection Section */} + + {/* MainContent handles the map and model selection logic */} + {/* Updated: Removed onCloseScenario prop */} + diff --git a/frontend/src/routes/_layout/items.tsx b/frontend/src/routes/_layout/items.tsx index 174fa83c9b..8b6e4c4f3f 100644 --- a/frontend/src/routes/_layout/items.tsx +++ b/frontend/src/routes/_layout/items.tsx @@ -1,3 +1,8 @@ +// **Items Management Page (items.tsx)** +// This file defines the route and page for managing items in the application. +// It utilizes Chakra UI for styling, TanStack React Query for data fetching, and React Router for navigation. +// The page provides an interface to list items, paginate through them, and perform actions on them, such as editing or deleting. + import { Button, Container, @@ -11,55 +16,62 @@ import { Th, Thead, Tr, -} from "@chakra-ui/react" -import { useQuery, useQueryClient } from "@tanstack/react-query" -import { createFileRoute, useNavigate } from "@tanstack/react-router" +} from "@chakra-ui/react" // Chakra UI components for layout, styling, and table rendering +import { useQuery, useQueryClient } from "@tanstack/react-query" // TanStack React Query hooks for data fetching and caching +import { createFileRoute, useNavigate } from "@tanstack/react-router" // TanStack React Router for route management import { useEffect } from "react" -import { z } from "zod" +import { z } from "zod" // Zod for schema validation -import { ItemsService } from "../../client" -import ActionsMenu from "../../components/Common/ActionsMenu" -import Navbar from "../../components/Common/Navbar" -import AddItem from "../../components/Items/AddItem" +import { ItemsService } from "../../client" // Service to interact with the backend for fetching items +import ActionsMenu from "../../components/Common/ActionsMenu" // Action menu component for individual items +import Navbar from "../../components/Common/Navbar" // Navbar component for page navigation +import AddItem from "../../components/Items/AddItem" // Modal for adding new items +// Define schema for validating search parameters (page number) const itemsSearchSchema = z.object({ - page: z.number().catch(1), + page: z.number().catch(1), // Default to page 1 if no page is provided }) +// Route setup for the "/_layout/items" path export const Route = createFileRoute("/_layout/items")({ - component: Items, - validateSearch: (search) => itemsSearchSchema.parse(search), + component: Items, // The component to render for this route + validateSearch: (search) => itemsSearchSchema.parse(search), // Validate search params }) -const PER_PAGE = 5 +const PER_PAGE = 5 // Number of items to display per page +// Function to generate query options for fetching items data function getItemsQueryOptions({ page }: { page: number }) { return { queryFn: () => - ItemsService.readItems({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }), - queryKey: ["items", { page }], + ItemsService.readItems({ skip: (page - 1) * PER_PAGE, limit: PER_PAGE }), // Fetch items for the given page + queryKey: ["items", { page }], // Unique query key for caching and refetching } } +// ItemsTable component responsible for rendering the table of items function ItemsTable() { - const queryClient = useQueryClient() - const { page } = Route.useSearch() - const navigate = useNavigate({ from: Route.fullPath }) + const queryClient = useQueryClient() // Access query cache for prefetching and caching data + const { page } = Route.useSearch() // Get the current page from the search parameters + const navigate = useNavigate({ from: Route.fullPath }) // Navigation hook to change search params const setPage = (page: number) => - navigate({ search: (prev) => ({ ...prev, page }) }) + navigate({ search: (prev) => ({ ...prev, page }) }) // Function to update the current page in the URL + // Fetch items data using React Query const { data: items, isPending, isPlaceholderData, } = useQuery({ ...getItemsQueryOptions({ page }), - placeholderData: (prevData) => prevData, + placeholderData: (prevData) => prevData, // Use previous data while fetching new data }) + // Pagination logic const hasNextPage = !isPlaceholderData && items?.data.length === PER_PAGE const hasPreviousPage = page > 1 + // Prefetch next page of items if there's a next page useEffect(() => { if (hasNextPage) { queryClient.prefetchQuery(getItemsQueryOptions({ page: page + 1 })) @@ -68,6 +80,7 @@ function ItemsTable() { return ( <> + {/* Render the items table */} @@ -81,6 +94,7 @@ function ItemsTable() { {isPending ? ( + {/* Render skeleton loaders while data is pending */} {new Array(4).fill(null).map((_, index) => ( ) : ( + {/* Render fetched items */} {items?.data.map((item) => ( - - + ))} @@ -112,13 +121,9 @@ function ItemsTable() { )}
@@ -90,21 +104,16 @@ function ItemsTable() {
{item.id} - {item.title} - + {item.title} {item.description || "N/A"} - + {/* Actions menu for each item */}
- + + {/* Pagination controls */} + @@ -131,6 +136,7 @@ function ItemsTable() { ) } +// Main Items component rendering the page layout function Items() { return ( @@ -138,8 +144,9 @@ function Items() { Items Management + {/* Navbar with AddItem modal for adding new items */} - + {/* Display the list of items */} ) -} +} \ No newline at end of file diff --git a/frontend/src/routes/_layout/settings.tsx b/frontend/src/routes/_layout/settings.tsx index 68266c6b9a..765ffd3610 100644 --- a/frontend/src/routes/_layout/settings.tsx +++ b/frontend/src/routes/_layout/settings.tsx @@ -1,3 +1,8 @@ +// **User Settings Page (settings.tsx)** +// This file defines the route and page for managing user settings in the application. +// It uses Chakra UI for styling and layout, TanStack React Query for data fetching, and React Router for routing. +// The page provides a tabbed interface for the user to update their profile, change their password, adjust appearance settings, or delete their account. + import { Container, Heading, @@ -6,48 +11,60 @@ import { TabPanel, TabPanels, Tabs, -} from "@chakra-ui/react" -import { useQueryClient } from "@tanstack/react-query" -import { createFileRoute } from "@tanstack/react-router" +} from "@chakra-ui/react" // Chakra UI components for layout, styling, and tabs functionality +import { useQueryClient } from "@tanstack/react-query" // TanStack React Query hook for accessing the query cache +import { createFileRoute } from "@tanstack/react-router" // TanStack React Router for route management -import type { UserPublic } from "../../client" -import Appearance from "../../components/UserSettings/Appearance" -import ChangePassword from "../../components/UserSettings/ChangePassword" -import DeleteAccount from "../../components/UserSettings/DeleteAccount" -import UserInformation from "../../components/UserSettings/UserInformation" +import type { UserPublic } from "../../client" // Type for the current user data from the backend +import Appearance from "../../components/UserSettings/Appearance" // Component for updating appearance settings +import ChangePassword from "../../components/UserSettings/ChangePassword" // Component for changing the user's password +import DeleteAccount from "../../components/UserSettings/DeleteAccount" // Component for deleting the user's account +import UserInformation from "../../components/UserSettings/UserInformation" // Component for viewing and editing user information +// Configuration for the tabs on the settings page const tabsConfig = [ - { title: "My profile", component: UserInformation }, - { title: "Password", component: ChangePassword }, - { title: "Appearance", component: Appearance }, - { title: "Danger zone", component: DeleteAccount }, + { title: "My profile", component: UserInformation }, // Tab for user information + { title: "Password", component: ChangePassword }, // Tab for changing password + { title: "Appearance", component: Appearance }, // Tab for appearance settings + { title: "Danger zone", component: DeleteAccount }, // Tab for account deletion (Danger zone) ] +// Define the route for the "/_layout/settings" path export const Route = createFileRoute("/_layout/settings")({ - component: UserSettings, + component: UserSettings, // The component that will render for this route }) +// Main UserSettings component function UserSettings() { - const queryClient = useQueryClient() - const currentUser = queryClient.getQueryData(["currentUser"]) + const queryClient = useQueryClient() // Access React Query's cache to get the current user data + const currentUser = queryClient.getQueryData(["currentUser"]) // Fetch the current user from the query cache + + // Modify tabs based on whether the user is a superuser or not const finalTabs = currentUser?.is_superuser - ? tabsConfig.slice(0, 3) + ? tabsConfig.slice(0, 3) // Remove the "Danger zone" tab for non-superusers : tabsConfig return ( + {/* Page heading */} User Settings + + {/* Tabs for different settings categories */} + {/* Tab list (top of the tabbed interface) */} {finalTabs.map((tab, index) => ( - {tab.title} + {tab.title} // Render each tab title ))} + + {/* Tab panels (content for each tab) */} {finalTabs.map((tab, index) => ( + {/* Render the component corresponding to the selected tab */} ))} @@ -55,4 +72,4 @@ function UserSettings() { ) -} +} \ No newline at end of file diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx index 20a9be6564..69f139f81c 100644 --- a/frontend/src/routes/login.tsx +++ b/frontend/src/routes/login.tsx @@ -1,3 +1,7 @@ +// This component handles user login functionality. It uses Chakra UI for styling, React Hook Form +// for form management, and custom hooks for authentication. Users are redirected to the home page +// if they are already logged in, and the form includes validation and error handling. + import { ViewIcon, ViewOffIcon } from "@chakra-ui/icons" import { Button, @@ -20,30 +24,32 @@ import { } from "@tanstack/react-router" import { type SubmitHandler, useForm } from "react-hook-form" -import Logo from "/assets/images/fastapi-logo.svg" -import type { Body_login_login_access_token as AccessToken } from "../client" -import useAuth, { isLoggedIn } from "../hooks/useAuth" -import { emailPattern } from "../utils" +import Logo from "/assets/images/fastapi-logo.svg" // Import logo for branding +import type { Body_login_login_access_token as AccessToken } from "../client" // API typing for login +import useAuth, { isLoggedIn } from "../hooks/useAuth" // Custom hooks for auth logic +import { emailPattern } from "../utils" // Regex pattern for email validation +// Define the login route with a redirect if the user is already logged in export const Route = createFileRoute("/login")({ component: Login, beforeLoad: async () => { - if (isLoggedIn()) { + if (isLoggedIn()) { // Check if user is logged in throw redirect({ - to: "/", + to: "/", // Redirect to home if logged in }) } }, }) +// Main Login component function Login() { - const [show, setShow] = useBoolean() - const { loginMutation, error, resetError } = useAuth() + const [show, setShow] = useBoolean() // Toggle visibility of password + const { loginMutation, error, resetError } = useAuth() // Custom hook for login and error handling const { - register, - handleSubmit, + register, // Register input fields for validation + handleSubmit, // Handle form submission formState: { errors, isSubmitting }, - } = useForm({ + } = useForm({ // Set up form with React Hook Form and default values mode: "onBlur", criteriaMode: "all", defaultValues: { @@ -52,15 +58,16 @@ function Login() { }, }) + // Function to handle form submission const onSubmit: SubmitHandler = async (data) => { if (isSubmitting) return - resetError() + resetError() // Reset error state before attempting login try { - await loginMutation.mutateAsync(data) + await loginMutation.mutateAsync(data) // Attempt login with provided data } catch { - // error is handled by useAuth hook + // Error is handled by the useAuth hook } } @@ -77,28 +84,32 @@ function Login() { centerContent > FastAPI logo + + {/* Username input with validation */} {errors.username && ( - {errors.username.message} + {errors.username.message} // Display validation error )} + + {/* Password input with toggle visibility */} @@ -124,14 +135,20 @@ function Login() { - {error && {error}} + {error && {error}} // Display error from auth hook + + {/* Forgot password link */} Forgot password? + + {/* Submit button */} + + {/* Sign up link */} Don't have an account?{" "} diff --git a/frontend/src/routes/recover-password.tsx b/frontend/src/routes/recover-password.tsx index 5716728bbb..a7bac5dd72 100644 --- a/frontend/src/routes/recover-password.tsx +++ b/frontend/src/routes/recover-password.tsx @@ -1,3 +1,7 @@ +// This component provides a form for users to initiate password recovery by entering their email address. +// If the user is logged in, they are redirected to the home page. Upon submitting a valid email, a password +// recovery email is sent, and a success toast notification is shown. + import { Button, Container, @@ -11,15 +15,17 @@ import { useMutation } from "@tanstack/react-query" import { createFileRoute, redirect } from "@tanstack/react-router" import { type SubmitHandler, useForm } from "react-hook-form" -import { type ApiError, LoginService } from "../client" -import { isLoggedIn } from "../hooks/useAuth" -import useCustomToast from "../hooks/useCustomToast" -import { emailPattern, handleError } from "../utils" +import { type ApiError, LoginService } from "../client" // Import LoginService for password recovery +import { isLoggedIn } from "../hooks/useAuth" // Import utility to check if user is logged in +import useCustomToast from "../hooks/useCustomToast" // Custom hook for displaying toast notifications +import { emailPattern, handleError } from "../utils" // Utilities for email validation and error handling +// Define FormData interface for form input structure interface FormData { email: string } +// Set up routing for password recovery page, redirecting logged-in users to the home page export const Route = createFileRoute("/recover-password")({ component: RecoverPassword, beforeLoad: async () => { @@ -31,38 +37,43 @@ export const Route = createFileRoute("/recover-password")({ }, }) +// Main component function for password recovery form function RecoverPassword() { + // Set up form handling with react-hook-form const { - register, - handleSubmit, - reset, + register, // Register form fields + handleSubmit, // Handle form submission + reset, // Reset form fields on success formState: { errors, isSubmitting }, } = useForm() - const showToast = useCustomToast() + const showToast = useCustomToast() // Custom hook to display toast notifications + // Async function to call password recovery API const recoverPassword = async (data: FormData) => { await LoginService.recoverPassword({ email: data.email, }) } + // Mutation for handling API call with success and error handling const mutation = useMutation({ - mutationFn: recoverPassword, + mutationFn: recoverPassword, // Function to execute on mutation onSuccess: () => { showToast( "Email sent.", "We sent an email with a link to get back into your account.", "success", ) - reset() + reset() // Reset form fields after successful request }, onError: (err: ApiError) => { - handleError(err, showToast) + handleError(err, showToast) // Show error using handleError utility }, }) + // Function for handling form submission const onSubmit: SubmitHandler = async (data) => { - mutation.mutate(data) + mutation.mutate(data) // Trigger mutation on form submission } return ( @@ -82,20 +93,24 @@ function RecoverPassword() { A password recovery email will be sent to the registered account. + + {/* Email input field with validation */} {errors.email && ( - {errors.email.message} + {errors.email.message} // Display error message for email )} + + {/* Submit button with loading state */} diff --git a/frontend/src/theme.tsx b/frontend/src/theme.tsx index 71675dddca..03f3ad539c 100644 --- a/frontend/src/theme.tsx +++ b/frontend/src/theme.tsx @@ -1,55 +1,65 @@ +// This file customizes the default Chakra UI theme with a set of defined colors +// and component styles. It configures UI colors, button variants, and tab styling +// to give the application a cohesive look and feel. + import { extendTheme } from "@chakra-ui/react" +// Define styles for disabled elements, used across components const disabledStyles = { _disabled: { backgroundColor: "ui.main", }, } +// Extend Chakra's default theme with custom colors and component variants const theme = extendTheme({ + // Define a color palette for consistent UI styling colors: { ui: { - main: "#009688", - secondary: "#EDF2F7", - success: "#48BB78", - danger: "#E53E3E", - light: "#FAFAFA", - dark: "#1A202C", - darkSlate: "#252D3D", - dim: "#A0AEC0", + main: "#009688", // Primary color, used in main elements + secondary: "#EDF2F7", // Secondary background color + success: "#48BB78", // Color to indicate success + danger: "#E53E3E", // Color to indicate danger or errors + light: "#FAFAFA", // Light background color + dark: "#1A202C", // Dark text color + darkSlate: "#252D3D", // Slightly lighter dark color for backgrounds + dim: "#A0AEC0", // Dimmed text color, often for placeholders or hints }, }, + // Customize styles for specific Chakra UI components components: { Button: { + // Define button variants for primary and danger actions variants: { primary: { - backgroundColor: "ui.main", - color: "ui.light", + backgroundColor: "ui.main", // Background for primary button + color: "ui.light", // Text color for primary button _hover: { - backgroundColor: "#00766C", + backgroundColor: "#00766C", // Darker color on hover }, _disabled: { - ...disabledStyles, + ...disabledStyles, // Apply disabled styles _hover: { - ...disabledStyles, + ...disabledStyles, // Prevent hover styling when disabled }, }, }, danger: { - backgroundColor: "ui.danger", - color: "ui.light", + backgroundColor: "ui.danger", // Background for danger button + color: "ui.light", // Text color for danger button _hover: { - backgroundColor: "#E32727", + backgroundColor: "#E32727", // Darker color on hover for danger button }, }, }, }, Tabs: { + // Define styles for the enclosed tab variant variants: { enclosed: { tab: { _selected: { - color: "ui.main", + color: "ui.main", // Change color of selected tab }, }, }, @@ -58,4 +68,4 @@ const theme = extendTheme({ }, }) -export default theme +export default theme \ No newline at end of file diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 99f906303c..8ff1bf991d 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -1,15 +1,23 @@ +// Utility functions for form validation patterns and error handling in the app. +// Includes regular expressions for email and name validation, as well as password rules. +// Also provides an error handling function to display error messages in toasts. + import type { ApiError } from "./client" +// emailPattern: Regular expression for validating email addresses with error message export const emailPattern = { value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, message: "Invalid email address", } +// namePattern: Regular expression for validating names with up to 30 alphabetic or accented characters export const namePattern = { value: /^[A-Za-z\s\u00C0-\u017F]{1,30}$/, message: "Invalid name", } +// passwordRules: Returns an object containing password validation rules, including minimum length +// Optionally makes the password field required based on the isRequired argument export const passwordRules = (isRequired = true) => { const rules: any = { minLength: { @@ -25,8 +33,10 @@ export const passwordRules = (isRequired = true) => { return rules } +// confirmPasswordRules: Returns an object containing validation rules for confirming a password +// Uses a validate function to check if the password matches the confirmation field export const confirmPasswordRules = ( - getValues: () => any, + getValues: () => any, // Function to retrieve form values isRequired = true, ) => { const rules: any = { @@ -43,6 +53,8 @@ export const confirmPasswordRules = ( return rules } +// handleError: Function to handle API errors by displaying an error toast message +// Uses a custom toast function to display error details to the user export const handleError = (err: ApiError, showToast: any) => { const errDetail = (err.body as any)?.detail let errorMessage = errDetail || "Something went wrong." diff --git a/frontend/tests/reset-password.spec.ts b/frontend/tests/reset-password.spec.ts index 88ec798791..7e433c14c6 100644 --- a/frontend/tests/reset-password.spec.ts +++ b/frontend/tests/reset-password.spec.ts @@ -1,121 +1,125 @@ +// This test suite verifies the password recovery process, covering scenarios such as UI visibility, successful password reset, +// handling of expired or invalid reset links, and password strength validation. Each test simulates user interactions to +// confirm expected behaviors, including email retrieval, form validation, and error handling. + import { expect, test } from "@playwright/test" import { findLastEmail } from "./utils/mailcatcher" import { randomEmail, randomPassword } from "./utils/random" import { logInUser, signUpNewUser } from "./utils/user" +// Use a clean session state for each test to ensure isolation test.use({ storageState: { cookies: [], origins: [] } }) +// Test to check if the "Password Recovery" title is visible test("Password Recovery title is visible", async ({ page }) => { await page.goto("/recover-password") - - await expect( - page.getByRole("heading", { name: "Password Recovery" }), - ).toBeVisible() + await expect(page.getByRole("heading", { name: "Password Recovery" })).toBeVisible() }) +// Test to verify if the email input is visible, empty, and editable test("Input is visible, empty and editable", async ({ page }) => { await page.goto("/recover-password") - await expect(page.getByPlaceholder("Email")).toBeVisible() await expect(page.getByPlaceholder("Email")).toHaveText("") await expect(page.getByPlaceholder("Email")).toBeEditable() }) +// Test to ensure the "Continue" button is visible test("Continue button is visible", async ({ page }) => { await page.goto("/recover-password") - await expect(page.getByRole("button", { name: "Continue" })).toBeVisible() }) -test("User can reset password successfully using the link", async ({ - page, - request, -}) => { +// Test for a successful password reset using a reset link sent to the user's email +test("User can reset password successfully using the link", async ({ page, request }) => { const fullName = "Test User" const email = randomEmail() const password = randomPassword() const newPassword = randomPassword() - // Sign up a new user + // Step 1: Register a new user await signUpNewUser(page, fullName, email, password) + // Step 2: Request password recovery await page.goto("/recover-password") await page.getByPlaceholder("Email").fill(email) - await page.getByRole("button", { name: "Continue" }).click() + // Step 3: Retrieve the last email sent to the user const emailData = await findLastEmail({ request, filter: (e) => e.recipients.includes(`<${email}>`), timeout: 5000, }) + // Step 4: Access the password reset link in the email await page.goto(`http://localhost:1080/messages/${emailData.id}.html`) - const selector = 'a[href*="/reset-password?token="]' - let url = await page.getAttribute(selector, "href") - - // TODO: update var instead of doing a replace url = url!.replace("http://localhost/", "http://localhost:5173/") - // Set the new password and confirm it + // Step 5: Set and confirm the new password await page.goto(url) - await page.getByLabel("Set Password").fill(newPassword) await page.getByLabel("Confirm Password").fill(newPassword) await page.getByRole("button", { name: "Reset Password" }).click() + + // Verify the success message await expect(page.getByText("Password updated successfully")).toBeVisible() - // Check if the user is able to login with the new password + // Step 6: Log in with the new password to confirm success await logInUser(page, email, newPassword) }) +// Test to handle expired or invalid reset link test("Expired or invalid reset link", async ({ page }) => { const password = randomPassword() const invalidUrl = "/reset-password?token=invalidtoken" + // Attempt to use an invalid token for password reset await page.goto(invalidUrl) - await page.getByLabel("Set Password").fill(password) await page.getByLabel("Confirm Password").fill(password) await page.getByRole("button", { name: "Reset Password" }).click() + // Confirm that the appropriate error message is displayed await expect(page.getByText("Invalid token")).toBeVisible() }) +// Test to validate weak password prevention during password reset test("Weak new password validation", async ({ page, request }) => { const fullName = "Test User" const email = randomEmail() const password = randomPassword() - const weakPassword = "123" + const weakPassword = "123" // Example weak password - // Sign up a new user + // Step 1: Register a new user await signUpNewUser(page, fullName, email, password) + // Step 2: Request password recovery await page.goto("/recover-password") await page.getByPlaceholder("Email").fill(email) await page.getByRole("button", { name: "Continue" }).click() + // Step 3: Retrieve the password recovery email const emailData = await findLastEmail({ request, filter: (e) => e.recipients.includes(`<${email}>`), timeout: 5000, }) + // Step 4: Access the reset link from the email await page.goto(`http://localhost:1080/messages/${emailData.id}.html`) - const selector = 'a[href*="/reset-password?token="]' let url = await page.getAttribute(selector, "href") url = url!.replace("http://localhost/", "http://localhost:5173/") - // Set a weak new password + // Step 5: Attempt to set a weak password await page.goto(url) await page.getByLabel("Set Password").fill(weakPassword) await page.getByLabel("Confirm Password").fill(weakPassword) await page.getByRole("button", { name: "Reset Password" }).click() - await expect( - page.getByText("Password must be at least 8 characters"), - ).toBeVisible() + // Confirm that the weak password error message is shown + await expect(page.getByText("Password must be at least 8 characters")).toBeVisible() }) diff --git a/frontend/tests/utils/mailcatcher.ts b/frontend/tests/utils/mailcatcher.ts index 601ce434fb..e3e2f62f62 100644 --- a/frontend/tests/utils/mailcatcher.ts +++ b/frontend/tests/utils/mailcatcher.ts @@ -1,23 +1,32 @@ +// Utility functions for fetching and filtering emails during Playwright tests. +// Provides functions to locate the latest email that matches specific criteria, with timeout support +// to handle cases where emails may be delayed. + import type { APIRequestContext } from "@playwright/test" +// Type definition for an email, specifying fields used in filtering and email retrieval type Email = { id: number recipients: string[] subject: string } +// findEmail: Fetches emails from the mailcatcher API and applies an optional filter. +// Returns the most recent email or null if no emails match. async function findEmail({ request, filter, }: { request: APIRequestContext; filter?: (email: Email) => boolean }) { const response = await request.get("http://localhost:1080/messages") + // Parse the response as JSON and filter emails if a filter is provided let emails = await response.json() if (filter) { emails = emails.filter(filter) } + // Select the most recent email from the filtered list, if any const email = emails[emails.length - 1] if (email) { @@ -27,6 +36,8 @@ async function findEmail({ return null } +// findLastEmail: Checks for the latest email that matches a specified filter, within a timeout period +// Useful for scenarios where we need to wait for an email to arrive. export function findLastEmail({ request, filter, @@ -36,6 +47,7 @@ export function findLastEmail({ filter?: (email: Email) => boolean timeout?: number }) { + // Timeout promise: rejects if no email is found within the specified time const timeoutPromise = new Promise((_, reject) => setTimeout( () => reject(new Error("Timeout while trying to get latest email")), @@ -43,6 +55,7 @@ export function findLastEmail({ ), ) + // Function to repeatedly check for matching emails, every 100ms, until one is found or timeout occurs const checkEmails = async () => { while (true) { const emailData = await findEmail({ request, filter }) @@ -55,5 +68,6 @@ export function findLastEmail({ } } + // Run checkEmails with a timeout constraint return Promise.race([timeoutPromise, checkEmails()]) } diff --git a/scrap.txt b/scrap.txt new file mode 100644 index 0000000000..c7dbbc7630 --- /dev/null +++ b/scrap.txt @@ -0,0 +1,77 @@ +import React, { useEffect } from "react"; +import { + MapContainer, + TileLayer, + LayersControl, + GeoJSON, + ScaleControl, + ZoomControl, + useMap, +} from "react-leaflet"; +import L, { LatLngTuple } from "leaflet"; // Explicit import of Leaflet +import "leaflet/dist/leaflet.css"; // Ensure Leaflet CSS is imported + +const center: LatLngTuple = [51.505, -0.09]; + +interface LeafletMapComponentProps { + geojson: any; +} + +// Hook to adjust map bounds when geoJSON changes +function FitBounds({ geojson }: { geojson: any }) { + const map = useMap(); // This hook provides access to the map instance + + useEffect(() => { + if (geojson && map) { + const geojsonLayer = L.geoJSON(geojson); // Add geoJSON to the map + const bounds = geojsonLayer.getBounds(); // Get the bounds of the geoJSON layer + + if (bounds.isValid()) { + map.fitBounds(bounds); // Fit the map to the geoJSON layer bounds + } + } + }, [geojson, map]); // Re-run when geojson or map changes + + return null; +} + +const LeafletMapComponent: React.FC = ({ geojson }) => { + return ( + + + {/* LayersControl to manage different map layers */} + + + + + + + + + {/* Render the geoJSON layer if geojson data is available */} + {geojson && } + + {/* Fit bounds based on geoJSON */} + + + {/* Add a scale control to the map */} + + + {/* Add zoom control to the bottom left corner */} + + + ); +}; + +export default LeafletMapComponent; \ No newline at end of file