diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000..bf278f841e Binary files /dev/null and b/.DS_Store differ diff --git a/.env b/.env deleted file mode 100644 index 1d44286e25..0000000000 --- a/.env +++ /dev/null @@ -1,45 +0,0 @@ -# Domain -# This would be set to the production domain with an env var on deployment -# used by Traefik to transmit traffic and aqcuire TLS certificates -DOMAIN=localhost -# To test the local Traefik config -# DOMAIN=localhost.tiangolo.com - -# Used by the backend to generate links in emails to the frontend -FRONTEND_HOST=http://localhost:5173 -# In staging and production, set this env var to the frontend host, e.g. -# FRONTEND_HOST=https://dashboard.example.com - -# Environment: local, staging, production -ENVIRONMENT=local - -PROJECT_NAME="Full Stack FastAPI Project" -STACK_NAME=full-stack-fastapi-project - -# Backend -BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com" -SECRET_KEY=changethis -FIRST_SUPERUSER=admin@example.com -FIRST_SUPERUSER_PASSWORD=changethis - -# Emails -SMTP_HOST= -SMTP_USER= -SMTP_PASSWORD= -EMAILS_FROM_EMAIL=info@example.com -SMTP_TLS=True -SMTP_SSL=False -SMTP_PORT=587 - -# Postgres -POSTGRES_SERVER=localhost -POSTGRES_PORT=5432 -POSTGRES_DB=app -POSTGRES_USER=postgres -POSTGRES_PASSWORD=changethis - -SENTRY_DSN= - -# Configure these with your own Docker registry images -DOCKER_IMAGE_BACKEND=backend -DOCKER_IMAGE_FRONTEND=frontend diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000000..5d778a0115 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,138 @@ +name: Deploy to EC2 + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + test: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test_db + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.10 + + - name: Install dependencies + run: | + cd backend + uv sync + + - name: Run migrations + env: + PROJECT_NAME: Mosaic Test + POSTGRES_SERVER: localhost + POSTGRES_PORT: 5432 + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test_db + SECRET_KEY: test-secret-key-for-testing + FIRST_SUPERUSER: admin@example.com + FIRST_SUPERUSER_PASSWORD: testpassword + ENVIRONMENT: local + run: | + cd backend + uv run alembic upgrade head + + - name: Run tests + env: + PROJECT_NAME: Test Project + POSTGRES_SERVER: localhost + POSTGRES_PORT: 5432 + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: test_db + SECRET_KEY: test-secret-key-for-testing + FIRST_SUPERUSER: admin@example.com + FIRST_SUPERUSER_PASSWORD: testpassword + ENVIRONMENT: local + run: | + cd backend + uv run pytest tests/ -v + + deploy: + needs: test + runs-on: ubuntu-latest + if: always() && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup SSH + uses: webfactory/ssh-agent@v0.7.0 + with: + ssh-private-key: ${{ secrets.EC2_SSH_KEY }} + + - name: Add EC2 to known hosts + run: | + ssh-keyscan -H ${{ secrets.EC2_HOST }} >> ~/.ssh/known_hosts + + - name: Deploy to EC2 + run: | + ssh ec2-user@${{ secrets.EC2_HOST }} 'bash -s' << 'ENDSSH' + cd mosaic-project-cs4800 || cd mosaic-project-cs4800-main + git pull origin main || git pull origin master + chmod +x deploy-ip.sh + ./deploy-ip.sh ${{ secrets.EC2_HOST }} + cat > .env << 'ENVEOF' + ENVIRONMENT=production + DOMAIN=${{ secrets.EC2_HOST }} + PROJECT_NAME=Mosaic Project + STACK_NAME=mosaic-project-production + BACKEND_CORS_ORIGINS=http://${{ secrets.EC2_HOST }}:5173,http://${{ secrets.EC2_HOST }}:80,http://${{ secrets.EC2_HOST }} + FRONTEND_HOST=http://${{ secrets.EC2_HOST }}:5173 + SECRET_KEY=${{ secrets.SECRET_KEY }} + FIRST_SUPERUSER=${{ secrets.FIRST_SUPERUSER }} + FIRST_SUPERUSER_PASSWORD=${{ secrets.FIRST_SUPERUSER_PASSWORD }} + POSTGRES_SERVER=db + POSTGRES_PORT=5432 + POSTGRES_USER=postgres + POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_DB=app + SMTP_HOST= + SMTP_USER= + SMTP_PASSWORD= + EMAILS_FROM_EMAIL= + DOCKER_IMAGE_BACKEND=mosaic-backend + DOCKER_IMAGE_FRONTEND=mosaic-frontend + TAG=latest + ENVEOF + export DOCKER_BUILDKIT=1 + export COMPOSE_DOCKER_CLI_BUILD=1 + docker compose -f docker-compose.production.yml down + docker compose -f docker-compose.production.yml up -d --build + sleep 30 + docker compose -f docker-compose.production.yml ps + curl -f http://localhost:8000/api/v1/utils/health-check/ || echo "Backend health check failed" + ENDSSH + + - name: Verify deployment + run: | + # Wait a bit for services to fully start + sleep 10 + + # Test if the application is accessible + curl -f http://${{ secrets.EC2_HOST }}:8000/api/v1/utils/health-check/ || echo "Backend not accessible" + curl -f http://${{ secrets.EC2_HOST }} || echo "Frontend not accessible" diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 431d6fcf06..849b87bb92 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -41,6 +41,28 @@ jobs: if: ${{ needs.changes.outputs.changed == 'true' }} timeout-minutes: 60 runs-on: ubuntu-latest + env: + DOMAIN: localhost + ENVIRONMENT: local + FRONTEND_HOST: http://localhost:5173 + BACKEND_CORS_ORIGINS: http://localhost:5173,http://localhost:8000 + SECRET_KEY: ${{ secrets.SECRET_KEY }} + FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }} + FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }} + POSTGRES_SERVER: db + POSTGRES_PORT: 5432 + POSTGRES_DB: app + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + SMTP_HOST: "" + SMTP_USER: "" + SMTP_PASSWORD: "" + EMAILS_FROM_EMAIL: noreply@example.com + SENTRY_DSN: "" + PROJECT_NAME: "Mosaic Project" + STACK_NAME: mosaic-project-local + DOCKER_IMAGE_BACKEND: backend + DOCKER_IMAGE_FRONTEND: frontend strategy: matrix: shardIndex: [1, 2, 3, 4] @@ -48,6 +70,8 @@ jobs: fail-fast: false steps: - uses: actions/checkout@v5 + - name: Create .env file + run: touch .env - uses: actions/setup-node@v5 with: node-version: lts/* @@ -75,6 +99,7 @@ jobs: - run: docker compose down -v --remove-orphans - name: Run Playwright tests run: docker compose run --rm playwright npx playwright test --fail-on-flaky-tests --trace=retain-on-failure --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + - run: docker compose down -v --remove-orphans - name: Upload blob report to GitHub Actions Artifacts if: ${{ !cancelled() }} diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index 8084d04bbc..7e7943c87f 100644 --- a/.github/workflows/test-backend.yml +++ b/.github/workflows/test-backend.yml @@ -12,9 +12,33 @@ on: jobs: test-backend: runs-on: ubuntu-latest + env: + DOMAIN: localhost + ENVIRONMENT: local + FRONTEND_HOST: http://localhost:5173 + BACKEND_CORS_ORIGINS: http://localhost:5173,http://localhost:8000 + SECRET_KEY: ${{ secrets.SECRET_KEY }} + FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }} + FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }} + POSTGRES_SERVER: localhost + POSTGRES_PORT: 5432 + POSTGRES_DB: app + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + SMTP_HOST: "" + SMTP_USER: "" + SMTP_PASSWORD: "" + EMAILS_FROM_EMAIL: noreply@example.com + SENTRY_DSN: "" + PROJECT_NAME: "Mosaic Project" + STACK_NAME: mosaic-project-local + DOCKER_IMAGE_BACKEND: backend + DOCKER_IMAGE_FRONTEND: frontend steps: - name: Checkout uses: actions/checkout@v5 + - name: Create .env file + run: touch .env - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/.github/workflows/test-docker-compose.yml b/.github/workflows/test-docker-compose.yml index c14d9dd630..ed996feda9 100644 --- a/.github/workflows/test-docker-compose.yml +++ b/.github/workflows/test-docker-compose.yml @@ -13,9 +13,33 @@ jobs: test-docker-compose: runs-on: ubuntu-latest + env: + DOMAIN: localhost + ENVIRONMENT: local + FRONTEND_HOST: http://localhost:5173 + BACKEND_CORS_ORIGINS: http://localhost:5173,http://localhost:8000 + SECRET_KEY: ${{ secrets.SECRET_KEY }} + FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }} + FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }} + POSTGRES_SERVER: db + POSTGRES_PORT: 5432 + POSTGRES_DB: app + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + SMTP_HOST: "" + SMTP_USER: "" + SMTP_PASSWORD: "" + EMAILS_FROM_EMAIL: noreply@example.com + SENTRY_DSN: "" + PROJECT_NAME: "Mosaic Project" + STACK_NAME: mosaic-project-local + DOCKER_IMAGE_BACKEND: backend + DOCKER_IMAGE_FRONTEND: frontend steps: - name: Checkout uses: actions/checkout@v5 + - name: Create .env file + run: touch .env - run: docker compose build - run: docker compose down -v --remove-orphans - run: docker compose up -d --wait backend frontend adminer diff --git a/.gitignore b/.gitignore index a6dd346572..3681abd707 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,15 @@ +.env .vscode node_modules/ /test-results/ /playwright-report/ /blob-report/ /playwright/.cache/ + +# OpenAPI generated file +frontend/openapi.json + +# Editor backup files +*~ +*.swp +*.swo \ No newline at end of file diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md new file mode 100644 index 0000000000..d04433b194 --- /dev/null +++ b/DEPLOYMENT_GUIDE.md @@ -0,0 +1,129 @@ +# AWS EC2 Deployment Guide (IP-based) + +This guide will help you deploy your FastAPI project to AWS EC2 using the public IP address. + +## Prerequisites + +1. AWS EC2 instance running Amazon Linux 2023 +2. Security group configured to allow: + - SSH (port 22) + - HTTP (port 80) + - Backend API (port 8000) + - Adminer (port 8080) + - Database (port 5432) - optional for external access + +## Step 1: Set up EC2 Instance + +Connect to your EC2 instance and run these commands: + +```bash +# Update the system +sudo yum update -y + +# Install Docker +sudo yum install -y docker +sudo systemctl start docker +sudo systemctl enable docker +sudo usermod -a -G docker ec2-user + +# Install Docker Compose +sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose +sudo chmod +x /usr/local/bin/docker-compose + +# Logout and login again to apply docker group changes +exit +``` + +## Step 2: Upload Project Files + +Upload your project files to the EC2 instance. You can use: + +```bash +# From your local machine, upload the project +scp -r -i your-key.pem . ec2-user@YOUR_EC2_IP:/home/ec2-user/mosaic-project/ +``` + +Or clone from Git if your project is in a repository: + +```bash +# On the EC2 instance +git clone YOUR_REPOSITORY_URL +cd mosaic-project-cs4800 +``` + +## Step 3: Deploy the Application + +1. **Run the deployment script** (replace YOUR_EC2_IP with your actual IP): + +```bash +./deploy-ip.sh YOUR_EC2_IP +``` + +2. **Start the services**: + +```bash +docker compose -f docker-compose.production.yml up -d +``` + +## Step 4: Verify Deployment + +Your application will be available at: + +- **Frontend**: `http://YOUR_EC2_IP` +- **Backend API**: `http://YOUR_EC2_IP:8000` +- **API Documentation**: `http://YOUR_EC2_IP:8000/docs` +- **Adminer (Database UI)**: `http://YOUR_EC2_IP:8080` + +## Step 5: Access the Application + +1. **Create your first admin user**: + - Go to `http://YOUR_EC2_IP:8000/docs` + - Use the `/api/v1/users/` endpoint to create a user + - Or use the frontend registration + +2. **Login and start using the application**: + - Frontend: `http://YOUR_EC2_IP` + - API docs: `http://YOUR_EC2_IP:8000/docs` + +## Troubleshooting + +### Check if services are running: +```bash +docker compose -f docker-compose.production.yml ps +``` + +### View logs: +```bash +# All services +docker compose -f docker-compose.production.yml logs + +# Specific service +docker compose -f docker-compose.production.yml logs backend +docker compose -f docker-compose.production.yml logs frontend +``` + +### Restart services: +```bash +docker compose -f docker-compose.production.yml restart +``` + +### Stop services: +```bash +docker compose -f docker-compose.production.yml down +``` + +## Security Notes + +- The deployment uses HTTP (not HTTPS) since we're using IP addresses +- Database is accessible on port 5432 - consider restricting this in production +- Adminer is accessible on port 8080 - consider restricting this in production +- All passwords are generated securely using Python's secrets module + +## Environment Variables + +The deployment script automatically generates: +- `SECRET_KEY`: Secure random key for JWT tokens +- `FIRST_SUPERUSER_PASSWORD`: Secure random password for admin user +- `POSTGRES_PASSWORD`: Secure random password for database + +You can modify these in the `.env` file if needed. diff --git a/README.md b/README.md index afe124f3fb..e306da02f8 100644 --- a/README.md +++ b/README.md @@ -1,239 +1 @@ -# Full Stack FastAPI Template - -Test -Coverage - -## Technology Stack and Features - -- โšก [**FastAPI**](https://fastapi.tiangolo.com) for the Python backend API. - - ๐Ÿงฐ [SQLModel](https://sqlmodel.tiangolo.com) for the Python SQL database interactions (ORM). - - ๐Ÿ” [Pydantic](https://docs.pydantic.dev), used by FastAPI, for the data validation and settings management. - - ๐Ÿ’พ [PostgreSQL](https://www.postgresql.org) as the SQL database. -- ๐Ÿš€ [React](https://react.dev) for the frontend. - - ๐Ÿ’ƒ Using TypeScript, hooks, Vite, and other parts of a modern frontend stack. - - ๐ŸŽจ [Chakra UI](https://chakra-ui.com) for the frontend components. - - ๐Ÿค– An automatically generated frontend client. - - ๐Ÿงช [Playwright](https://playwright.dev) for End-to-End testing. - - ๐Ÿฆ‡ Dark mode support. -- ๐Ÿ‹ [Docker Compose](https://www.docker.com) for development and production. -- ๐Ÿ”’ Secure password hashing by default. -- ๐Ÿ”‘ JWT (JSON Web Token) authentication. -- ๐Ÿ“ซ Email based password recovery. -- โœ… Tests with [Pytest](https://pytest.org). -- ๐Ÿ“ž [Traefik](https://traefik.io) as a reverse proxy / load balancer. -- ๐Ÿšข Deployment instructions using Docker Compose, including how to set up a frontend Traefik proxy to handle automatic HTTPS certificates. -- ๐Ÿญ CI (continuous integration) and CD (continuous deployment) based on GitHub Actions. - -### Dashboard Login - -[![API docs](img/login.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Dashboard - Admin - -[![API docs](img/dashboard.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Dashboard - Create User - -[![API docs](img/dashboard-create.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Dashboard - Items - -[![API docs](img/dashboard-items.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Dashboard - User Settings - -[![API docs](img/dashboard-user-settings.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Dashboard - Dark Mode - -[![API docs](img/dashboard-dark.png)](https://github.com/fastapi/full-stack-fastapi-template) - -### Interactive API Documentation - -[![API docs](img/docs.png)](https://github.com/fastapi/full-stack-fastapi-template) - -## How To Use It - -You can **just fork or clone** this repository and use it as is. - -โœจ It just works. โœจ - -### How to Use a Private Repository - -If you want to have a private repository, GitHub won't allow you to simply fork it as it doesn't allow changing the visibility of forks. - -But you can do the following: - -- Create a new GitHub repo, for example `my-full-stack`. -- Clone this repository manually, set the name with the name of the project you want to use, for example `my-full-stack`: - -```bash -git clone git@github.com:fastapi/full-stack-fastapi-template.git my-full-stack -``` - -- Enter into the new directory: - -```bash -cd my-full-stack -``` - -- Set the new origin to your new repository, copy it from the GitHub interface, for example: - -```bash -git remote set-url origin git@github.com:octocat/my-full-stack.git -``` - -- Add this repo as another "remote" to allow you to get updates later: - -```bash -git remote add upstream git@github.com:fastapi/full-stack-fastapi-template.git -``` - -- Push the code to your new repository: - -```bash -git push -u origin master -``` - -### Update From the Original Template - -After cloning the repository, and after doing changes, you might want to get the latest changes from this original template. - -- Make sure you added the original repository as a remote, you can check it with: - -```bash -git remote -v - -origin git@github.com:octocat/my-full-stack.git (fetch) -origin git@github.com:octocat/my-full-stack.git (push) -upstream git@github.com:fastapi/full-stack-fastapi-template.git (fetch) -upstream git@github.com:fastapi/full-stack-fastapi-template.git (push) -``` - -- Pull the latest changes without merging: - -```bash -git pull --no-commit upstream master -``` - -This will download the latest changes from this template without committing them, that way you can check everything is right before committing. - -- If there are conflicts, solve them in your editor. - -- Once you are done, commit the changes: - -```bash -git merge --continue -``` - -### Configure - -You can then update configs in the `.env` files to customize your configurations. - -Before deploying it, make sure you change at least the values for: - -- `SECRET_KEY` -- `FIRST_SUPERUSER_PASSWORD` -- `POSTGRES_PASSWORD` - -You can (and should) pass these as environment variables from secrets. - -Read the [deployment.md](./deployment.md) docs for more details. - -### Generate Secret Keys - -Some environment variables in the `.env` file have a default value of `changethis`. - -You have to change them with a secret key, to generate secret keys you can run the following command: - -```bash -python -c "import secrets; print(secrets.token_urlsafe(32))" -``` - -Copy the content and use that as password / secret key. And run that again to generate another secure key. - -## How To Use It - Alternative With Copier - -This repository also supports generating a new project using [Copier](https://copier.readthedocs.io). - -It will copy all the files, ask you configuration questions, and update the `.env` files with your answers. - -### Install Copier - -You can install Copier with: - -```bash -pip install copier -``` - -Or better, if you have [`pipx`](https://pipx.pypa.io/), you can run it with: - -```bash -pipx install copier -``` - -**Note**: If you have `pipx`, installing copier is optional, you could run it directly. - -### Generate a Project With Copier - -Decide a name for your new project's directory, you will use it below. For example, `my-awesome-project`. - -Go to the directory that will be the parent of your project, and run the command with your project's name: - -```bash -copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust -``` - -If you have `pipx` and you didn't install `copier`, you can run it directly: - -```bash -pipx run copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust -``` - -**Note** the `--trust` option is necessary to be able to execute a [post-creation script](https://github.com/fastapi/full-stack-fastapi-template/blob/master/.copier/update_dotenv.py) that updates your `.env` files. - -### Input Variables - -Copier will ask you for some data, you might want to have at hand before generating the project. - -But don't worry, you can just update any of that in the `.env` files afterwards. - -The input variables, with their default values (some auto generated) are: - -- `project_name`: (default: `"FastAPI Project"`) The name of the project, shown to API users (in .env). -- `stack_name`: (default: `"fastapi-project"`) The name of the stack used for Docker Compose labels and project name (no spaces, no periods) (in .env). -- `secret_key`: (default: `"changethis"`) The secret key for the project, used for security, stored in .env, you can generate one with the method above. -- `first_superuser`: (default: `"admin@example.com"`) The email of the first superuser (in .env). -- `first_superuser_password`: (default: `"changethis"`) The password of the first superuser (in .env). -- `smtp_host`: (default: "") The SMTP server host to send emails, you can set it later in .env. -- `smtp_user`: (default: "") The SMTP server user to send emails, you can set it later in .env. -- `smtp_password`: (default: "") The SMTP server password to send emails, you can set it later in .env. -- `emails_from_email`: (default: `"info@example.com"`) The email account to send emails from, you can set it later in .env. -- `postgres_password`: (default: `"changethis"`) The password for the PostgreSQL database, stored in .env, you can generate one with the method above. -- `sentry_dsn`: (default: "") The DSN for Sentry, if you are using it, you can set it later in .env. - -## Backend Development - -Backend docs: [backend/README.md](./backend/README.md). - -## Frontend Development - -Frontend docs: [frontend/README.md](./frontend/README.md). - -## Deployment - -Deployment docs: [deployment.md](./deployment.md). - -## Development - -General development docs: [development.md](./development.md). - -This includes using Docker Compose, custom local domains, `.env` configurations, etc. - -## Release Notes - -Check the file [release-notes.md](./release-notes.md). - -## License - -The Full Stack FastAPI Template is licensed under the terms of the MIT license. +Mosaic, a project for CS4800 Software Engineering. \ No newline at end of file diff --git a/a3_test_api/arthur_test_main.py b/a3_test_api/arthur_test_main.py new file mode 100644 index 0000000000..6d077c3b00 --- /dev/null +++ b/a3_test_api/arthur_test_main.py @@ -0,0 +1,14 @@ +# Arthur Nguyen +# Assignment 3, Exercise 3 +# test_main.py +from fastapi import FastAPI + +app = FastAPI() + +@app.get("/") +def read_root(): + return {"message": "Hello from your test API!"} + +@app.get("/ping") +def ping(): + return {"status": "ok"} \ No newline at end of file diff --git a/a3_test_api/nathan_test_api b/a3_test_api/nathan_test_api new file mode 100644 index 0000000000..690ad79b73 --- /dev/null +++ b/a3_test_api/nathan_test_api @@ -0,0 +1,12 @@ +from fastapi import FastAPI +import time +app = FastAPI() + + +@app.get("/") +async def root(): + return {"message": "Hello World"} + +@app.get("/nathan_ping"): +async def nathan_ping(): + return {"message": f"Nathan says hi to you at {time.ctime()}"} \ No newline at end of file diff --git a/backend/app/alembic/versions/2025010801_add_organizations_projects_galleries.py b/backend/app/alembic/versions/2025010801_add_organizations_projects_galleries.py new file mode 100644 index 0000000000..3a306f79f8 --- /dev/null +++ b/backend/app/alembic/versions/2025010801_add_organizations_projects_galleries.py @@ -0,0 +1,89 @@ +"""Add organizations, projects, and galleries + +Revision ID: 2025010801 +Revises: 1a31ce608336 +Create Date: 2025-01-08 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '2025010801' +down_revision = '1a31ce608336' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + # Create organization table + op.create_table( + 'organization', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=1000), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + + # Create project table + op.create_table( + 'project', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('client_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('client_email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=True), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), + sa.Column('deadline', sa.Date(), nullable=True), + sa.Column('start_date', sa.Date(), nullable=True), + sa.Column('budget', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True), + sa.Column('progress', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('organization_id', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['organization_id'], ['organization.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + + # Create gallery table + op.create_table( + 'gallery', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('date', sa.Date(), nullable=True), + sa.Column('photo_count', sa.Integer(), nullable=False), + sa.Column('photographer', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), + sa.Column('cover_image_url', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('project_id', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['project_id'], ['project.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + + # Add organization_id to user table + op.add_column('user', sa.Column('organization_id', sa.UUID(), nullable=True)) + op.create_foreign_key(None, 'user', 'organization', ['organization_id'], ['id']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + # Remove organization_id from user table + op.drop_constraint(None, 'user', type_='foreignkey') + op.drop_column('user', 'organization_id') + + # Drop tables in reverse order (due to foreign keys) + op.drop_table('gallery') + op.drop_table('project') + op.drop_table('organization') + + # ### end Alembic commands ### + diff --git a/backend/app/alembic/versions/2025110201_add_user_type_field.py b/backend/app/alembic/versions/2025110201_add_user_type_field.py new file mode 100644 index 0000000000..2d042126b3 --- /dev/null +++ b/backend/app/alembic/versions/2025110201_add_user_type_field.py @@ -0,0 +1,27 @@ +"""Add user_type field to User table + +Revision ID: 2025110201 +Revises: 2025010801 +Create Date: 2025-11-02 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes + +# revision identifiers, used by Alembic. +revision = '2025110201' +down_revision = '2025010801' +branch_labels = None +depends_on = None + + +def upgrade(): + # Add user_type column to user table with default value + op.add_column('user', sa.Column('user_type', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False, server_default='team_member')) + + +def downgrade(): + # Remove user_type column from user table + op.drop_column('user', 'user_type') + diff --git a/backend/app/alembic/versions/2025110301_add_project_access_table.py b/backend/app/alembic/versions/2025110301_add_project_access_table.py new file mode 100644 index 0000000000..6fad24d4ee --- /dev/null +++ b/backend/app/alembic/versions/2025110301_add_project_access_table.py @@ -0,0 +1,51 @@ +"""Add project_access table for client invitations + +Revision ID: 2025110301 +Revises: 2025110201 +Create Date: 2025-11-03 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel.sql.sqltypes +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '2025110301' +down_revision = '2025110201' +branch_labels = None +depends_on = None + + +def upgrade(): + # Create project_access table + op.create_table( + 'projectaccess', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('role', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False, server_default='viewer'), + sa.Column('can_comment', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('can_download', sa.Boolean(), nullable=False, server_default='true'), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('project_id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(['project_id'], ['project.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + + # Create index for faster lookups + op.create_index('ix_projectaccess_project_id', 'projectaccess', ['project_id']) + op.create_index('ix_projectaccess_user_id', 'projectaccess', ['user_id']) + + # Create unique constraint to prevent duplicate access entries + op.create_unique_constraint('uq_projectaccess_project_user', 'projectaccess', ['project_id', 'user_id']) + + +def downgrade(): + # Drop indexes + op.drop_index('ix_projectaccess_user_id', 'projectaccess') + op.drop_index('ix_projectaccess_project_id', 'projectaccess') + + # Drop table + op.drop_table('projectaccess') + diff --git a/backend/app/alembic/versions/2025110302_add_organization_invitation_table.py b/backend/app/alembic/versions/2025110302_add_organization_invitation_table.py new file mode 100644 index 0000000000..21067412e4 --- /dev/null +++ b/backend/app/alembic/versions/2025110302_add_organization_invitation_table.py @@ -0,0 +1,36 @@ +"""add organization invitation table + +Revision ID: 2025110302 +Revises: 2025110301 +Create Date: 2025-11-03 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '2025110302' +down_revision = '2025110301' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'organizationinvitation', + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('organization_id', sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(['organization_id'], ['organization.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_organizationinvitation_email'), 'organizationinvitation', ['email'], unique=False) + + +def downgrade(): + op.drop_index(op.f('ix_organizationinvitation_email'), table_name='organizationinvitation') + op.drop_table('organizationinvitation') + diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8e8f..26c842cd28 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,6 +1,17 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils +from app.api.routes import ( + galleries, + invitations, + items, + login, + organizations, + private, + project_access, + projects, + users, + utils, +) from app.core.config import settings api_router = APIRouter() @@ -8,6 +19,17 @@ api_router.include_router(users.router) api_router.include_router(utils.router) api_router.include_router(items.router) +api_router.include_router( + organizations.router, prefix="/organizations", tags=["organizations"] +) +api_router.include_router(projects.router, prefix="/projects", tags=["projects"]) +api_router.include_router( + project_access.router, prefix="/projects", tags=["project-access"] +) +api_router.include_router( + invitations.router, prefix="/invitations", tags=["invitations"] +) +api_router.include_router(galleries.router, prefix="/galleries", tags=["galleries"]) if settings.ENVIRONMENT == "local": diff --git a/backend/app/api/routes/galleries.py b/backend/app/api/routes/galleries.py new file mode 100644 index 0000000000..68c5b02335 --- /dev/null +++ b/backend/app/api/routes/galleries.py @@ -0,0 +1,225 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException + +from app import crud +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + GalleriesPublic, + GalleryCreate, + GalleryPublic, + GalleryUpdate, + Message, +) + +router = APIRouter() + + +@router.get("/", response_model=GalleriesPublic) +def read_galleries( + session: SessionDep, + current_user: CurrentUser, + project_id: uuid.UUID | None = None, + skip: int = 0, + limit: int = 100, +) -> Any: + """ + Retrieve galleries. If project_id is provided, get galleries for that project. + Otherwise, get all galleries based on user type: + - Team members: all galleries from their organization + - Clients: galleries from projects they have access to + """ + user_type = getattr(current_user, "user_type", None) + + if project_id: + # Verify user has access to this project + project = crud.get_project(session=session, project_id=project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Check access based on user type + if user_type == "client": + # Client must have explicit access + if not crud.user_has_project_access( + session=session, project_id=project_id, user_id=current_user.id + ): + raise HTTPException(status_code=403, detail="Not enough permissions") + else: + # Team member must be in same organization + if ( + not current_user.organization_id + or project.organization_id != current_user.organization_id + ): + raise HTTPException(status_code=403, detail="Not enough permissions") + + galleries = crud.get_galleries_by_project( + session=session, project_id=project_id, skip=skip, limit=limit + ) + count = len(galleries) # Simple count for project galleries + else: + # No specific project - list all accessible galleries + if user_type == "client": + # Get galleries from all projects the client has access to + accessible_projects = crud.get_user_accessible_projects( + session=session, user_id=current_user.id, skip=0, limit=1000 + ) + project_ids = [p.id for p in accessible_projects] + + # Get galleries for all accessible projects + galleries = [] + for pid in project_ids[skip : skip + limit]: + project_galleries = crud.get_galleries_by_project( + session=session, project_id=pid, skip=0, limit=100 + ) + galleries.extend(project_galleries) + + count = sum( + len( + crud.get_galleries_by_project( + session=session, project_id=pid, skip=0, limit=1000 + ) + ) + for pid in project_ids + ) + else: + # Team member - get all galleries from organization + if not current_user.organization_id: + raise HTTPException( + status_code=400, detail="User is not part of an organization" + ) + + galleries = crud.get_galleries_by_organization( + session=session, + organization_id=current_user.organization_id, + skip=skip, + limit=limit, + ) + count = crud.count_galleries_by_organization( + session=session, organization_id=current_user.organization_id + ) + + return GalleriesPublic(data=galleries, count=count) + + +@router.post("/", response_model=GalleryPublic) +def create_gallery( + *, session: SessionDep, current_user: CurrentUser, gallery_in: GalleryCreate +) -> Any: + """ + Create new gallery. Only team members can create galleries. + """ + user_type = getattr(current_user, "user_type", None) + + # Only team members can create galleries + if user_type != "team_member": + raise HTTPException( + status_code=403, detail="Only team members can create galleries" + ) + + if not current_user.organization_id: + raise HTTPException( + status_code=400, detail="User is not part of an organization" + ) + + # Verify project belongs to user's organization + project = crud.get_project(session=session, project_id=gallery_in.project_id) + if not project or project.organization_id != current_user.organization_id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + gallery = crud.create_gallery(session=session, gallery_in=gallery_in) + return gallery + + +@router.get("/{id}", response_model=GalleryPublic) +def read_gallery(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: + """ + Get gallery by ID. + """ + gallery = crud.get_gallery(session=session, gallery_id=id) + if not gallery: + raise HTTPException(status_code=404, detail="Gallery not found") + + # Check access based on user type + user_type = getattr(current_user, "user_type", None) + project = crud.get_project(session=session, project_id=gallery.project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + if user_type == "client": + # Client must have access to the project + if not crud.user_has_project_access( + session=session, project_id=project.id, user_id=current_user.id + ): + raise HTTPException(status_code=403, detail="Not enough permissions") + else: + # Team member must be in same organization + if ( + not current_user.organization_id + or project.organization_id != current_user.organization_id + ): + raise HTTPException(status_code=403, detail="Not enough permissions") + + return gallery + + +@router.put("/{id}", response_model=GalleryPublic) +def update_gallery( + *, + session: SessionDep, + current_user: CurrentUser, + id: uuid.UUID, + gallery_in: GalleryUpdate, +) -> Any: + """ + Update a gallery. Only team members can update galleries. + """ + user_type = getattr(current_user, "user_type", None) + + # Only team members can update galleries + if user_type != "team_member": + raise HTTPException( + status_code=403, detail="Only team members can update galleries" + ) + + gallery = crud.get_gallery(session=session, gallery_id=id) + if not gallery: + raise HTTPException(status_code=404, detail="Gallery not found") + + # Check if gallery's project belongs to user's organization + project = crud.get_project(session=session, project_id=gallery.project_id) + if not project or project.organization_id != current_user.organization_id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + gallery = crud.update_gallery( + session=session, db_gallery=gallery, gallery_in=gallery_in + ) + return gallery + + +@router.delete("/{id}") +def delete_gallery( + session: SessionDep, current_user: CurrentUser, id: uuid.UUID +) -> Message: + """ + Delete a gallery. Only team members can delete galleries. + """ + user_type = getattr(current_user, "user_type", None) + + # Only team members can delete galleries + if user_type != "team_member": + raise HTTPException( + status_code=403, detail="Only team members can delete galleries" + ) + + gallery = crud.get_gallery(session=session, gallery_id=id) + if not gallery: + raise HTTPException(status_code=404, detail="Gallery not found") + + # Check if gallery's project belongs to user's organization + project = crud.get_project(session=session, project_id=gallery.project_id) + if not project or project.organization_id != current_user.organization_id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + crud.delete_gallery(session=session, gallery_id=id) + return Message(message="Gallery deleted successfully") diff --git a/backend/app/api/routes/invitations.py b/backend/app/api/routes/invitations.py new file mode 100644 index 0000000000..250ff3a408 --- /dev/null +++ b/backend/app/api/routes/invitations.py @@ -0,0 +1,104 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException +from sqlmodel import func, select + +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + OrganizationInvitation, + OrganizationInvitationCreate, + OrganizationInvitationPublic, + OrganizationInvitationsPublic, +) + +router = APIRouter() + + +@router.post("/", response_model=OrganizationInvitationPublic) +def create_invitation( + *, + session: SessionDep, + current_user: CurrentUser, + invitation_in: OrganizationInvitationCreate, +) -> Any: + """ + Create an organization invitation. + Team members can invite people to their organization. + """ + if not current_user.organization_id: + raise HTTPException( + status_code=400, + detail="You must be part of an organization to invite others", + ) + + # Check if invitation already exists + statement = select(OrganizationInvitation).where( + OrganizationInvitation.email == invitation_in.email, + OrganizationInvitation.organization_id == current_user.organization_id, + ) + existing = session.exec(statement).first() + if existing: + raise HTTPException( + status_code=400, + detail="An invitation has already been sent to this email", + ) + + invitation = OrganizationInvitation( + email=invitation_in.email, + organization_id=current_user.organization_id, + ) + session.add(invitation) + session.commit() + session.refresh(invitation) + return invitation + + +@router.get("/", response_model=OrganizationInvitationsPublic) +def read_invitations( + session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 +) -> Any: + """ + Retrieve invitations for the current user's organization. + """ + if not current_user.organization_id: + raise HTTPException( + status_code=400, + detail="You must be part of an organization", + ) + + count_statement = ( + select(func.count()) + .select_from(OrganizationInvitation) + .where(OrganizationInvitation.organization_id == current_user.organization_id) + ) + count = session.exec(count_statement).one() + + statement = ( + select(OrganizationInvitation) + .where(OrganizationInvitation.organization_id == current_user.organization_id) + .offset(skip) + .limit(limit) + ) + invitations = session.exec(statement).all() + + return OrganizationInvitationsPublic(data=invitations, count=count) + + +@router.delete("/{invitation_id}") +def delete_invitation( + session: SessionDep, current_user: CurrentUser, invitation_id: uuid.UUID +) -> Any: + """ + Delete an invitation. + """ + invitation = session.get(OrganizationInvitation, invitation_id) + if not invitation: + raise HTTPException(status_code=404, detail="Invitation not found") + + if invitation.organization_id != current_user.organization_id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + session.delete(invitation) + session.commit() + return {"message": "Invitation deleted"} diff --git a/backend/app/api/routes/organizations.py b/backend/app/api/routes/organizations.py new file mode 100644 index 0000000000..c4eadffc9c --- /dev/null +++ b/backend/app/api/routes/organizations.py @@ -0,0 +1,114 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException + +from app import crud +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + Organization, + OrganizationCreate, + OrganizationPublic, + OrganizationUpdate, +) + +router = APIRouter() + + +@router.post("/", response_model=OrganizationPublic) +def create_organization( + *, + session: SessionDep, + current_user: CurrentUser, + organization_in: OrganizationCreate, +) -> Any: + """ + Create a new organization. + Only team members without an organization can create one. + """ + # Check user is a team member + if getattr(current_user, "user_type", None) != "team_member": + raise HTTPException( + status_code=403, + detail="Only team members can create organizations", + ) + + # Check user doesn't already have an organization + if current_user.organization_id: + raise HTTPException( + status_code=400, + detail="You already belong to an organization", + ) + + # Create the organization + organization = crud.create_organization( + session=session, organization_in=organization_in + ) + + # Assign the user to the new organization + current_user.organization_id = organization.id + session.add(current_user) + session.commit() + session.refresh(current_user) + + return organization + + +@router.get("/{organization_id}", response_model=OrganizationPublic) +def read_organization( + session: SessionDep, current_user: CurrentUser, organization_id: uuid.UUID +) -> Any: + """ + Get organization by ID. + Users can only view their own organization. + """ + organization = session.get(Organization, organization_id) + if not organization: + raise HTTPException(status_code=404, detail="Organization not found") + + # Only allow viewing own organization (unless superuser) + if ( + not current_user.is_superuser + and current_user.organization_id != organization_id + ): + raise HTTPException( + status_code=403, + detail="Not enough permissions", + ) + + return organization + + +@router.put("/{organization_id}", response_model=OrganizationPublic) +def update_organization( + *, + session: SessionDep, + current_user: CurrentUser, + organization_id: uuid.UUID, + organization_in: OrganizationUpdate, +) -> Any: + """ + Update an organization. + Only team members from that organization can update it. + """ + organization = session.get(Organization, organization_id) + if not organization: + raise HTTPException(status_code=404, detail="Organization not found") + + # Only allow updating own organization (unless superuser) + if ( + not current_user.is_superuser + and current_user.organization_id != organization_id + ): + raise HTTPException( + status_code=403, + detail="Not enough permissions", + ) + + update_dict = organization_in.model_dump(exclude_unset=True) + organization.sqlmodel_update(update_dict) + session.add(organization) + session.commit() + session.refresh(organization) + + return organization diff --git a/backend/app/api/routes/private.py b/backend/app/api/routes/private.py index 9f33ef1900..bd44e03b67 100644 --- a/backend/app/api/routes/private.py +++ b/backend/app/api/routes/private.py @@ -3,10 +3,10 @@ from fastapi import APIRouter from pydantic import BaseModel +from app import crud from app.api.deps import SessionDep -from app.core.security import get_password_hash from app.models import ( - User, + UserCreate, UserPublic, ) @@ -26,13 +26,11 @@ def create_user(user_in: PrivateUserCreate, session: SessionDep) -> Any: Create a new user. """ - user = User( + user_create = UserCreate( email=user_in.email, full_name=user_in.full_name, - hashed_password=get_password_hash(user_in.password), + password=user_in.password, ) - session.add(user) - session.commit() - + user = crud.create_user(session=session, user_create=user_create) return user diff --git a/backend/app/api/routes/project_access.py b/backend/app/api/routes/project_access.py new file mode 100644 index 0000000000..1e85cca480 --- /dev/null +++ b/backend/app/api/routes/project_access.py @@ -0,0 +1,178 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException + +from app import crud +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + Message, + ProjectAccessCreate, + ProjectAccessesPublic, + ProjectAccessPublic, + ProjectAccessUpdate, + User, +) + +router = APIRouter() + + +@router.post("/{project_id}/access", response_model=ProjectAccessPublic) +def grant_project_access( + *, + session: SessionDep, + current_user: CurrentUser, + project_id: uuid.UUID, + user_id: uuid.UUID, + role: str = "viewer", + can_comment: bool = True, + can_download: bool = True, +) -> Any: + """ + Grant a user access to a project (invite a client). + Only team members can invite clients. + """ + # Check if current user is a team member + if getattr(current_user, "user_type", None) != "team_member": + raise HTTPException( + status_code=403, + detail="Only team members can invite clients to projects", + ) + + # Check if project exists and user has access to it + project = crud.get_project(session=session, project_id=project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Check if current user's organization owns the project + if ( + not current_user.organization_id + or current_user.organization_id != project.organization_id + ): + raise HTTPException( + status_code=403, + detail="You don't have permission to manage this project", + ) + + # Check if user to be invited exists + user_to_invite = session.get(User, user_id) + if not user_to_invite: + raise HTTPException(status_code=404, detail="User not found") + + # Create access + access_in = ProjectAccessCreate( + project_id=project_id, + user_id=user_id, + role=role, + can_comment=can_comment, + can_download=can_download, + ) + access = crud.create_project_access(session=session, access_in=access_in) + return access + + +@router.get("/{project_id}/access", response_model=ProjectAccessesPublic) +def read_project_access_list( + *, + session: SessionDep, + current_user: CurrentUser, + project_id: uuid.UUID, +) -> Any: + """ + Get list of users with access to a project. + Only team members from the project's organization can see this. + """ + # Check if project exists + project = crud.get_project(session=session, project_id=project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Check permissions + if getattr(current_user, "user_type", None) == "team_member": + if current_user.organization_id != project.organization_id: + raise HTTPException(status_code=403, detail="Access denied") + else: + # Clients can only see their own access + if not crud.user_has_project_access( + session=session, project_id=project_id, user_id=current_user.id + ): + raise HTTPException(status_code=403, detail="Access denied") + + access_list = crud.get_project_access_list(session=session, project_id=project_id) + return ProjectAccessesPublic(data=access_list, count=len(access_list)) + + +@router.delete("/{project_id}/access/{user_id}", response_model=Message) +def revoke_project_access( + *, + session: SessionDep, + current_user: CurrentUser, + project_id: uuid.UUID, + user_id: uuid.UUID, +) -> Any: + """ + Revoke a user's access to a project. + Only team members from the project's organization can do this. + """ + # Check if current user is a team member + if getattr(current_user, "user_type", None) != "team_member": + raise HTTPException( + status_code=403, + detail="Only team members can revoke project access", + ) + + # Check if project exists + project = crud.get_project(session=session, project_id=project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Check permissions + if current_user.organization_id != project.organization_id: + raise HTTPException(status_code=403, detail="Access denied") + + # Revoke access + crud.delete_project_access(session=session, project_id=project_id, user_id=user_id) + return Message(message="Access revoked successfully") + + +@router.patch("/{project_id}/access/{user_id}", response_model=ProjectAccessPublic) +def update_project_access_permissions( + *, + session: SessionDep, + current_user: CurrentUser, + project_id: uuid.UUID, + user_id: uuid.UUID, + access_in: ProjectAccessUpdate, +) -> Any: + """ + Update a user's project access permissions. + Only team members from the project's organization can do this. + """ + # Check if current user is a team member + if getattr(current_user, "user_type", None) != "team_member": + raise HTTPException( + status_code=403, + detail="Only team members can update project access", + ) + + # Check if project exists + project = crud.get_project(session=session, project_id=project_id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Check permissions + if current_user.organization_id != project.organization_id: + raise HTTPException(status_code=403, detail="Access denied") + + # Get existing access + db_access = crud.get_project_access( + session=session, project_id=project_id, user_id=user_id + ) + if not db_access: + raise HTTPException(status_code=404, detail="Access not found") + + # Update access + access = crud.update_project_access( + session=session, db_access=db_access, access_in=access_in + ) + return access diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py new file mode 100644 index 0000000000..ed1aef9ad0 --- /dev/null +++ b/backend/app/api/routes/projects.py @@ -0,0 +1,194 @@ +import uuid +from typing import Any + +from fastapi import APIRouter, HTTPException + +from app import crud +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + DashboardStats, + GalleryCreate, + Message, + ProjectCreate, + ProjectPublic, + ProjectsPublic, + ProjectUpdate, +) + +router = APIRouter() + + +@router.get("/", response_model=ProjectsPublic) +def read_projects( + session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 +) -> Any: + """ + Retrieve projects. + - Team members see projects from their organization + - Clients see projects they have been invited to + """ + if getattr(current_user, "user_type", None) == "client": + # Clients see only projects they have access to + projects = crud.get_user_accessible_projects( + session=session, + user_id=current_user.id, + skip=skip, + limit=limit, + ) + count = crud.count_user_accessible_projects( + session=session, user_id=current_user.id + ) + else: + # Team members see projects from their organization + if not current_user.organization_id: + raise HTTPException( + status_code=400, detail="User is not part of an organization" + ) + + projects = crud.get_projects_by_organization( + session=session, + organization_id=current_user.organization_id, + skip=skip, + limit=limit, + ) + count = crud.count_projects_by_organization( + session=session, organization_id=current_user.organization_id + ) + + return ProjectsPublic(data=projects, count=count) + + +@router.get("/stats", response_model=DashboardStats) +def read_dashboard_stats(session: SessionDep, current_user: CurrentUser) -> Any: + """ + Get dashboard statistics for the current user's organization. + Only available to team members. + """ + if getattr(current_user, "user_type", None) != "team_member": + raise HTTPException( + status_code=403, detail="Dashboard stats only available to team members" + ) + + if not current_user.organization_id: + raise HTTPException( + status_code=400, detail="User is not part of an organization" + ) + + return crud.get_dashboard_stats( + session=session, organization_id=current_user.organization_id + ) + + +@router.post("/", response_model=ProjectPublic) +def create_project( + *, session: SessionDep, current_user: CurrentUser, project_in: ProjectCreate +) -> Any: + """ + Create new project. + Only team members can create projects. + """ + if getattr(current_user, "user_type", None) != "team_member": + raise HTTPException( + status_code=403, detail="Only team members can create projects" + ) + + if not current_user.organization_id: + raise HTTPException( + status_code=400, detail="User is not part of an organization" + ) + + # Ensure the project is being created for the user's organization + if project_in.organization_id != current_user.organization_id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + project = crud.create_project(session=session, project_in=project_in) + + # Automatically create a gallery for the project + gallery_in = GalleryCreate( + name=f"{project.name} - Gallery", + project_id=project.id, + status="draft", + ) + crud.create_gallery(session=session, gallery_in=gallery_in) + + return project + + +@router.get("/{id}", response_model=ProjectPublic) +def read_project(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: + """ + Get project by ID. + """ + project = crud.get_project(session=session, project_id=id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Check permissions based on user type + if getattr(current_user, "user_type", None) == "client": + # Clients need explicit access + if not crud.user_has_project_access( + session=session, project_id=id, user_id=current_user.id + ): + raise HTTPException(status_code=403, detail="Not enough permissions") + else: + # Team members need organization match + if project.organization_id != current_user.organization_id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + return project + + +@router.put("/{id}", response_model=ProjectPublic) +def update_project( + *, + session: SessionDep, + current_user: CurrentUser, + id: uuid.UUID, + project_in: ProjectUpdate, +) -> Any: + """ + Update a project. + Only team members from the project's organization can update projects. + """ + if getattr(current_user, "user_type", None) != "team_member": + raise HTTPException( + status_code=403, detail="Only team members can update projects" + ) + + project = crud.get_project(session=session, project_id=id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Check if project belongs to user's organization + if project.organization_id != current_user.organization_id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + project = crud.update_project( + session=session, db_project=project, project_in=project_in + ) + return project + + +@router.delete("/{id}") +def delete_project( + session: SessionDep, current_user: CurrentUser, id: uuid.UUID +) -> Message: + """ + Delete a project. + Only team members from the project's organization can delete projects. + """ + if getattr(current_user, "user_type", None) != "team_member": + raise HTTPException( + status_code=403, detail="Only team members can delete projects" + ) + + project = crud.get_project(session=session, project_id=id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Check if project belongs to user's organization + if project.organization_id != current_user.organization_id: + raise HTTPException(status_code=403, detail="Not enough permissions") + + crud.delete_project(session=session, project_id=id) + return Message(message="Project deleted successfully") diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 6429818458..708d4def76 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -48,6 +48,30 @@ def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any: return UsersPublic(data=users, count=count) +@router.get("/clients", response_model=UsersPublic) +def read_clients( + session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 +) -> Any: + """ + Retrieve client users. Team members can access this to invite clients. + """ + if getattr(current_user, "user_type", None) != "team_member": + raise HTTPException( + status_code=403, + detail="Only team members can list clients", + ) + + count_statement = ( + select(func.count()).select_from(User).where(User.user_type == "client") + ) + count = session.exec(count_statement).one() + + statement = select(User).where(User.user_type == "client").offset(skip).limit(limit) + users = session.exec(statement).all() + + return UsersPublic(data=users, count=count) + + @router.post( "/", dependencies=[Depends(get_current_active_superuser)], response_model=UserPublic ) @@ -143,18 +167,169 @@ def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any: def register_user(session: SessionDep, user_in: UserRegister) -> Any: """ Create new user without the need to be logged in. + Team members are assigned to an organization only if they were invited. """ + from sqlmodel import select + + from app.models import OrganizationInvitation + user = crud.get_user_by_email(session=session, email=user_in.email) if user: raise HTTPException( status_code=400, detail="The user with this email already exists in the system", ) + user_create = UserCreate.model_validate(user_in) + + # Check if there's an invitation for this email (team members only) + if user_create.user_type == "team_member": + statement = select(OrganizationInvitation).where( + OrganizationInvitation.email == user_create.email + ) + invitation = session.exec(statement).first() + + if invitation: + # Auto-assign to the invited organization + user_create.organization_id = invitation.organization_id + # Delete the invitation after use + session.delete(invitation) + session.commit() + user = crud.create_user(session=session, user_create=user_create) return user +@router.get("/organization-members", response_model=UsersPublic) +def get_organization_members( + session: SessionDep, + current_user: CurrentUser, + skip: int = 0, + limit: int = 100, +) -> Any: + """ + Get all members of the current user's organization. + Accessible by team members to see their organization members. + """ + if getattr(current_user, "user_type", None) != "team_member": + raise HTTPException( + status_code=403, detail="Only team members can view organization members" + ) + + if not current_user.organization_id: + raise HTTPException( + status_code=400, + detail="You must be part of an organization to view members", + ) + + count_statement = ( + select(func.count()) + .select_from(User) + .where(User.organization_id == current_user.organization_id) + .where(User.user_type == "team_member") + ) + count = session.exec(count_statement).one() + + statement = ( + select(User) + .where(User.organization_id == current_user.organization_id) + .where(User.user_type == "team_member") + .offset(skip) + .limit(limit) + ) + users = session.exec(statement).all() + + return UsersPublic(data=users, count=count) + + +@router.get("/pending", response_model=UsersPublic) +def get_pending_users( + session: SessionDep, + current_user: CurrentUser, + skip: int = 0, + limit: int = 100, +) -> Any: + """ + Get users without an organization (pending approval). + Accessible by team members to invite people to their organization. + """ + if getattr(current_user, "user_type", None) != "team_member": + raise HTTPException( + status_code=403, detail="Only team members can invite users" + ) + + from sqlmodel import select + + count_statement = ( + select(func.count()) + .select_from(User) + .where(User.organization_id.is_(None)) # type: ignore[union-attr] + .where(User.user_type == "team_member") + ) + count = session.exec(count_statement).one() + + statement = ( + select(User) + .where(User.organization_id.is_(None)) # type: ignore[union-attr] + .where(User.user_type == "team_member") + .offset(skip) + .limit(limit) + ) + users = session.exec(statement).all() + + return UsersPublic(data=users, count=count) + + +@router.patch("/{user_id}/assign-organization", response_model=UserPublic) +def assign_user_to_organization( + user_id: uuid.UUID, + session: SessionDep, + current_user: CurrentUser, + organization_id: uuid.UUID | None = None, +) -> Any: + """ + Assign a user to an organization. + Team members can assign users to their own organization. + Superusers can assign to any organization. + """ + if ( + getattr(current_user, "user_type", None) != "team_member" + and not current_user.is_superuser + ): + raise HTTPException(status_code=403, detail="Not enough permissions") + + user = session.get(User, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Determine which organization to assign to + if current_user.is_superuser and organization_id: + # Superuser can specify any organization + target_org_id = organization_id + else: + # Team members assign to their own organization + if not current_user.organization_id: + raise HTTPException( + status_code=400, + detail="You must be part of an organization to invite others", + ) + target_org_id = current_user.organization_id + + # Verify organization exists + from app.models import Organization + + org = session.get(Organization, target_org_id) + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + + user.organization_id = target_org_id + session.add(user) + session.commit() + session.refresh(user) + + return user + + @router.get("/{user_id}", response_model=UserPublic) def read_user_by_id( user_id: uuid.UUID, session: SessionDep, current_user: CurrentUser diff --git a/backend/app/api/routes/utils.py b/backend/app/api/routes/utils.py index fc093419b3..c022f2606c 100644 --- a/backend/app/api/routes/utils.py +++ b/backend/app/api/routes/utils.py @@ -1,3 +1,8 @@ +import platform +import sys +from datetime import datetime +from typing import Any + from fastapi import APIRouter, Depends from pydantic.networks import EmailStr @@ -29,3 +34,30 @@ def test_email(email_to: EmailStr) -> Message: @router.get("/health-check/") async def health_check() -> bool: return True + + +@router.get("/system-info/") +async def get_system_info() -> dict[str, Any]: + """ + Get interesting system information including current time, platform details, and Python version. + """ + return { + "message": "System information retrieved successfully", + "timestamp": datetime.now().isoformat(), + "platform": { + "system": platform.system(), + "release": platform.release(), + "version": platform.version(), + "machine": platform.machine(), + "processor": platform.processor(), + }, + "python": { + "version": sys.version, + "version_info": { + "major": sys.version_info.major, + "minor": sys.version_info.minor, + "micro": sys.version_info.micro, + }, + }, + "fun_fact": "This API endpoint was created as part of CS4800 team project exercise!", + } diff --git a/backend/app/core/db.py b/backend/app/core/db.py index ba991fb36d..a78956144c 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -2,7 +2,7 @@ from app import crud from app.core.config import settings -from app.models import User, UserCreate +from app.models import OrganizationCreate, User, UserCreate engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) @@ -21,13 +21,25 @@ def init_db(session: Session) -> None: # This works because the models are already imported and registered from app.models # SQLModel.metadata.create_all(engine) + # Check if superuser exists user = session.exec( select(User).where(User.email == settings.FIRST_SUPERUSER) ).first() + if not user: + # Create the superuser's organization + organization_in = OrganizationCreate( + name="Admin Organization", description="Organization for admin user" + ) + organization = crud.create_organization( + session=session, organization_in=organization_in + ) + + # Create superuser and assign to their organization user_in = UserCreate( email=settings.FIRST_SUPERUSER, password=settings.FIRST_SUPERUSER_PASSWORD, is_superuser=True, + organization_id=organization.id, ) user = crud.create_user(session=session, user_create=user_in) diff --git a/backend/app/crud.py b/backend/app/crud.py index 905bf48724..9de6a84d6a 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -1,10 +1,30 @@ import uuid +from datetime import datetime, timedelta from typing import Any -from sqlmodel import Session, select +from sqlmodel import Session, desc, func, or_, select from app.core.security import get_password_hash, verify_password -from app.models import Item, ItemCreate, User, UserCreate, UserUpdate +from app.models import ( + DashboardStats, + Gallery, + GalleryCreate, + GalleryUpdate, + Item, + ItemCreate, + Organization, + OrganizationCreate, + OrganizationUpdate, + Project, + ProjectAccess, + ProjectAccessCreate, + ProjectAccessUpdate, + ProjectCreate, + ProjectUpdate, + User, + UserCreate, + UserUpdate, +) def create_user(*, session: Session, user_create: UserCreate) -> User: @@ -52,3 +72,362 @@ def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) - session.commit() session.refresh(db_item) return db_item + + +# ============================================================================ +# ORGANIZATION CRUD +# ============================================================================ + + +def create_organization( + *, session: Session, organization_in: OrganizationCreate +) -> Organization: + db_obj = Organization.model_validate(organization_in) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + + +def get_organization( + *, session: Session, organization_id: uuid.UUID +) -> Organization | None: + return session.get(Organization, organization_id) + + +def get_default_organization(*, session: Session) -> Organization | None: + """Get the default organization (typically 'Default Organization')""" + statement = select(Organization).where(Organization.name == "Default Organization") + return session.exec(statement).first() + + +def update_organization( + *, + session: Session, + db_organization: Organization, + organization_in: OrganizationUpdate, +) -> Organization: + organization_data = organization_in.model_dump(exclude_unset=True) + db_organization.sqlmodel_update(organization_data) + session.add(db_organization) + session.commit() + session.refresh(db_organization) + return db_organization + + +# ============================================================================ +# PROJECT CRUD +# ============================================================================ + + +def create_project(*, session: Session, project_in: ProjectCreate) -> Project: + db_obj = Project.model_validate(project_in) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + + +def get_project(*, session: Session, project_id: uuid.UUID) -> Project | None: + return session.get(Project, project_id) + + +def get_projects_by_organization( + *, session: Session, organization_id: uuid.UUID, skip: int = 0, limit: int = 100 +) -> list[Project]: + statement = ( + select(Project) + .where(Project.organization_id == organization_id) + .offset(skip) + .limit(limit) + .order_by(desc(Project.created_at)) + ) + return list(session.exec(statement).all()) + + +def count_projects_by_organization( + *, session: Session, organization_id: uuid.UUID +) -> int: + statement = ( + select(func.count()) + .select_from(Project) + .where(Project.organization_id == organization_id) + ) + return session.exec(statement).one() + + +def update_project( + *, session: Session, db_project: Project, project_in: ProjectUpdate +) -> Project: + project_data = project_in.model_dump(exclude_unset=True) + project_data["updated_at"] = datetime.utcnow() + db_project.sqlmodel_update(project_data) + session.add(db_project) + session.commit() + session.refresh(db_project) + return db_project + + +def delete_project(*, session: Session, project_id: uuid.UUID) -> None: + project = session.get(Project, project_id) + if project: + session.delete(project) + session.commit() + + +# ============================================================================ +# GALLERY CRUD +# ============================================================================ + + +def create_gallery(*, session: Session, gallery_in: GalleryCreate) -> Gallery: + db_obj = Gallery.model_validate(gallery_in) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + + +def get_gallery(*, session: Session, gallery_id: uuid.UUID) -> Gallery | None: + return session.get(Gallery, gallery_id) + + +def get_galleries_by_project( + *, session: Session, project_id: uuid.UUID, skip: int = 0, limit: int = 100 +) -> list[Gallery]: + statement = ( + select(Gallery) + .where(Gallery.project_id == project_id) + .offset(skip) + .limit(limit) + .order_by(desc(Gallery.created_at)) + ) + return list(session.exec(statement).all()) + + +def get_galleries_by_organization( + *, session: Session, organization_id: uuid.UUID, skip: int = 0, limit: int = 100 +) -> list[Gallery]: + """Get all galleries for projects in an organization""" + statement = ( + select(Gallery) + .join(Project) + .where(Project.organization_id == organization_id) + .offset(skip) + .limit(limit) + .order_by(desc(Gallery.created_at)) + ) + return list(session.exec(statement).all()) + + +def count_galleries_by_organization( + *, session: Session, organization_id: uuid.UUID +) -> int: + statement = ( + select(func.count()) + .select_from(Gallery) + .join(Project) + .where(Project.organization_id == organization_id) + ) + return session.exec(statement).one() + + +def update_gallery( + *, session: Session, db_gallery: Gallery, gallery_in: GalleryUpdate +) -> Gallery: + gallery_data = gallery_in.model_dump(exclude_unset=True) + db_gallery.sqlmodel_update(gallery_data) + session.add(db_gallery) + session.commit() + session.refresh(db_gallery) + return db_gallery + + +def delete_gallery(*, session: Session, gallery_id: uuid.UUID) -> None: + gallery = session.get(Gallery, gallery_id) + if gallery: + session.delete(gallery) + session.commit() + + +# ============================================================================ +# PROJECT ACCESS CRUD +# ============================================================================ + + +def create_project_access( + *, session: Session, access_in: ProjectAccessCreate +) -> ProjectAccess: + """Grant a user access to a project""" + # Check if access already exists + existing = session.exec( + select(ProjectAccess).where( + ProjectAccess.project_id == access_in.project_id, + ProjectAccess.user_id == access_in.user_id, + ) + ).first() + + if existing: + # Update existing access + for key, value in access_in.model_dump(exclude_unset=True).items(): + setattr(existing, key, value) + session.add(existing) + session.commit() + session.refresh(existing) + return existing + + # Create new access + db_obj = ProjectAccess.model_validate(access_in) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + return db_obj + + +def get_project_access( + *, session: Session, project_id: uuid.UUID, user_id: uuid.UUID +) -> ProjectAccess | None: + """Get a user's access to a specific project""" + statement = select(ProjectAccess).where( + ProjectAccess.project_id == project_id, + ProjectAccess.user_id == user_id, + ) + return session.exec(statement).first() + + +def get_project_access_list( + *, session: Session, project_id: uuid.UUID +) -> list[ProjectAccess]: + """Get all users with access to a project""" + statement = select(ProjectAccess).where(ProjectAccess.project_id == project_id) + return list(session.exec(statement).all()) + + +def get_user_accessible_projects( + *, session: Session, user_id: uuid.UUID, skip: int = 0, limit: int = 100 +) -> list[Project]: + """Get all projects a user has access to (for clients)""" + statement = ( + select(Project) + .join(ProjectAccess) + .where(ProjectAccess.user_id == user_id) + .offset(skip) + .limit(limit) + .order_by(desc(Project.created_at)) + ) + return list(session.exec(statement).all()) + + +def count_user_accessible_projects(*, session: Session, user_id: uuid.UUID) -> int: + """Count projects a user has access to""" + statement = ( + select(func.count()) + .select_from(Project) + .join(ProjectAccess) + .where(ProjectAccess.user_id == user_id) + ) + return session.exec(statement).one() + + +def update_project_access( + *, session: Session, db_access: ProjectAccess, access_in: ProjectAccessUpdate +) -> ProjectAccess: + """Update project access permissions""" + access_data = access_in.model_dump(exclude_unset=True) + db_access.sqlmodel_update(access_data) + session.add(db_access) + session.commit() + session.refresh(db_access) + return db_access + + +def delete_project_access( + *, session: Session, project_id: uuid.UUID, user_id: uuid.UUID +) -> None: + """Remove a user's access to a project""" + access = get_project_access(session=session, project_id=project_id, user_id=user_id) + if access: + session.delete(access) + session.commit() + + +def user_has_project_access( + *, session: Session, project_id: uuid.UUID, user_id: uuid.UUID +) -> bool: + """Check if a user has access to a project""" + statement = ( + select(func.count()) + .select_from(ProjectAccess) + .where( + ProjectAccess.project_id == project_id, + ProjectAccess.user_id == user_id, + ) + ) + count = session.exec(statement).one() + return count > 0 + + +# ============================================================================ +# DASHBOARD STATS +# ============================================================================ + + +def get_dashboard_stats( + *, session: Session, organization_id: uuid.UUID +) -> DashboardStats: + """Calculate dashboard statistics for an organization""" + + # Count active projects (in_progress or review status) + active_projects_stmt = ( + select(func.count()) + .select_from(Project) + .where( + Project.organization_id == organization_id, + or_(Project.status == "in_progress", Project.status == "review"), + ) + ) + active_projects = session.exec(active_projects_stmt).one() + + # Count upcoming deadlines (projects with deadline in next 14 days, not completed) + today = datetime.utcnow().date() + two_weeks = today + timedelta(days=14) + upcoming_deadlines_stmt = ( + select(func.count()) + .select_from(Project) + .where( + Project.organization_id == organization_id, + Project.deadline.isnot(None), # type: ignore[union-attr] + Project.deadline >= today, # type: ignore[operator] + Project.deadline <= two_weeks, # type: ignore[operator] + Project.status != "completed", + ) + ) + upcoming_deadlines = session.exec(upcoming_deadlines_stmt).one() + + # Count team members in organization + team_members_stmt = ( + select(func.count()) + .select_from(User) + .where(User.organization_id == organization_id) + ) + team_members = session.exec(team_members_stmt).one() + + # Count completed projects this month + first_day_of_month = today.replace(day=1) + completed_this_month_stmt = ( + select(func.count()) + .select_from(Project) + .where( + Project.organization_id == organization_id, + Project.status == "completed", + Project.updated_at >= first_day_of_month, + ) + ) + completed_this_month = session.exec(completed_this_month_stmt).one() + + return DashboardStats( + active_projects=active_projects, + upcoming_deadlines=upcoming_deadlines, + team_members=team_members, + completed_this_month=completed_this_month, + ) diff --git a/backend/app/main.py b/backend/app/main.py index 9a95801e74..b25f93b2a8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,7 +8,9 @@ def custom_generate_unique_id(route: APIRoute) -> str: - return f"{route.tags[0]}-{route.name}" + if route.tags: + return f"{route.tags[0]}-{route.name}" + return route.name if settings.SENTRY_DSN and settings.ENVIRONMENT != "local": @@ -18,6 +20,7 @@ def custom_generate_unique_id(route: APIRoute) -> str: title=settings.PROJECT_NAME, openapi_url=f"{settings.API_V1_STR}/openapi.json", generate_unique_id_function=custom_generate_unique_id, + redirect_slashes=False, ) # Set all CORS enabled origins diff --git a/backend/app/models.py b/backend/app/models.py index 2389b4a532..7383fd05ca 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,4 +1,7 @@ import uuid +from datetime import date as DateType +from datetime import datetime +from typing import Optional from pydantic import EmailStr from sqlmodel import Field, Relationship, SQLModel @@ -10,6 +13,8 @@ class UserBase(SQLModel): is_active: bool = True is_superuser: bool = False full_name: str | None = Field(default=None, max_length=255) + user_type: str = Field(default="team_member", max_length=50) + organization_id: uuid.UUID | None = Field(default=None) # Properties to receive via API on creation @@ -21,6 +26,7 @@ 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) + user_type: str = Field(default="team_member", max_length=50) # Properties to receive via API on update, all are optional @@ -44,6 +50,13 @@ 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) + organization_id: uuid.UUID | None = Field( + default=None, foreign_key="organization.id" + ) + organization: Optional["Organization"] = Relationship(back_populates="users") + project_access: list["ProjectAccess"] = Relationship( + back_populates="user", cascade_delete=True + ) # Properties to return via API, id is always required @@ -111,3 +124,247 @@ class TokenPayload(SQLModel): class NewPassword(SQLModel): token: str new_password: str = Field(min_length=8, max_length=40) + + +# ============================================================================ +# ORGANIZATION MODELS +# ============================================================================ + + +class OrganizationBase(SQLModel): + name: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=1000) + + +class OrganizationCreate(OrganizationBase): + pass + + +class OrganizationUpdate(SQLModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=1000) + + +class Organization(OrganizationBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + created_at: datetime = Field(default_factory=datetime.utcnow) + users: list["User"] = Relationship(back_populates="organization") + projects: list["Project"] = Relationship( + back_populates="organization", cascade_delete=True + ) + + +class OrganizationPublic(OrganizationBase): + id: uuid.UUID + created_at: datetime + + +class OrganizationsPublic(SQLModel): + data: list[OrganizationPublic] + count: int + + +# ============================================================================ +# PROJECT MODELS +# ============================================================================ + + +class ProjectBase(SQLModel): + name: str = Field(min_length=1, max_length=255) + client_name: str = Field(min_length=1, max_length=255) + client_email: str | None = Field(default=None, max_length=255) + description: str | None = Field(default=None, max_length=2000) + status: str = Field( + default="planning", max_length=50 + ) # planning, in_progress, review, completed + deadline: DateType | None = None + start_date: DateType | None = None + budget: str | None = Field(default=None, max_length=100) + progress: int = Field(default=0, ge=0, le=100) + + +class ProjectCreate(ProjectBase): + organization_id: uuid.UUID + + +class ProjectUpdate(SQLModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + client_name: str | None = Field(default=None, min_length=1, max_length=255) + client_email: str | None = Field(default=None, max_length=255) + description: str | None = Field(default=None, max_length=2000) + status: str | None = Field(default=None, max_length=50) + deadline: DateType | None = None + start_date: DateType | None = None + budget: str | None = Field(default=None, max_length=100) + progress: int | None = Field(default=None, ge=0, le=100) + + +class Project(ProjectBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + organization_id: uuid.UUID = Field( + foreign_key="organization.id", nullable=False, ondelete="CASCADE" + ) + organization: Optional["Organization"] = Relationship(back_populates="projects") + galleries: list["Gallery"] = Relationship( + back_populates="project", cascade_delete=True + ) + access_list: list["ProjectAccess"] = Relationship( + back_populates="project", cascade_delete=True + ) + + +class ProjectPublic(ProjectBase): + id: uuid.UUID + created_at: datetime + updated_at: datetime + organization_id: uuid.UUID + + +class ProjectsPublic(SQLModel): + data: list[ProjectPublic] + count: int + + +# ============================================================================ +# GALLERY MODELS +# ============================================================================ + + +class GalleryBase(SQLModel): + name: str = Field(min_length=1, max_length=255) + date: DateType | None = None + photo_count: int = Field(default=0, ge=0) + photographer: str | None = Field(default=None, max_length=255) + status: str = Field(default="draft", max_length=50) # draft, processing, published + cover_image_url: str | None = Field(default=None, max_length=500) + + +class GalleryCreate(GalleryBase): + project_id: uuid.UUID + + +class GalleryUpdate(SQLModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + date: DateType | None = None + photo_count: int | None = Field(default=None, ge=0) + photographer: str | None = Field(default=None, max_length=255) + status: str | None = Field(default=None, max_length=50) + cover_image_url: str | None = Field(default=None, max_length=500) + + +class Gallery(GalleryBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + created_at: datetime = Field(default_factory=datetime.utcnow) + project_id: uuid.UUID = Field( + foreign_key="project.id", nullable=False, ondelete="CASCADE" + ) + project: Optional["Project"] = Relationship(back_populates="galleries") + + +class GalleryPublic(GalleryBase): + id: uuid.UUID + created_at: datetime + project_id: uuid.UUID + + +class GalleriesPublic(SQLModel): + data: list[GalleryPublic] + count: int + + +# ============================================================================ +# PROJECT ACCESS (Client Invitations) +# ============================================================================ + + +class ProjectAccessBase(SQLModel): + role: str = Field(default="viewer", max_length=50) # viewer, collaborator + can_comment: bool = Field(default=True) + can_download: bool = Field(default=True) + + +class ProjectAccessCreate(ProjectAccessBase): + project_id: uuid.UUID + user_id: uuid.UUID + + +class ProjectAccessUpdate(SQLModel): + role: str | None = Field(default=None, max_length=50) + can_comment: bool | None = None + can_download: bool | None = None + + +class ProjectAccess(ProjectAccessBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + created_at: datetime = Field(default_factory=datetime.utcnow) + project_id: uuid.UUID = Field( + foreign_key="project.id", nullable=False, ondelete="CASCADE" + ) + user_id: uuid.UUID = Field( + foreign_key="user.id", nullable=False, ondelete="CASCADE" + ) + project: Optional["Project"] = Relationship(back_populates="access_list") + user: Optional["User"] = Relationship(back_populates="project_access") + + +class ProjectAccessPublic(ProjectAccessBase): + id: uuid.UUID + created_at: datetime + project_id: uuid.UUID + user_id: uuid.UUID + + +class ProjectAccessWithUser(ProjectAccessPublic): + user: UserPublic + + +class ProjectAccessesPublic(SQLModel): + data: list[ProjectAccessPublic] + count: int + + +# ============================================================================ +# DASHBOARD STATS +# ============================================================================ + + +class DashboardStats(SQLModel): + active_projects: int + upcoming_deadlines: int + team_members: int + completed_this_month: int + + +# ============================================================================ +# Organization Invitation Models +# ============================================================================ + + +class OrganizationInvitationBase(SQLModel): + email: EmailStr = Field(max_length=255, index=True) + + +class OrganizationInvitationCreate(OrganizationInvitationBase): + pass + + +class OrganizationInvitation(OrganizationInvitationBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + created_at: datetime = Field(default_factory=datetime.utcnow) + organization_id: uuid.UUID = Field( + foreign_key="organization.id", nullable=False, ondelete="CASCADE" + ) + organization: Optional["Organization"] = Relationship() + + +class OrganizationInvitationPublic(OrganizationInvitationBase): + id: uuid.UUID + created_at: datetime + organization_id: uuid.UUID + + +class OrganizationInvitationsPublic(SQLModel): + data: list[OrganizationInvitationPublic] + count: int diff --git a/backend/app/seed_data.py b/backend/app/seed_data.py new file mode 100644 index 0000000000..8a870fbf43 --- /dev/null +++ b/backend/app/seed_data.py @@ -0,0 +1,188 @@ +"""Seed script to add sample projects and galleries to the database""" + +import logging +from datetime import date, timedelta + +from sqlmodel import Session, select + +from app.core.db import engine +from app.models import Gallery, Organization, Project + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +def seed_data() -> None: + with Session(engine) as session: + # Get the admin organization (created during init_db) + organization = session.exec( + select(Organization).where(Organization.name == "Admin Organization") + ).first() + + if not organization: + logger.error("Admin organization not found! Run initial_data.py first.") + return + + logger.info(f"Using organization: {organization.name}") + + # Check if we already have sample data + existing_projects = session.exec( + select(Project).where(Project.organization_id == organization.id) + ).first() + + if existing_projects: + logger.info("Sample data already exists. Skipping seed.") + return + + # Create sample projects + today = date.today() + + project1 = Project( + name="Sarah & John Wedding Photography", + client_name="Sarah Thompson", + client_email="sarah@example.com", + description="Full day wedding photography coverage including ceremony, reception, and portraits. Client wants natural, candid shots with some posed family photos.", + status="in_progress", + deadline=today + timedelta(days=7), + start_date=today - timedelta(days=18), + budget="$3,500", + progress=65, + organization_id=organization.id, + ) + session.add(project1) + + project2 = Project( + name="Product Shoot - TechCorp", + client_name="TechCorp Inc.", + client_email="marketing@techcorp.com", + description="Product photography for new smartphone lineup. Clean white background shots and lifestyle photography.", + status="review", + deadline=today + timedelta(days=4), + start_date=today - timedelta(days=7), + budget="$2,000", + progress=90, + organization_id=organization.id, + ) + session.add(project2) + + project3 = Project( + name="Brand Photography - StartupX", + client_name="StartupX", + client_email="team@startupx.com", + description="Corporate headshots and office culture photography for startup's website and marketing materials.", + status="planning", + deadline=today + timedelta(days=12), + start_date=today, + budget="$1,800", + progress=15, + organization_id=organization.id, + ) + session.add(project3) + + project4 = Project( + name="Corporate Headshots - Law Firm", + client_name="Smith & Associates", + client_email="office@smithlaw.com", + description="Professional headshots for 25 attorneys and staff members.", + status="planning", + deadline=today + timedelta(days=10), + start_date=today + timedelta(days=2), + budget="$1,250", + progress=10, + organization_id=organization.id, + ) + session.add(project4) + + project5 = Project( + name="Restaurant Menu Photography", + client_name="Bella Italia", + client_email="chef@bellaitalia.com", + description="Food photography for new seasonal menu. 30 dishes to be photographed.", + status="completed", + deadline=today - timedelta(days=8), + start_date=today - timedelta(days=30), + budget="$1,500", + progress=100, + organization_id=organization.id, + ) + session.add(project5) + + # Commit projects first so we have their IDs + session.commit() + session.refresh(project1) + session.refresh(project2) + session.refresh(project3) + session.refresh(project4) + session.refresh(project5) + + logger.info("Created 5 sample projects") + + # Create one gallery per project + gallery1 = Gallery( + name=f"{project1.name} - Gallery", + date=project1.start_date, + photo_count=0, + photographer=None, + status="draft", + cover_image_url=None, + project_id=project1.id, + ) + session.add(gallery1) + + gallery2 = Gallery( + name=f"{project2.name} - Gallery", + date=project2.start_date, + photo_count=0, + photographer=None, + status="draft", + cover_image_url=None, + project_id=project2.id, + ) + session.add(gallery2) + + gallery3 = Gallery( + name=f"{project3.name} - Gallery", + date=project3.start_date, + photo_count=0, + photographer=None, + status="draft", + cover_image_url=None, + project_id=project3.id, + ) + session.add(gallery3) + + gallery4 = Gallery( + name=f"{project4.name} - Gallery", + date=project4.start_date, + photo_count=0, + photographer=None, + status="draft", + cover_image_url=None, + project_id=project4.id, + ) + session.add(gallery4) + + gallery5 = Gallery( + name=f"{project5.name} - Gallery", + date=project5.start_date, + photo_count=0, + photographer=None, + status="draft", + cover_image_url=None, + project_id=project5.id, + ) + session.add(gallery5) + + session.commit() + logger.info("Created 5 galleries (one per project)") + logger.info("Sample data seeding complete!") + + +def main() -> None: + logger.info("Starting sample data seeding...") + seed_data() + logger.info("Done!") + + +if __name__ == "__main__": + main() diff --git a/backend/tests/api/routes/test_login.py b/backend/tests/api/routes/test_login.py index ee166913bd..e8f092761c 100644 --- a/backend/tests/api/routes/test_login.py +++ b/backend/tests/api/routes/test_login.py @@ -51,6 +51,7 @@ def test_recovery_password( with ( patch("app.core.config.settings.SMTP_HOST", "smtp.example.com"), patch("app.core.config.settings.SMTP_USER", "admin@example.com"), + patch("app.core.config.settings.EMAILS_FROM_EMAIL", "noreply@example.com"), ): email = "test@example.com" r = client.post( @@ -116,3 +117,40 @@ def test_reset_password_invalid_token( assert "detail" in response assert r.status_code == 400 assert response["detail"] == "Invalid token" + + +# Arthur Nguyen, Assignment 6: added test for SQL injection protection +def test_login_sql_injection_protection(client: TestClient) -> None: + """ + Test that login endpoint is protected against SQL injection attacks. + Verifies that malicious SQL commands in username/password don't compromise security. + """ + # Common SQL injection attack patterns + sql_injection_attempts = [ + "admin' OR '1'='1", + "admin'--", + "admin' OR '1'='1'--", + "'; DROP TABLE users--", + "admin' OR 1=1#", + "' UNION SELECT NULL--", + ] + + for malicious_input in sql_injection_attempts: + login_data = { + "username": malicious_input, + "password": "anypassword", + } + r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) + + # Should return authentication error, not SQL error or success + assert r.status_code == 400 + response_data = r.json() + assert "access_token" not in response_data + + # Also test in password field + login_data = { + "username": settings.FIRST_SUPERUSER, + "password": malicious_input, + } + r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data) + assert r.status_code == 400 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8ddab7b321..f38afb7463 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -7,7 +7,7 @@ from app.core.config import settings from app.core.db import engine, init_db from app.main import app -from app.models import Item, User +from app.models import Item, Organization, User from tests.utils.user import authentication_token_from_email from tests.utils.utils import get_superuser_token_headers @@ -17,10 +17,13 @@ def db() -> Generator[Session, None, None]: with Session(engine) as session: init_db(session) yield session + # Clean up in proper order due to foreign key constraints statement = delete(Item) session.execute(statement) statement = delete(User) session.execute(statement) + statement = delete(Organization) + session.execute(statement) session.commit() diff --git a/backend/tests/crud/test_gallery.py b/backend/tests/crud/test_gallery.py new file mode 100644 index 0000000000..22a23cebf1 --- /dev/null +++ b/backend/tests/crud/test_gallery.py @@ -0,0 +1,163 @@ +"""Unit tests for Gallery CRUD operations""" + +from datetime import date + +from sqlmodel import Session + +from app import crud +from app.models import GalleryCreate, GalleryUpdate, OrganizationCreate, ProjectCreate +from tests.utils.utils import random_lower_string + + +def test_create_gallery(db: Session) -> None: + """Test creating a new gallery""" + # Create organization and project first + org_in = OrganizationCreate(name=random_lower_string()) + organization = crud.create_organization(session=db, organization_in=org_in) + + project_in = ProjectCreate( + name="Test Project", client_name="Test Client", organization_id=organization.id + ) + project = crud.create_project(session=db, project_in=project_in) + + # Create gallery + gallery_name = f"Test Gallery {random_lower_string()}" + gallery_in = GalleryCreate( + name=gallery_name, + date=date.today(), + photo_count=50, + photographer="Test Photographer", + status="published", + cover_image_url="https://example.com/image.jpg", + project_id=project.id, + ) + + gallery = crud.create_gallery(session=db, gallery_in=gallery_in) + + assert gallery.name == gallery_name + assert gallery.photo_count == 50 + assert gallery.photographer == "Test Photographer" + assert gallery.status == "published" + assert gallery.project_id == project.id + assert gallery.id is not None + + +def test_get_gallery(db: Session) -> None: + """Test retrieving a gallery by ID""" + # Setup: create org, project, and gallery + org = crud.create_organization( + session=db, organization_in=OrganizationCreate(name=random_lower_string()) + ) + project = crud.create_project( + session=db, + project_in=ProjectCreate( + name="Test Project", client_name="Client", organization_id=org.id + ), + ) + gallery_in = GalleryCreate( + name="Test Gallery", photo_count=100, status="draft", project_id=project.id + ) + created_gallery = crud.create_gallery(session=db, gallery_in=gallery_in) + + # Test retrieval + retrieved_gallery = crud.get_gallery(session=db, gallery_id=created_gallery.id) + + assert retrieved_gallery is not None + assert retrieved_gallery.id == created_gallery.id + assert retrieved_gallery.name == "Test Gallery" + assert retrieved_gallery.photo_count == 100 + + +def test_update_gallery(db: Session) -> None: + """Test updating a gallery""" + # Setup + org = crud.create_organization( + session=db, organization_in=OrganizationCreate(name=random_lower_string()) + ) + project = crud.create_project( + session=db, + project_in=ProjectCreate( + name="Test Project", client_name="Client", organization_id=org.id + ), + ) + gallery = crud.create_gallery( + session=db, + gallery_in=GalleryCreate( + name="Original Name", photo_count=50, status="draft", project_id=project.id + ), + ) + + # Update gallery + gallery_update = GalleryUpdate( + name="Updated Name", photo_count=100, status="published" + ) + + updated_gallery = crud.update_gallery( + session=db, db_gallery=gallery, gallery_in=gallery_update + ) + + assert updated_gallery.name == "Updated Name" + assert updated_gallery.photo_count == 100 + assert updated_gallery.status == "published" + + +def test_get_galleries_by_project(db: Session) -> None: + """Test retrieving all galleries for a project""" + # Setup + org = crud.create_organization( + session=db, organization_in=OrganizationCreate(name=random_lower_string()) + ) + project = crud.create_project( + session=db, + project_in=ProjectCreate( + name="Test Project", client_name="Client", organization_id=org.id + ), + ) + + # Create multiple galleries + for i in range(3): + crud.create_gallery( + session=db, + gallery_in=GalleryCreate( + name=f"Gallery {i}", photo_count=i * 10, project_id=project.id + ), + ) + + # Retrieve galleries + galleries = crud.get_galleries_by_project(session=db, project_id=project.id) + + assert len(galleries) == 3 + gallery_names = [g.name for g in galleries] + assert "Gallery 0" in gallery_names + assert "Gallery 1" in gallery_names + assert "Gallery 2" in gallery_names + + +def test_delete_gallery(db: Session) -> None: + """Test deleting a gallery""" + # Setup + org = crud.create_organization( + session=db, organization_in=OrganizationCreate(name=random_lower_string()) + ) + project = crud.create_project( + session=db, + project_in=ProjectCreate( + name="Test Project", client_name="Client", organization_id=org.id + ), + ) + gallery = crud.create_gallery( + session=db, + gallery_in=GalleryCreate( + name="Gallery to Delete", photo_count=50, project_id=project.id + ), + ) + gallery_id = gallery.id + + # Verify gallery exists + assert crud.get_gallery(session=db, gallery_id=gallery_id) is not None + + # Delete gallery + crud.delete_gallery(session=db, gallery_id=gallery_id) + + # Verify gallery is deleted + assert crud.get_gallery(session=db, gallery_id=gallery_id) is None diff --git a/backend/tests/crud/test_project.py b/backend/tests/crud/test_project.py new file mode 100644 index 0000000000..c84b9149f8 --- /dev/null +++ b/backend/tests/crud/test_project.py @@ -0,0 +1,202 @@ +"""Unit tests for Project CRUD operations""" + +from datetime import date, timedelta + +from sqlmodel import Session + +from app import crud +from app.models import OrganizationCreate, ProjectCreate, ProjectUpdate +from tests.utils.utils import random_lower_string + + +def test_create_project(db: Session) -> None: + """Test creating a new project""" + # First create an organization + org_in = OrganizationCreate( + name=random_lower_string(), description="Test organization" + ) + organization = crud.create_organization(session=db, organization_in=org_in) + + # Create a project + project_name = f"Test Project {random_lower_string()}" + client_name = f"Client {random_lower_string()}" + deadline = date.today() + timedelta(days=30) + + project_in = ProjectCreate( + name=project_name, + client_name=client_name, + description="Test project description", + status="planning", + deadline=deadline, + budget="$5,000", + progress=0, + organization_id=organization.id, + ) + + project = crud.create_project(session=db, project_in=project_in) + + assert project.name == project_name + assert project.client_name == client_name + assert project.status == "planning" + assert project.deadline == deadline + assert project.budget == "$5,000" + assert project.progress == 0 + assert project.organization_id == organization.id + assert project.id is not None + + +def test_get_project(db: Session) -> None: + """Test retrieving a project by ID""" + # Create organization and project + org_in = OrganizationCreate(name=random_lower_string()) + organization = crud.create_organization(session=db, organization_in=org_in) + + project_in = ProjectCreate( + name="Test Project", + client_name="Test Client", + status="in_progress", + progress=50, + organization_id=organization.id, + ) + created_project = crud.create_project(session=db, project_in=project_in) + + # Retrieve the project + retrieved_project = crud.get_project(session=db, project_id=created_project.id) + + assert retrieved_project is not None + assert retrieved_project.id == created_project.id + assert retrieved_project.name == "Test Project" + assert retrieved_project.progress == 50 + + +def test_update_project(db: Session) -> None: + """Test updating a project""" + # Create organization and project + org_in = OrganizationCreate(name=random_lower_string()) + organization = crud.create_organization(session=db, organization_in=org_in) + + project_in = ProjectCreate( + name="Original Name", + client_name="Test Client", + status="planning", + progress=0, + organization_id=organization.id, + ) + project = crud.create_project(session=db, project_in=project_in) + + # Update the project + project_update = ProjectUpdate( + name="Updated Name", status="in_progress", progress=75 + ) + + updated_project = crud.update_project( + session=db, db_project=project, project_in=project_update + ) + + assert updated_project.name == "Updated Name" + assert updated_project.status == "in_progress" + assert updated_project.progress == 75 + assert updated_project.client_name == "Test Client" # Unchanged field + + +def test_get_projects_by_organization(db: Session) -> None: + """Test retrieving all projects for an organization""" + # Create organization + org_in = OrganizationCreate(name=random_lower_string()) + organization = crud.create_organization(session=db, organization_in=org_in) + + # Create multiple projects + _project1 = crud.create_project( + session=db, + project_in=ProjectCreate( + name="Project 1", client_name="Client 1", organization_id=organization.id + ), + ) + + _project2 = crud.create_project( + session=db, + project_in=ProjectCreate( + name="Project 2", client_name="Client 2", organization_id=organization.id + ), + ) + + # Retrieve projects + projects = crud.get_projects_by_organization( + session=db, organization_id=organization.id + ) + + assert len(projects) == 2 + project_names = [p.name for p in projects] + assert "Project 1" in project_names + assert "Project 2" in project_names + + +def test_count_projects_by_organization(db: Session) -> None: + """Test counting projects for an organization""" + # Create organization + org_in = OrganizationCreate(name=random_lower_string()) + organization = crud.create_organization(session=db, organization_in=org_in) + + # Initially should have 0 projects + count = crud.count_projects_by_organization( + session=db, organization_id=organization.id + ) + assert count == 0 + + # Create 3 projects + for i in range(3): + crud.create_project( + session=db, + project_in=ProjectCreate( + name=f"Project {i}", + client_name=f"Client {i}", + organization_id=organization.id, + ), + ) + + # Should now have 3 projects + count = crud.count_projects_by_organization( + session=db, organization_id=organization.id + ) + assert count == 3 + + +def test_delete_project(db: Session) -> None: + """Test deleting a project""" + # Create organization and project + org_in = OrganizationCreate(name=random_lower_string()) + organization = crud.create_organization(session=db, organization_in=org_in) + + project_in = ProjectCreate( + name="Project to Delete", + client_name="Test Client", + organization_id=organization.id, + ) + project = crud.create_project(session=db, project_in=project_in) + project_id = project.id + + # Verify project exists + assert crud.get_project(session=db, project_id=project_id) is not None + + # Delete project + crud.delete_project(session=db, project_id=project_id) + + # Verify project is deleted + assert crud.get_project(session=db, project_id=project_id) is None + + +def test_project_progress_validation(db: Session) -> None: + """Test that project progress is validated (0-100)""" + org_in = OrganizationCreate(name=random_lower_string()) + organization = crud.create_organization(session=db, organization_in=org_in) + + # Valid progress values + for progress_val in [0, 50, 100]: + project_in = ProjectCreate( + name=f"Project {progress_val}", + client_name="Test Client", + progress=progress_val, + organization_id=organization.id, + ) + project = crud.create_project(session=db, project_in=project_in) + assert project.progress == progress_val diff --git a/backend/tests/crud/test_stats.py b/backend/tests/crud/test_stats.py new file mode 100644 index 0000000000..b149869dbc --- /dev/null +++ b/backend/tests/crud/test_stats.py @@ -0,0 +1,156 @@ +"""Unit tests for Dashboard Statistics""" + +from datetime import date, timedelta + +from sqlmodel import Session + +from app import crud +from app.models import OrganizationCreate, ProjectCreate +from tests.utils.utils import random_lower_string + + +def test_dashboard_stats_empty_organization(db: Session) -> None: + """Test dashboard stats for an organization with no projects""" + org_in = OrganizationCreate(name=random_lower_string()) + organization = crud.create_organization(session=db, organization_in=org_in) + + stats = crud.get_dashboard_stats(session=db, organization_id=organization.id) + + assert stats.active_projects == 0 + assert stats.upcoming_deadlines == 0 + assert stats.team_members == 0 + assert stats.completed_this_month == 0 + + +def test_dashboard_stats_active_projects(db: Session) -> None: + """Test counting active projects (in_progress and review status)""" + org = crud.create_organization( + session=db, organization_in=OrganizationCreate(name=random_lower_string()) + ) + + # Create projects with different statuses + crud.create_project( + session=db, + project_in=ProjectCreate( + name="Project 1", + client_name="Client 1", + status="in_progress", + organization_id=org.id, + ), + ) + crud.create_project( + session=db, + project_in=ProjectCreate( + name="Project 2", + client_name="Client 2", + status="review", + organization_id=org.id, + ), + ) + crud.create_project( + session=db, + project_in=ProjectCreate( + name="Project 3", + client_name="Client 3", + status="planning", # Not active + organization_id=org.id, + ), + ) + crud.create_project( + session=db, + project_in=ProjectCreate( + name="Project 4", + client_name="Client 4", + status="completed", # Not active + organization_id=org.id, + ), + ) + + stats = crud.get_dashboard_stats(session=db, organization_id=org.id) + + # Should count only in_progress and review + assert stats.active_projects == 2 + + +def test_dashboard_stats_upcoming_deadlines(db: Session) -> None: + """Test counting upcoming deadlines (within next 14 days)""" + org = crud.create_organization( + session=db, organization_in=OrganizationCreate(name=random_lower_string()) + ) + + today = date.today() + + # Project with deadline in 5 days - should count + crud.create_project( + session=db, + project_in=ProjectCreate( + name="Project Soon", + client_name="Client", + status="in_progress", + deadline=today + timedelta(days=5), + organization_id=org.id, + ), + ) + + # Project with deadline in 30 days - should NOT count (too far) + crud.create_project( + session=db, + project_in=ProjectCreate( + name="Project Later", + client_name="Client", + status="in_progress", + deadline=today + timedelta(days=30), + organization_id=org.id, + ), + ) + + # Completed project with deadline in 7 days - should NOT count (completed) + crud.create_project( + session=db, + project_in=ProjectCreate( + name="Project Done", + client_name="Client", + status="completed", + deadline=today + timedelta(days=7), + organization_id=org.id, + ), + ) + + stats = crud.get_dashboard_stats(session=db, organization_id=org.id) + + # Should only count the first project + assert stats.upcoming_deadlines == 1 + + +def test_dashboard_stats_completed_this_month(db: Session) -> None: + """Test counting projects completed this month""" + org = crud.create_organization( + session=db, organization_in=OrganizationCreate(name=random_lower_string()) + ) + + # Create completed projects + _project1 = crud.create_project( + session=db, + project_in=ProjectCreate( + name="Completed Project", + client_name="Client", + status="completed", + organization_id=org.id, + ), + ) + + # Also create a non-completed project + crud.create_project( + session=db, + project_in=ProjectCreate( + name="In Progress Project", + client_name="Client", + status="in_progress", + organization_id=org.id, + ), + ) + + stats = crud.get_dashboard_stats(session=db, organization_id=org.id) + + # Should count only completed projects + assert stats.completed_this_month >= 1 # At least the one we created diff --git a/deploy-ip.sh b/deploy-ip.sh new file mode 100644 index 0000000000..a30ac40696 --- /dev/null +++ b/deploy-ip.sh @@ -0,0 +1,168 @@ +#!/bin/bash + +# Deployment script for FastAPI project on EC2 with IP address +# Usage: ./deploy-ip.sh YOUR_EC2_IP + +if [ -z "$1" ]; then + echo "Usage: ./deploy-ip.sh YOUR_EC2_IP" + echo "Example: ./deploy-ip.sh 54.123.45.67" + exit 1 +fi + +EC2_IP=$1 + +echo "Deploying to EC2 IP: $EC2_IP" + +# Create environment file +cat > .env << EOF +# Production Environment Variables for IP-based deployment +ENVIRONMENT=production +DOMAIN=$EC2_IP +PROJECT_NAME=Mosaic Project +STACK_NAME=mosaic-project-production +BACKEND_CORS_ORIGINS=http://$EC2_IP:5173,http://$EC2_IP:80,http://$EC2_IP +FRONTEND_HOST=http://$EC2_IP:5173 +SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))") +FIRST_SUPERUSER=admin@example.com +FIRST_SUPERUSER_PASSWORD=$(python3 -c "import secrets; print(secrets.token_urlsafe(16))") +POSTGRES_SERVER=db +POSTGRES_PORT=5432 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=$(python3 -c "import secrets; print(secrets.token_urlsafe(16))") +POSTGRES_DB=app +SMTP_HOST= +SMTP_USER= +SMTP_PASSWORD= +EMAILS_FROM_EMAIL= +DOCKER_IMAGE_BACKEND=mosaic-backend +DOCKER_IMAGE_FRONTEND=mosaic-frontend +TAG=latest +EOF + +echo "Environment file created with secure random keys" + +# Create simplified docker-compose for IP deployment +cat > docker-compose.production.yml << EOF +services: + db: + image: postgres:17 + restart: always + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER} -d \${POSTGRES_DB}"] + interval: 10s + retries: 5 + start_period: 30s + timeout: 10s + volumes: + - app-db-data:/var/lib/postgresql/data/pgdata + environment: + - PGDATA=/var/lib/postgresql/data/pgdata + - POSTGRES_PASSWORD=\${POSTGRES_PASSWORD} + - POSTGRES_USER=\${POSTGRES_USER} + - POSTGRES_DB=\${POSTGRES_DB} + ports: + - "5432:5432" + + adminer: + image: adminer + restart: always + depends_on: + - db + environment: + - ADMINER_DESIGN=pepa-linha-dark + ports: + - "8080:8080" + + prestart: + image: \${DOCKER_IMAGE_BACKEND}:\${TAG} + build: + context: ./backend + depends_on: + db: + condition: service_healthy + restart: true + command: bash scripts/prestart.sh + environment: + - PROJECT_NAME=\${PROJECT_NAME} + - DOMAIN=\${DOMAIN} + - FRONTEND_HOST=\${FRONTEND_HOST} + - ENVIRONMENT=\${ENVIRONMENT} + - BACKEND_CORS_ORIGINS=\${BACKEND_CORS_ORIGINS} + - SECRET_KEY=\${SECRET_KEY} + - FIRST_SUPERUSER=\${FIRST_SUPERUSER} + - FIRST_SUPERUSER_PASSWORD=\${FIRST_SUPERUSER_PASSWORD} + - SMTP_HOST=\${SMTP_HOST} + - SMTP_USER=\${SMTP_USER} + - SMTP_PASSWORD=\${SMTP_PASSWORD} + - EMAILS_FROM_EMAIL=\${EMAILS_FROM_EMAIL} + - POSTGRES_SERVER=db + - POSTGRES_PORT=\${POSTGRES_PORT} + - POSTGRES_DB=\${POSTGRES_DB} + - POSTGRES_USER=\${POSTGRES_USER} + - POSTGRES_PASSWORD=\${POSTGRES_PASSWORD} + + backend: + image: \${DOCKER_IMAGE_BACKEND}:\${TAG} + restart: always + depends_on: + db: + condition: service_healthy + restart: true + prestart: + condition: service_completed_successfully + build: + context: ./backend + environment: + - PROJECT_NAME=\${PROJECT_NAME} + - DOMAIN=\${DOMAIN} + - FRONTEND_HOST=\${FRONTEND_HOST} + - ENVIRONMENT=\${ENVIRONMENT} + - BACKEND_CORS_ORIGINS=\${BACKEND_CORS_ORIGINS} + - SECRET_KEY=\${SECRET_KEY} + - FIRST_SUPERUSER=\${FIRST_SUPERUSER} + - FIRST_SUPERUSER_PASSWORD=\${FIRST_SUPERUSER_PASSWORD} + - SMTP_HOST=\${SMTP_HOST} + - SMTP_USER=\${SMTP_USER} + - SMTP_PASSWORD=\${SMTP_PASSWORD} + - EMAILS_FROM_EMAIL=\${EMAILS_FROM_EMAIL} + - POSTGRES_SERVER=db + - POSTGRES_PORT=\${POSTGRES_PORT} + - POSTGRES_DB=\${POSTGRES_DB} + - POSTGRES_USER=\${POSTGRES_USER} + - POSTGRES_PASSWORD=\${POSTGRES_PASSWORD} + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"] + interval: 10s + timeout: 5s + retries: 5 + ports: + - "8000:8000" + + frontend: + image: \${DOCKER_IMAGE_FRONTEND}:\${TAG} + restart: always + build: + context: ./frontend + args: + - VITE_API_URL=http://$EC2_IP:8000 + - NODE_ENV=production + ports: + - "80:80" + +volumes: + app-db-data: +EOF + +echo "Production docker-compose file created" + +echo "Deployment files created successfully!" +echo "" +echo "Next steps:" +echo "1. Copy your project files to the EC2 instance" +echo "2. Run: docker compose -f docker-compose.production.yml up -d" +echo "" +echo "Your application will be available at:" +echo "- Frontend: http://$EC2_IP" +echo "- Backend API: http://$EC2_IP:8000" +echo "- API Docs: http://$EC2_IP:8000/docs" +echo "- Adminer: http://$EC2_IP:8080" diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 0751abe901..8d9fbd7468 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -121,6 +121,9 @@ services: # For the reports when run locally - PLAYWRIGHT_HTML_HOST=0.0.0.0 - CI=${CI} + # Test credentials + - FIRST_SUPERUSER=${FIRST_SUPERUSER} + - FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD} volumes: - ./frontend/blob-report:/app/blob-report - ./frontend/test-results:/app/test-results diff --git a/docker-compose.yml b/docker-compose.yml index b1aa17ed43..4ba11e968d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,7 @@ services: - POSTGRES_USER=${POSTGRES_USER?Variable not set} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} - SENTRY_DSN=${SENTRY_DSN} + - PROJECT_NAME=${PROJECT_NAME?Variable not set} backend: image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}' @@ -107,6 +108,7 @@ services: - POSTGRES_USER=${POSTGRES_USER?Variable not set} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set} - SENTRY_DSN=${SENTRY_DSN} + - PROJECT_NAME=${PROJECT_NAME?Variable not set} healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"] diff --git a/frontend/.env b/frontend/.env deleted file mode 100644 index 27fcbfe8c8..0000000000 --- a/frontend/.env +++ /dev/null @@ -1,2 +0,0 @@ -VITE_API_URL=http://localhost:8000 -MAILCATCHER_HOST=http://localhost:1080 diff --git a/frontend/index.html b/frontend/index.html index 57621a268b..2f52280354 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,8 +4,8 @@ - Full Stack FastAPI Project - + Mosaic +
diff --git a/frontend/public/assets/images/mosaicm.png b/frontend/public/assets/images/mosaicm.png new file mode 100644 index 0000000000..449257e70f Binary files /dev/null and b/frontend/public/assets/images/mosaicm.png differ diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index a5c029db0a..c9b441e7be 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -55,6 +55,273 @@ export const Body_login_login_access_tokenSchema = { title: 'Body_login-login_access_token' } as const; +export const DashboardStatsSchema = { + properties: { + active_projects: { + type: 'integer', + title: 'Active Projects' + }, + upcoming_deadlines: { + type: 'integer', + title: 'Upcoming Deadlines' + }, + team_members: { + type: 'integer', + title: 'Team Members' + }, + completed_this_month: { + type: 'integer', + title: 'Completed This Month' + } + }, + type: 'object', + required: ['active_projects', 'upcoming_deadlines', 'team_members', 'completed_this_month'], + title: 'DashboardStats' +} as const; + +export const GalleriesPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/GalleryPublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'GalleriesPublic' +} as const; + +export const GalleryCreateSchema = { + properties: { + name: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Name' + }, + date: { + anyOf: [ + { + type: 'string', + format: 'date' + }, + { + type: 'null' + } + ], + title: 'Date' + }, + photo_count: { + type: 'integer', + minimum: 0, + title: 'Photo Count', + default: 0 + }, + photographer: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Photographer' + }, + status: { + type: 'string', + maxLength: 50, + title: 'Status', + default: 'draft' + }, + cover_image_url: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Cover Image Url' + }, + project_id: { + type: 'string', + format: 'uuid', + title: 'Project Id' + } + }, + type: 'object', + required: ['name', 'project_id'], + title: 'GalleryCreate' +} as const; + +export const GalleryPublicSchema = { + properties: { + name: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Name' + }, + date: { + anyOf: [ + { + type: 'string', + format: 'date' + }, + { + type: 'null' + } + ], + title: 'Date' + }, + photo_count: { + type: 'integer', + minimum: 0, + title: 'Photo Count', + default: 0 + }, + photographer: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Photographer' + }, + status: { + type: 'string', + maxLength: 50, + title: 'Status', + default: 'draft' + }, + cover_image_url: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Cover Image Url' + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + project_id: { + type: 'string', + format: 'uuid', + title: 'Project Id' + } + }, + type: 'object', + required: ['name', 'id', 'created_at', 'project_id'], + title: 'GalleryPublic' +} as const; + +export const GalleryUpdateSchema = { + properties: { + name: { + anyOf: [ + { + type: 'string', + maxLength: 255, + minLength: 1 + }, + { + type: 'null' + } + ], + title: 'Name' + }, + date: { + anyOf: [ + { + type: 'string', + format: 'date' + }, + { + type: 'null' + } + ], + title: 'Date' + }, + photo_count: { + anyOf: [ + { + type: 'integer', + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Photo Count' + }, + photographer: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Photographer' + }, + status: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Status' + }, + cover_image_url: { + anyOf: [ + { + type: 'string', + maxLength: 500 + }, + { + type: 'null' + } + ], + title: 'Cover Image Url' + } + }, + type: 'object', + title: 'GalleryUpdate' +} as const; + export const HTTPValidationErrorSchema = { properties: { detail: { @@ -237,6 +504,458 @@ export const PrivateUserCreateSchema = { title: 'PrivateUserCreate' } as const; +export const ProjectAccessPublicSchema = { + properties: { + role: { + type: 'string', + maxLength: 50, + title: 'Role', + default: 'viewer' + }, + can_comment: { + type: 'boolean', + title: 'Can Comment', + default: true + }, + can_download: { + type: 'boolean', + title: 'Can Download', + default: true + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + project_id: { + type: 'string', + format: 'uuid', + title: 'Project Id' + }, + user_id: { + type: 'string', + format: 'uuid', + title: 'User Id' + } + }, + type: 'object', + required: ['id', 'created_at', 'project_id', 'user_id'], + title: 'ProjectAccessPublic' +} as const; + +export const ProjectAccessUpdateSchema = { + properties: { + role: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Role' + }, + can_comment: { + anyOf: [ + { + type: 'boolean' + }, + { + type: 'null' + } + ], + title: 'Can Comment' + }, + can_download: { + anyOf: [ + { + type: 'boolean' + }, + { + type: 'null' + } + ], + title: 'Can Download' + } + }, + type: 'object', + title: 'ProjectAccessUpdate' +} as const; + +export const ProjectAccessesPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/ProjectAccessPublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'ProjectAccessesPublic' +} as const; + +export const ProjectCreateSchema = { + properties: { + name: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Name' + }, + client_name: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Client Name' + }, + client_email: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Client Email' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 2000 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + status: { + type: 'string', + maxLength: 50, + title: 'Status', + default: 'planning' + }, + deadline: { + anyOf: [ + { + type: 'string', + format: 'date' + }, + { + type: 'null' + } + ], + title: 'Deadline' + }, + start_date: { + anyOf: [ + { + type: 'string', + format: 'date' + }, + { + type: 'null' + } + ], + title: 'Start Date' + }, + budget: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'Budget' + }, + progress: { + type: 'integer', + maximum: 100, + minimum: 0, + title: 'Progress', + default: 0 + }, + organization_id: { + type: 'string', + format: 'uuid', + title: 'Organization Id' + } + }, + type: 'object', + required: ['name', 'client_name', 'organization_id'], + title: 'ProjectCreate' +} as const; + +export const ProjectPublicSchema = { + properties: { + name: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Name' + }, + client_name: { + type: 'string', + maxLength: 255, + minLength: 1, + title: 'Client Name' + }, + client_email: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Client Email' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 2000 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + status: { + type: 'string', + maxLength: 50, + title: 'Status', + default: 'planning' + }, + deadline: { + anyOf: [ + { + type: 'string', + format: 'date' + }, + { + type: 'null' + } + ], + title: 'Deadline' + }, + start_date: { + anyOf: [ + { + type: 'string', + format: 'date' + }, + { + type: 'null' + } + ], + title: 'Start Date' + }, + budget: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'Budget' + }, + progress: { + type: 'integer', + maximum: 100, + minimum: 0, + title: 'Progress', + default: 0 + }, + id: { + type: 'string', + format: 'uuid', + title: 'Id' + }, + created_at: { + type: 'string', + format: 'date-time', + title: 'Created At' + }, + updated_at: { + type: 'string', + format: 'date-time', + title: 'Updated At' + }, + organization_id: { + type: 'string', + format: 'uuid', + title: 'Organization Id' + } + }, + type: 'object', + required: ['name', 'client_name', 'id', 'created_at', 'updated_at', 'organization_id'], + title: 'ProjectPublic' +} as const; + +export const ProjectUpdateSchema = { + properties: { + name: { + anyOf: [ + { + type: 'string', + maxLength: 255, + minLength: 1 + }, + { + type: 'null' + } + ], + title: 'Name' + }, + client_name: { + anyOf: [ + { + type: 'string', + maxLength: 255, + minLength: 1 + }, + { + type: 'null' + } + ], + title: 'Client Name' + }, + client_email: { + anyOf: [ + { + type: 'string', + maxLength: 255 + }, + { + type: 'null' + } + ], + title: 'Client Email' + }, + description: { + anyOf: [ + { + type: 'string', + maxLength: 2000 + }, + { + type: 'null' + } + ], + title: 'Description' + }, + status: { + anyOf: [ + { + type: 'string', + maxLength: 50 + }, + { + type: 'null' + } + ], + title: 'Status' + }, + deadline: { + anyOf: [ + { + type: 'string', + format: 'date' + }, + { + type: 'null' + } + ], + title: 'Deadline' + }, + start_date: { + anyOf: [ + { + type: 'string', + format: 'date' + }, + { + type: 'null' + } + ], + title: 'Start Date' + }, + budget: { + anyOf: [ + { + type: 'string', + maxLength: 100 + }, + { + type: 'null' + } + ], + title: 'Budget' + }, + progress: { + anyOf: [ + { + type: 'integer', + maximum: 100, + minimum: 0 + }, + { + type: 'null' + } + ], + title: 'Progress' + } + }, + type: 'object', + title: 'ProjectUpdate' +} as const; + +export const ProjectsPublicSchema = { + properties: { + data: { + items: { + '$ref': '#/components/schemas/ProjectPublic' + }, + type: 'array', + title: 'Data' + }, + count: { + type: 'integer', + title: 'Count' + } + }, + type: 'object', + required: ['data', 'count'], + title: 'ProjectsPublic' +} as const; + export const TokenSchema = { properties: { access_token: { diff --git a/frontend/src/client/sdk.gen.ts b/frontend/src/client/sdk.gen.ts index ba79e3f726..a00b5a390a 100644 --- a/frontend/src/client/sdk.gen.ts +++ b/frontend/src/client/sdk.gen.ts @@ -3,7 +3,121 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen'; +import type { GalleriesReadGalleriesData, GalleriesReadGalleriesResponse, GalleriesCreateGalleryData, GalleriesCreateGalleryResponse, GalleriesReadGalleryData, GalleriesReadGalleryResponse, GalleriesUpdateGalleryData, GalleriesUpdateGalleryResponse, GalleriesDeleteGalleryData, GalleriesDeleteGalleryResponse, ItemsReadItemsData, ItemsReadItemsResponse, ItemsCreateItemData, ItemsCreateItemResponse, ItemsReadItemData, ItemsReadItemResponse, ItemsUpdateItemData, ItemsUpdateItemResponse, ItemsDeleteItemData, ItemsDeleteItemResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, ProjectAccessGrantProjectAccessData, ProjectAccessGrantProjectAccessResponse, ProjectAccessReadProjectAccessListData, ProjectAccessReadProjectAccessListResponse, ProjectAccessRevokeProjectAccessData, ProjectAccessRevokeProjectAccessResponse, ProjectAccessUpdateProjectAccessPermissionsData, ProjectAccessUpdateProjectAccessPermissionsResponse, ProjectsReadProjectsData, ProjectsReadProjectsResponse, ProjectsCreateProjectData, ProjectsCreateProjectResponse, ProjectsReadDashboardStatsResponse, ProjectsReadProjectData, ProjectsReadProjectResponse, ProjectsUpdateProjectData, ProjectsUpdateProjectResponse, ProjectsDeleteProjectData, ProjectsDeleteProjectResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsGetSystemInfoResponse } from './types.gen'; + +export class GalleriesService { + /** + * Read Galleries + * Retrieve galleries. If project_id is provided, get galleries for that project. + * Otherwise, get all galleries for the user's organization. + * @param data The data for the request. + * @param data.projectId + * @param data.skip + * @param data.limit + * @returns GalleriesPublic Successful Response + * @throws ApiError + */ + public static readGalleries(data: GalleriesReadGalleriesData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/galleries/', + query: { + project_id: data.projectId, + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Create Gallery + * Create new gallery. + * @param data The data for the request. + * @param data.requestBody + * @returns GalleryPublic Successful Response + * @throws ApiError + */ + public static createGallery(data: GalleriesCreateGalleryData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/galleries/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Gallery + * Get gallery by ID. + * @param data The data for the request. + * @param data.id + * @returns GalleryPublic Successful Response + * @throws ApiError + */ + public static readGallery(data: GalleriesReadGalleryData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/galleries/{id}', + path: { + id: data.id + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Gallery + * Update a gallery. + * @param data The data for the request. + * @param data.id + * @param data.requestBody + * @returns GalleryPublic Successful Response + * @throws ApiError + */ + public static updateGallery(data: GalleriesUpdateGalleryData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/galleries/{id}', + path: { + id: data.id + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Gallery + * Delete a gallery. + * @param data The data for the request. + * @param data.id + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteGallery(data: GalleriesDeleteGalleryData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/galleries/{id}', + path: { + id: data.id + }, + errors: { + 422: 'Validation Error' + } + }); + } +} export class ItemsService { /** @@ -235,6 +349,243 @@ export class PrivateService { } } +export class ProjectAccessService { + /** + * Grant Project Access + * Grant a user access to a project (invite a client). + * Only team members can invite clients. + * @param data The data for the request. + * @param data.projectId + * @param data.userId + * @param data.role + * @param data.canComment + * @param data.canDownload + * @returns ProjectAccessPublic Successful Response + * @throws ApiError + */ + public static grantProjectAccess(data: ProjectAccessGrantProjectAccessData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/projects/{project_id}/access', + path: { + project_id: data.projectId + }, + query: { + user_id: data.userId, + role: data.role, + can_comment: data.canComment, + can_download: data.canDownload + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Project Access List + * Get list of users with access to a project. + * Only team members from the project's organization can see this. + * @param data The data for the request. + * @param data.projectId + * @returns ProjectAccessesPublic Successful Response + * @throws ApiError + */ + public static readProjectAccessList(data: ProjectAccessReadProjectAccessListData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/projects/{project_id}/access', + path: { + project_id: data.projectId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Revoke Project Access + * Revoke a user's access to a project. + * Only team members from the project's organization can do this. + * @param data The data for the request. + * @param data.projectId + * @param data.userId + * @returns Message Successful Response + * @throws ApiError + */ + public static revokeProjectAccess(data: ProjectAccessRevokeProjectAccessData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/projects/{project_id}/access/{user_id}', + path: { + project_id: data.projectId, + user_id: data.userId + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Project Access Permissions + * Update a user's project access permissions. + * Only team members from the project's organization can do this. + * @param data The data for the request. + * @param data.projectId + * @param data.userId + * @param data.requestBody + * @returns ProjectAccessPublic Successful Response + * @throws ApiError + */ + public static updateProjectAccessPermissions(data: ProjectAccessUpdateProjectAccessPermissionsData): CancelablePromise { + return __request(OpenAPI, { + method: 'PATCH', + url: '/api/v1/projects/{project_id}/access/{user_id}', + path: { + project_id: data.projectId, + user_id: data.userId + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } +} + +export class ProjectsService { + /** + * Read Projects + * Retrieve projects. + * - Team members see projects from their organization + * - Clients see projects they have been invited to + * @param data The data for the request. + * @param data.skip + * @param data.limit + * @returns ProjectsPublic Successful Response + * @throws ApiError + */ + public static readProjects(data: ProjectsReadProjectsData = {}): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/projects/', + query: { + skip: data.skip, + limit: data.limit + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Create Project + * Create new project. + * Only team members can create projects. + * @param data The data for the request. + * @param data.requestBody + * @returns ProjectPublic Successful Response + * @throws ApiError + */ + public static createProject(data: ProjectsCreateProjectData): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/api/v1/projects/', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Read Dashboard Stats + * Get dashboard statistics for the current user's organization. + * Only available to team members. + * @returns DashboardStats Successful Response + * @throws ApiError + */ + public static readDashboardStats(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/projects/stats' + }); + } + + /** + * Read Project + * Get project by ID. + * @param data The data for the request. + * @param data.id + * @returns ProjectPublic Successful Response + * @throws ApiError + */ + public static readProject(data: ProjectsReadProjectData): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/projects/{id}', + path: { + id: data.id + }, + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Update Project + * Update a project. + * Only team members from the project's organization can update projects. + * @param data The data for the request. + * @param data.id + * @param data.requestBody + * @returns ProjectPublic Successful Response + * @throws ApiError + */ + public static updateProject(data: ProjectsUpdateProjectData): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/v1/projects/{id}', + path: { + id: data.id + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 422: 'Validation Error' + } + }); + } + + /** + * Delete Project + * Delete a project. + * Only team members from the project's organization can delete projects. + * @param data The data for the request. + * @param data.id + * @returns Message Successful Response + * @throws ApiError + */ + public static deleteProject(data: ProjectsDeleteProjectData): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/v1/projects/{id}', + path: { + id: data.id + }, + errors: { + 422: 'Validation Error' + } + }); + } +} + export class UsersService { /** * Read Users @@ -465,4 +816,17 @@ export class UtilsService { url: '/api/v1/utils/health-check/' }); } + + /** + * Get System Info + * Get interesting system information including current time, platform details, and Python version. + * @returns unknown Successful Response + * @throws ApiError + */ + public static getSystemInfo(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/utils/system-info/' + }); + } } \ No newline at end of file diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index e5cf34c34c..b7babab6a6 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -9,6 +9,49 @@ export type Body_login_login_access_token = { client_secret?: (string | null); }; +export type DashboardStats = { + active_projects: number; + upcoming_deadlines: number; + team_members: number; + completed_this_month: number; +}; + +export type GalleriesPublic = { + data: Array; + count: number; +}; + +export type GalleryCreate = { + name: string; + date?: (string | null); + photo_count?: number; + photographer?: (string | null); + status?: string; + cover_image_url?: (string | null); + project_id: string; +}; + +export type GalleryPublic = { + name: string; + date?: (string | null); + photo_count?: number; + photographer?: (string | null); + status?: string; + cover_image_url?: (string | null); + id: string; + created_at: string; + project_id: string; +}; + +export type GalleryUpdate = { + name?: (string | null); + date?: (string | null); + photo_count?: (number | null); + photographer?: (string | null); + status?: (string | null); + cover_image_url?: (string | null); +}; + export type HTTPValidationError = { detail?: Array; }; @@ -51,6 +94,73 @@ export type PrivateUserCreate = { is_verified?: boolean; }; +export type ProjectAccessesPublic = { + data: Array; + count: number; +}; + +export type ProjectAccessPublic = { + role?: string; + can_comment?: boolean; + can_download?: boolean; + id: string; + created_at: string; + project_id: string; + user_id: string; +}; + +export type ProjectAccessUpdate = { + role?: (string | null); + can_comment?: (boolean | null); + can_download?: (boolean | null); +}; + +export type ProjectCreate = { + name: string; + client_name: string; + client_email?: (string | null); + description?: (string | null); + status?: string; + deadline?: (string | null); + start_date?: (string | null); + budget?: (string | null); + progress?: number; + organization_id: string; +}; + +export type ProjectPublic = { + name: string; + client_name: string; + client_email?: (string | null); + description?: (string | null); + status?: string; + deadline?: (string | null); + start_date?: (string | null); + budget?: (string | null); + progress?: number; + id: string; + created_at: string; + updated_at: string; + organization_id: string; +}; + +export type ProjectsPublic = { + data: Array; + count: number; +}; + +export type ProjectUpdate = { + name?: (string | null); + client_name?: (string | null); + client_email?: (string | null); + description?: (string | null); + status?: (string | null); + deadline?: (string | null); + start_date?: (string | null); + budget?: (string | null); + progress?: (number | null); +}; + export type Token = { access_token: string; token_type?: string; @@ -74,6 +184,8 @@ export type UserPublic = { is_active?: boolean; is_superuser?: boolean; full_name?: (string | null); + user_type?: string; + organization_id?: (string | null); id: string; }; @@ -81,6 +193,7 @@ export type UserRegister = { email: string; password: string; full_name?: (string | null); + user_type?: string; }; export type UsersPublic = { @@ -107,6 +220,39 @@ export type ValidationError = { type: string; }; +export type GalleriesReadGalleriesData = { + limit?: number; + projectId?: (string | null); + skip?: number; +}; + +export type GalleriesReadGalleriesResponse = (GalleriesPublic); + +export type GalleriesCreateGalleryData = { + requestBody: GalleryCreate; +}; + +export type GalleriesCreateGalleryResponse = (GalleryPublic); + +export type GalleriesReadGalleryData = { + id: string; +}; + +export type GalleriesReadGalleryResponse = (GalleryPublic); + +export type GalleriesUpdateGalleryData = { + id: string; + requestBody: GalleryUpdate; +}; + +export type GalleriesUpdateGalleryResponse = (GalleryPublic); + +export type GalleriesDeleteGalleryData = { + id: string; +}; + +export type GalleriesDeleteGalleryResponse = (Message); + export type ItemsReadItemsData = { limit?: number; skip?: number; @@ -171,6 +317,71 @@ export type PrivateCreateUserData = { export type PrivateCreateUserResponse = (UserPublic); +export type ProjectAccessGrantProjectAccessData = { + canComment?: boolean; + canDownload?: boolean; + projectId: string; + role?: string; + userId: string; +}; + +export type ProjectAccessGrantProjectAccessResponse = (ProjectAccessPublic); + +export type ProjectAccessReadProjectAccessListData = { + projectId: string; +}; + +export type ProjectAccessReadProjectAccessListResponse = (ProjectAccessesPublic); + +export type ProjectAccessRevokeProjectAccessData = { + projectId: string; + userId: string; +}; + +export type ProjectAccessRevokeProjectAccessResponse = (Message); + +export type ProjectAccessUpdateProjectAccessPermissionsData = { + projectId: string; + requestBody: ProjectAccessUpdate; + userId: string; +}; + +export type ProjectAccessUpdateProjectAccessPermissionsResponse = (ProjectAccessPublic); + +export type ProjectsReadProjectsData = { + limit?: number; + skip?: number; +}; + +export type ProjectsReadProjectsResponse = (ProjectsPublic); + +export type ProjectsCreateProjectData = { + requestBody: ProjectCreate; +}; + +export type ProjectsCreateProjectResponse = (ProjectPublic); + +export type ProjectsReadDashboardStatsResponse = (DashboardStats); + +export type ProjectsReadProjectData = { + id: string; +}; + +export type ProjectsReadProjectResponse = (ProjectPublic); + +export type ProjectsUpdateProjectData = { + id: string; + requestBody: ProjectUpdate; +}; + +export type ProjectsUpdateProjectResponse = (ProjectPublic); + +export type ProjectsDeleteProjectData = { + id: string; +}; + +export type ProjectsDeleteProjectResponse = (Message); + export type UsersReadUsersData = { limit?: number; skip?: number; @@ -231,4 +442,8 @@ export type UtilsTestEmailData = { export type UtilsTestEmailResponse = (Message); -export type UtilsHealthCheckResponse = (boolean); \ No newline at end of file +export type UtilsHealthCheckResponse = (boolean); + +export type UtilsGetSystemInfoResponse = ({ + [key: string]: unknown; +}); \ No newline at end of file diff --git a/frontend/src/components/Common/Navbar.tsx b/frontend/src/components/Common/Navbar.tsx index 7e952e005e..b62a3c0d20 100644 --- a/frontend/src/components/Common/Navbar.tsx +++ b/frontend/src/components/Common/Navbar.tsx @@ -1,7 +1,6 @@ -import { Flex, Image, useBreakpointValue } from "@chakra-ui/react" +import { Flex, Heading, useBreakpointValue } from "@chakra-ui/react" import { Link } from "@tanstack/react-router" -import Logo from "/assets/images/fastapi-logo.svg" import UserMenu from "./UserMenu" function Navbar() { @@ -12,7 +11,6 @@ function Navbar() { display={display} justify="space-between" position="sticky" - color="white" align="center" bg="bg.muted" w="100%" @@ -20,7 +18,9 @@ function Navbar() { p={4} > - Logo + + Mosaic + diff --git a/frontend/src/components/Common/SidebarItems.tsx b/frontend/src/components/Common/SidebarItems.tsx index 13f71495f5..eb6654f110 100644 --- a/frontend/src/components/Common/SidebarItems.tsx +++ b/frontend/src/components/Common/SidebarItems.tsx @@ -1,14 +1,16 @@ import { Box, Flex, Icon, Text } from "@chakra-ui/react" import { useQueryClient } from "@tanstack/react-query" import { Link as RouterLink } from "@tanstack/react-router" -import { FiBriefcase, FiHome, FiSettings, FiUsers } from "react-icons/fi" +import { FiBriefcase, FiFolder, FiHome, FiImage, FiSettings, FiUsers } from "react-icons/fi" import type { IconType } from "react-icons/lib" import type { UserPublic } from "@/client" const items = [ - { icon: FiHome, title: "Dashboard", path: "/" }, - { icon: FiBriefcase, title: "Items", path: "/items" }, + { icon: FiHome, title: "Dashboard", path: "/dashboard" }, + { icon: FiFolder, title: "Projects", path: "/projects", requiresOrg: true }, + { icon: FiImage, title: "Galleries", path: "/galleries", requiresOrg: true }, + { icon: FiBriefcase, title: "Organization", path: "/organization", requiresOrg: true, teamOnly: true }, { icon: FiSettings, title: "User Settings", path: "/settings" }, ] @@ -20,15 +22,30 @@ interface Item { icon: IconType title: string path: string + requiresOrg?: boolean + teamOnly?: boolean } const SidebarItems = ({ onClose }: SidebarItemsProps) => { const queryClient = useQueryClient() const currentUser = queryClient.getQueryData(["currentUser"]) - const finalItems: Item[] = currentUser?.is_superuser - ? [...items, { icon: FiUsers, title: "Admin", path: "/admin" }] - : items + // Check if user has organization (clients always have access, team members need org) + const hasOrganization = currentUser?.user_type === "client" || currentUser?.organization_id + + // Filter items based on user status + let finalItems: Item[] = items.filter(item => { + // Hide items that require org if user doesn't have one + if (item.requiresOrg && !hasOrganization) return false + // Hide team-only items from clients + if (item.teamOnly && currentUser?.user_type !== "team_member") return false + return true + }) + + // Add admin page for superusers + if (currentUser?.is_superuser) { + finalItems = [...finalItems, { icon: FiUsers, title: "Admin", path: "/admin" }] + } const listItems = finalItems.map(({ icon, title, path }) => ( diff --git a/frontend/src/components/Projects/ClientAccessList.tsx b/frontend/src/components/Projects/ClientAccessList.tsx new file mode 100644 index 0000000000..c3b6525343 --- /dev/null +++ b/frontend/src/components/Projects/ClientAccessList.tsx @@ -0,0 +1,134 @@ +import { Box, Card, Flex, Heading, Stack, Text } from "@chakra-ui/react" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { FiTrash2, FiUser } from "react-icons/fi" +import { Button } from "@/components/ui/button" +import useCustomToast from "@/hooks/useCustomToast" + +interface ClientAccessListProps { + projectId: string + isTeamMember: boolean +} + +export function ClientAccessList({ projectId, isTeamMember }: ClientAccessListProps) { + const { showSuccessToast, showErrorToast } = useCustomToast() + const queryClient = useQueryClient() + + // Fetch project access list + const { data: accessData, isLoading } = useQuery({ + queryKey: ["projectAccess", projectId], + queryFn: async () => { + const baseUrl = (import.meta.env.VITE_API_URL || "http://localhost:8000").replace(/\/$/, '') + const response = await fetch( + `${baseUrl}/api/v1/projects/${projectId}/access`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem("access_token")}`, + }, + } + ) + if (!response.ok) { + throw new Error("Failed to fetch access list") + } + return response.json() + }, + }) + + const revokeMutation = useMutation({ + mutationFn: async (userId: string) => { + const baseUrl = (import.meta.env.VITE_API_URL || "http://localhost:8000").replace(/\/$/, '') + const response = await fetch( + `${baseUrl}/api/v1/projects/${projectId}/access/${userId}`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${localStorage.getItem("access_token")}`, + }, + } + ) + if (!response.ok) { + const error = await response.json() + throw new Error(error.detail || "Failed to revoke access") + } + return response.json() + }, + onSuccess: () => { + showSuccessToast("Access revoked successfully") + queryClient.invalidateQueries({ queryKey: ["projectAccess", projectId] }) + }, + onError: (error: Error) => { + showErrorToast(error.message) + }, + }) + + if (isLoading) { + return Loading... + } + + const accessList = accessData?.data || [] + + if (accessList.length === 0) { + return ( + + + Invited Clients + + + No clients invited yet + + + ) + } + + return ( + + + Invited Clients + + + + {accessList.map((access: any) => ( + + + + + + + + User ID: {access.user_id.substring(0, 8)}... + + + Role: {access.role} + + + + {isTeamMember && ( + + )} + + ))} + + + + ) +} + diff --git a/frontend/src/components/Projects/CreateProject.tsx b/frontend/src/components/Projects/CreateProject.tsx new file mode 100644 index 0000000000..29994f50df --- /dev/null +++ b/frontend/src/components/Projects/CreateProject.tsx @@ -0,0 +1,199 @@ +import { useState } from "react" +import { useForm } from "react-hook-form" +import { + DialogRoot, + DialogContent, + DialogHeader, + DialogTitle, + DialogBody, + DialogFooter, + DialogCloseTrigger, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Field } from "@/components/ui/field" +import { Input, Textarea, NativeSelectRoot, NativeSelectField } from "@chakra-ui/react" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { ProjectsService, type ProjectCreate } from "@/client" +import { FiPlus } from "react-icons/fi" +import useCustomToast from "@/hooks/useCustomToast" +import useAuth from "@/hooks/useAuth" + +export function CreateProject() { + const [open, setOpen] = useState(false) + const { showSuccessToast, showErrorToast } = useCustomToast() + const queryClient = useQueryClient() + const { user: currentUser } = useAuth() + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + defaultValues: { + name: "", + description: "", + client_name: "", + client_email: "", + status: "planning", + budget: "", + start_date: "", + deadline: "", + progress: 0, + organization_id: currentUser?.organization_id || "", + }, + }) + + const createMutation = useMutation({ + mutationFn: async (data: ProjectCreate) => { + if (!currentUser?.organization_id) { + throw new Error("No organization assigned. Please contact support.") + } + + return await ProjectsService.createProject({ + requestBody: { + ...data, + organization_id: currentUser.organization_id, + }, + }) + }, + onSuccess: () => { + showSuccessToast("Project created successfully") + queryClient.invalidateQueries({ queryKey: ["recentProjects"] }) + queryClient.invalidateQueries({ queryKey: ["dashboardStats"] }) + setOpen(false) + reset() + }, + onError: (error: any) => { + let message = "Failed to create project" + + if (error?.body?.detail) { + if (Array.isArray(error.body.detail)) { + // Handle validation errors (422) + message = error.body.detail.map((e: any) => e.msg).join(", ") + } else if (typeof error.body.detail === "string") { + message = error.body.detail + } + } else if (error?.message) { + message = error.message + } + + showErrorToast(message) + }, + }) + + const onSubmit = (data: ProjectCreate) => { + // Clean up empty strings to undefined for optional fields + const cleanData = { + ...data, + description: data.description || undefined, + client_email: data.client_email || undefined, + budget: data.budget || undefined, + start_date: data.start_date || undefined, + deadline: data.deadline || undefined, + } + createMutation.mutate(cleanData) + } + + return ( + setOpen(e.open)} size="lg"> + + + + + Create New Project + + + +
+ +
+ + + + + +