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 72df638d56..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,7 +70,9 @@ jobs: fail-fast: false steps: - uses: actions/checkout@v5 - - uses: actions/setup-node@v6 + - name: Create .env file + run: touch .env + - uses: actions/setup-node@v5 with: node-version: lts/* - uses: actions/setup-python@v6 @@ -60,7 +84,7 @@ jobs: with: limit-access-to-actor: true - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v6 with: version: "0.4.15" enable-cache: true @@ -75,10 +99,11 @@ 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() }} - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v4 with: name: blob-report-${{ matrix.shardIndex }} path: frontend/blob-report @@ -94,14 +119,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v5 with: node-version: 20 - name: Install dependencies run: npm ci working-directory: frontend - name: Download blob reports from GitHub Actions Artifacts - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v5 with: path: frontend/all-blob-reports pattern: blob-report-* @@ -110,7 +135,7 @@ jobs: run: npx playwright merge-reports --reporter html ./all-blob-reports working-directory: frontend - name: Upload HTML report - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v4 with: name: html-report--attempt-${{ github.run_attempt }} path: frontend/playwright-report diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml index 5b0d8aa8d5..c755337389 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/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000000..13566b81b0 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000000..919471704b --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/mosaic-project-cs4800.iml b/.idea/mosaic-project-cs4800.iml new file mode 100644 index 0000000000..d6ebd48059 --- /dev/null +++ b/.idea/mosaic-project-cs4800.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000000..35eb1ddfbb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ 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/2025011501_add_photo_file_size_uploaded_at.py b/backend/app/alembic/versions/2025011501_add_photo_file_size_uploaded_at.py new file mode 100644 index 0000000000..51389c75da --- /dev/null +++ b/backend/app/alembic/versions/2025011501_add_photo_file_size_uploaded_at.py @@ -0,0 +1,34 @@ +"""add file_size and uploaded_at to photo table + +Revision ID: 2025011501 +Revises: 2025111201 +Create Date: 2025-01-15 +""" + +from alembic import op +import sqlalchemy as sa +import sqlmodel +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "2025011501" +down_revision = "2025111201" +branch_labels = None +depends_on = None + + +def upgrade(): + # Add file_size column (in bytes, default 0 for existing photos) + op.add_column("photo", sa.Column("file_size", sa.Integer(), nullable=False, server_default="0")) + + # Add uploaded_at column (use created_at as default for existing photos) + # First add as nullable, then update existing rows, then make non-nullable + op.add_column("photo", sa.Column("uploaded_at", sa.DateTime(), nullable=True)) + op.execute("UPDATE photo SET uploaded_at = created_at WHERE uploaded_at IS NULL") + op.alter_column("photo", "uploaded_at", nullable=False) + + +def downgrade(): + op.drop_column("photo", "uploaded_at") + op.drop_column("photo", "file_size") + 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/alembic/versions/2025111101_add_photo_table.py b/backend/app/alembic/versions/2025111101_add_photo_table.py new file mode 100644 index 0000000000..95d0fba807 --- /dev/null +++ b/backend/app/alembic/versions/2025111101_add_photo_table.py @@ -0,0 +1,36 @@ +"""add photo table + +Revision ID: 2025111101 +Revises: 2025110302 +Create Date: 2025-11-11 +""" + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision = "2025111101" +down_revision = "2025110302" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "photo", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("filename", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column("url", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("gallery_id", sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(["gallery_id"], ["gallery.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade(): + op.drop_table("photo") + + diff --git a/backend/app/alembic/versions/2025111201_add_project_invitation_table.py b/backend/app/alembic/versions/2025111201_add_project_invitation_table.py new file mode 100644 index 0000000000..f63f9304ae --- /dev/null +++ b/backend/app/alembic/versions/2025111201_add_project_invitation_table.py @@ -0,0 +1,40 @@ +"""add project invitation table + +Revision ID: 2025111201 +Revises: 2025110302 +Create Date: 2025-11-12 + +""" +from alembic import op +import sqlalchemy as sa +import sqlmodel +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '2025111201' +# changed from '2025110302' to '2025111101' +down_revision = '2025111101' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'projectinvitation', + sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False), + sa.Column('role', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False), + sa.Column('can_comment', sa.Boolean(), nullable=False), + sa.Column('can_download', sa.Boolean(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + 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') + ) + op.create_index(op.f('ix_projectinvitation_email'), 'projectinvitation', ['email'], unique=False) + + +def downgrade(): + op.drop_index(op.f('ix_projectinvitation_email'), table_name='projectinvitation') + op.drop_table('projectinvitation') + diff --git a/backend/app/api/main.py b/backend/app/api/main.py index eac18c8e8f..336ff8d0e5 100644 --- a/backend/app/api/main.py +++ b/backend/app/api/main.py @@ -1,13 +1,38 @@ from fastapi import APIRouter -from app.api.routes import items, login, private, users, utils +from app.api.routes import ( + galleries, + invitations, + login, + organizations, + private, + project_access, + projects, + users, + utils, +) from app.core.config import settings api_router = APIRouter() api_router.include_router(login.router) api_router.include_router(users.router) api_router.include_router(utils.router) -api_router.include_router(items.router) + + +api_router.include_router( + organizations.router, prefix="/organizations", tags=["organizations"] +) + +api_router.include_router( + project_access.router, prefix="/projects", tags=["project-access"] +) + +api_router.include_router(projects.router, prefix="/projects", tags=["projects"]) + +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..50dc84c844 --- /dev/null +++ b/backend/app/api/routes/galleries.py @@ -0,0 +1,475 @@ +import uuid +from typing import Any + +import os +from io import BytesIO +import zipfile +from pathlib import Path + +from fastapi import APIRouter, HTTPException, UploadFile, File, Body +from fastapi.responses import FileResponse, StreamingResponse + +from app import crud +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + GalleriesPublic, + GalleryCreate, + GalleryPublic, + GalleryUpdate, + Message, + PhotosPublic, + PhotoCreate, + PhotoPublic, +) + +router = APIRouter() + + +STORAGE_ROOT = Path(os.getenv("GALLERY_STORAGE_ROOT", "app_data/galleries")).resolve() + + +def _gallery_storage_dir(gallery_id: uuid.UUID) -> Path: + return STORAGE_ROOT / str(gallery_id) + + +@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}/photos", response_model=PhotosPublic) +def list_gallery_photos( + session: SessionDep, current_user: CurrentUser, id: uuid.UUID, skip: int = 0, limit: int = 100 +) -> Any: + """List photos in a gallery. Visible to clients with access and team in org.""" + gallery = crud.get_gallery(session=session, gallery_id=id) + if not gallery: + raise HTTPException(status_code=404, detail="Gallery not found") + + # Access check (same as read_gallery) + 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": + 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: + if ( + not current_user.organization_id + or project.organization_id != current_user.organization_id + ): + raise HTTPException(status_code=403, detail="Not enough permissions") + + photos = crud.get_photos_by_gallery(session=session, gallery_id=id, skip=skip, limit=limit) + return PhotosPublic( + data=[PhotoPublic.model_validate(p) for p in photos], count=len(photos) + ) + + +@router.post("/{id}/photos", response_model=PhotosPublic) +async def upload_gallery_photos( + id: uuid.UUID, + session: SessionDep, + current_user: CurrentUser, + files: list[UploadFile] = File(...), +) -> Any: + """Upload one or more photos to a gallery. Only team members; max 20 photos total per gallery.""" + # Permission: only team members + user_type = getattr(current_user, "user_type", None) + if user_type != "team_member": + raise HTTPException(status_code=403, detail="Only team members can upload photos") + + gallery = crud.get_gallery(session=session, gallery_id=id) + if not gallery: + raise HTTPException(status_code=404, detail="Gallery not found") + + # Org check + 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") + + # Enforce max 20 photos + existing_count = crud.count_photos_in_gallery(session=session, gallery_id=id) + if existing_count + len(files) > 20: + raise HTTPException( + status_code=400, detail="Gallery can hold at most 20 photos" + ) + + storage_dir = _gallery_storage_dir(id) + storage_dir.mkdir(parents=True, exist_ok=True) + + saved = [] + for uf in files: + # Normalize filename + safe_name = os.path.basename(uf.filename or "photo") + target_path = storage_dir / safe_name + # If duplicate filename, add suffix + counter = 1 + while target_path.exists(): + stem = Path(safe_name).stem + ext = Path(safe_name).suffix + target_path = storage_dir / f"{stem}-{counter}{ext}" + counter += 1 + content = await uf.read() + file_size = len(content) # Get file size in bytes + with open(target_path, "wb") as out: + out.write(content) + + # Create photo record; url points to file-serving endpoint + rel_filename = target_path.name + photo = crud.create_photo( + session=session, + photo_in=PhotoCreate( + gallery_id=id, + filename=rel_filename, + url=f"/api/v1/galleries/{id}/photos/files/{rel_filename}", + file_size=file_size, + ), + ) + saved.append(photo) + + return PhotosPublic( + data=[PhotoPublic.model_validate(p) for p in saved], count=len(saved) + ) + + +@router.delete("/{id}/photos") +def delete_gallery_photos( + id: uuid.UUID, + session: SessionDep, + current_user: CurrentUser, + photo_ids: list[uuid.UUID] = Body(..., embed=True), +) -> Message: + """Delete selected photos. Only team members. Also removes files from storage.""" + user_type = getattr(current_user, "user_type", None) + if user_type != "team_member": + raise HTTPException(status_code=403, detail="Only team members can delete photos") + + gallery = crud.get_gallery(session=session, gallery_id=id) + if not gallery: + raise HTTPException(status_code=404, detail="Gallery not found") + + 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") + + # Remove files + storage_dir = _gallery_storage_dir(id) + deleted = 0 + from sqlmodel import select + from app.models import Photo + + for pid in photo_ids: + photo = session.get(Photo, pid) + if photo and photo.gallery_id == id: + file_path = storage_dir / photo.filename + try: + if file_path.exists(): + file_path.unlink() + except Exception: + pass + deleted += 1 + + # Remove db rows + crud.delete_photos(session=session, gallery_id=id, photo_ids=photo_ids) + + return Message(message=f"Deleted {deleted} photos") + + +@router.get("/{id}/photos/files/{filename}") +def get_photo_file(id: uuid.UUID, filename: str) -> Any: + """Serve a stored photo file publicly.""" + file_path = _gallery_storage_dir(id) / filename + if not file_path.exists(): + raise HTTPException(status_code=404, detail="File not found") + return FileResponse(file_path) + + +@router.get("/{id}/download-all") +def download_all_photos(id: uuid.UUID, session: SessionDep) -> Any: + """Download all photos in the gallery as a ZIP, publicly accessible.""" + storage_dir = _gallery_storage_dir(id) + if not storage_dir.exists(): + raise HTTPException(status_code=404, detail="Gallery not found or empty") + + # Get project name for filename + gallery = crud.get_gallery(session=session, gallery_id=id) + project_name = "Project" + if gallery: + project = crud.get_project(session=session, project_id=gallery.project_id) + if project: + # Sanitize project name for filename + project_name = "".join(c if c.isalnum() or c in (" ", "-", "_") else "_" for c in project.name) + project_name = project_name.replace(" ", "_") + + memfile = BytesIO() + with zipfile.ZipFile(memfile, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: + for path in storage_dir.glob("*"): + if path.is_file(): + zf.write(path, arcname=path.name) + memfile.seek(0) + filename = f"Mosaic-{project_name}-Photos.zip" + return StreamingResponse( + memfile, + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"' + }, + ) + + +@router.post("/{id}/photos/download") +def download_selected_photos( + id: uuid.UUID, + session: SessionDep, + photo_ids: list[uuid.UUID] = Body(..., embed=True), +) -> Any: + """Download selected photos in the gallery as a ZIP, publicly accessible.""" + storage_dir = _gallery_storage_dir(id) + if not storage_dir.exists(): + raise HTTPException(status_code=404, detail="Gallery not found or empty") + + # Get project name for filename + gallery = crud.get_gallery(session=session, gallery_id=id) + project_name = "Project" + if gallery: + project = crud.get_project(session=session, project_id=gallery.project_id) + if project: + # Sanitize project name for filename + project_name = "".join(c if c.isalnum() or c in (" ", "-", "_") else "_" for c in project.name) + project_name = project_name.replace(" ", "_") + + # Collect filenames by reading DB + from app.models import Photo + files: list[Path] = [] + for pid in photo_ids: + photo = session.get(Photo, pid) + # Fallback: construct by filename only if not found + if photo and photo.gallery_id == id: + fp = storage_dir / photo.filename + if fp.exists(): + files.append(fp) + + if not files: + raise HTTPException(status_code=404, detail="No matching photos found") + + memfile = BytesIO() + with zipfile.ZipFile(memfile, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: + for path in files: + zf.write(path, arcname=path.name) + memfile.seek(0) + filename = f"Mosaic-{project_name}-Photos.zip" + return StreamingResponse( + memfile, + media_type="application/zip", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"' + }, + ) + +@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/items.py b/backend/app/api/routes/items.py deleted file mode 100644 index 177dc1e476..0000000000 --- a/backend/app/api/routes/items.py +++ /dev/null @@ -1,109 +0,0 @@ -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 Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message - -router = APIRouter(prefix="/items", tags=["items"]) - - -@router.get("/", response_model=ItemsPublic) -def read_items( - session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100 -) -> Any: - """ - Retrieve items. - """ - - if current_user.is_superuser: - count_statement = select(func.count()).select_from(Item) - count = session.exec(count_statement).one() - statement = select(Item).offset(skip).limit(limit) - items = session.exec(statement).all() - else: - count_statement = ( - select(func.count()) - .select_from(Item) - .where(Item.owner_id == current_user.id) - ) - count = session.exec(count_statement).one() - statement = ( - select(Item) - .where(Item.owner_id == current_user.id) - .offset(skip) - .limit(limit) - ) - items = session.exec(statement).all() - - return ItemsPublic(data=items, count=count) - - -@router.get("/{id}", response_model=ItemPublic) -def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any: - """ - Get item by ID. - """ - item = session.get(Item, id) - if not item: - raise HTTPException(status_code=404, detail="Item not found") - if not current_user.is_superuser and (item.owner_id != current_user.id): - raise HTTPException(status_code=400, detail="Not enough permissions") - return item - - -@router.post("/", response_model=ItemPublic) -def create_item( - *, session: SessionDep, current_user: CurrentUser, item_in: ItemCreate -) -> Any: - """ - Create new item. - """ - item = Item.model_validate(item_in, update={"owner_id": current_user.id}) - session.add(item) - session.commit() - session.refresh(item) - return item - - -@router.put("/{id}", response_model=ItemPublic) -def update_item( - *, - session: SessionDep, - current_user: CurrentUser, - id: uuid.UUID, - item_in: ItemUpdate, -) -> Any: - """ - Update an item. - """ - item = session.get(Item, id) - if not item: - raise HTTPException(status_code=404, detail="Item not found") - if not current_user.is_superuser and (item.owner_id != current_user.id): - raise HTTPException(status_code=400, detail="Not enough permissions") - update_dict = item_in.model_dump(exclude_unset=True) - item.sqlmodel_update(update_dict) - session.add(item) - session.commit() - session.refresh(item) - return item - - -@router.delete("/{id}") -def delete_item( - session: SessionDep, current_user: CurrentUser, id: uuid.UUID -) -> Message: - """ - Delete an item. - """ - item = session.get(Item, id) - if not item: - raise HTTPException(status_code=404, detail="Item not found") - if not current_user.is_superuser and (item.owner_id != current_user.id): - raise HTTPException(status_code=400, detail="Not enough permissions") - session.delete(item) - session.commit() - return Message(message="Item deleted successfully") 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..670e00ab30 --- /dev/null +++ b/backend/app/api/routes/project_access.py @@ -0,0 +1,294 @@ +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, + ProjectAccessInviteByEmail, + ProjectAccessPublic, + ProjectAccessUpdate, + ProjectAccessWithUser, + User, +) + +router = APIRouter() + + +# AN - New endpoint to get all projects the current user has access to +@router.get("/my-projects") +def read_my_projects( + session: SessionDep, + current_user: CurrentUser, +) -> Any: + """ + Get all projects the current user has access to. + For clients: returns projects they've been invited to. + For team members: returns all projects in their organization. + """ + if getattr(current_user, "user_type", None) == "client": + # Use the existing function - perfect! + projects = crud.get_user_accessible_projects( + session=session, user_id=current_user.id, skip=0, limit=1000 + ) + return {"data": projects, "count": len(projects)} + elif getattr(current_user, "user_type", None) == "team_member": + if not current_user.organization_id: + return {"data": [], "count": 0} + projects = crud.get_projects_by_organization( + session=session, + organization_id=current_user.organization_id, + skip=0, + limit=1000 + ) + return {"data": projects, "count": len(projects)} + else: + return {"data": [], "count": 0} + +@router.post("/{project_id}/access/invite-by-email") +def invite_client_by_email( + *, + session: SessionDep, + current_user: CurrentUser, + project_id: uuid.UUID, + invite_data: ProjectAccessInviteByEmail, +) -> Any: + """ + Invite a client to a project by email. + If user exists: grants immediate access + If user doesn't exist: creates a pending invitation + 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", + ) + + # Invite client by email + try: + access, is_pending = crud.invite_client_by_email( + session=session, + project_id=project_id, + email=invite_data.email, + role=invite_data.role, + can_comment=invite_data.can_comment, + can_download=invite_data.can_download, + ) + + if is_pending: + return { + "message": "Invitation sent. Client will get access when they sign up with this email.", + "is_pending": True, + "email": invite_data.email, + } + else: + return { + "message": "Client invited successfully", + "is_pending": False, + "access": ProjectAccessPublic.model_validate(access), + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@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. + DEPRECATED: Use /invite-by-email endpoint instead. + """ + # 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=list[ProjectAccessWithUser]) +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) + # Convert to ProjectAccessWithUser + result = [] + for access in access_list: + user = session.get(User, access.user_id) + if user: + from app.models import UserPublic + + result.append( + ProjectAccessWithUser( + id=access.id, + created_at=access.created_at, + project_id=access.project_id, + user_id=access.user_id, + role=access.role, + can_comment=access.can_comment, + can_download=access.can_download, + user=UserPublic.model_validate(user), + ) + ) + return result + + +@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..b035391fc3 --- /dev/null +++ b/backend/app/api/routes/projects.py @@ -0,0 +1,237 @@ +import os +import uuid +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, HTTPException +from sqlmodel import select + +from app import crud +from app.api.deps import CurrentUser, SessionDep +from app.models import ( + DashboardStats, + GalleryCreate, + Message, + ProjectCreate, + ProjectPublic, + ProjectsPublic, + ProjectUpdate, +) + +router = APIRouter() + + +def _gallery_storage_root() -> Path: + """Return the root directory where gallery photos are stored. + + Mirrors the logic in app.api.routes.galleries.STORAGE_ROOT without importing it + directly here to avoid any circular import issues at app startup. + """ + return Path(os.getenv("GALLERY_STORAGE_ROOT", "app_data/galleries")).resolve() + + +@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") + + # Before deleting the project from DB, remove gallery folders & photos from storage. + from app.models import Gallery + + storage_root = _gallery_storage_root() + + # Find all galleries for this project + statement = select(Gallery).where(Gallery.project_id == id) + galleries = session.exec(statement).all() + + for gallery in galleries: + gallery_dir = storage_root / str(gallery.id) + if gallery_dir.exists() and gallery_dir.is_dir(): + # Best-effort recursive delete of all files and subdirs + for root, dirs, files in os.walk(gallery_dir, topdown=False): + for name in files: + try: + (Path(root) / name).unlink() + except OSError: + pass + for name in dirs: + try: + (Path(root) / name).rmdir() + except OSError: + pass + try: + gallery_dir.rmdir() + except OSError: + # If something remains locked, ignore; DB rows will still be removed + pass + + # Delete the project. DB-level cascading will remove galleries & photos. + 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..0634c43c36 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -2,7 +2,7 @@ from typing import Any from fastapi import APIRouter, Depends, HTTPException -from sqlmodel import col, delete, func, select +from sqlmodel import func, select from app import crud from app.api.deps import ( @@ -13,7 +13,6 @@ from app.core.config import settings from app.core.security import get_password_hash, verify_password from app.models import ( - Item, Message, UpdatePassword, User, @@ -48,6 +47,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,15 +166,175 @@ 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) + + # Process any pending project invitations for clients + if user.user_type == "client": + crud.process_pending_project_invitations( + session=session, + user_id=user.id, + email=user.email, + ) + + 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 @@ -219,8 +402,6 @@ def delete_user( raise HTTPException( status_code=403, detail="Super users are not allowed to delete themselves" ) - statement = delete(Item).where(col(Item.owner_id) == user_id) - session.exec(statement) # type: ignore session.delete(user) session.commit() return Message(message="User deleted successfully") 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/config.py b/backend/app/core/config.py index 6a8ca50bb1..72e376a1c6 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -26,7 +26,7 @@ def parse_cors(v: Any) -> list[str] | str: class Settings(BaseSettings): model_config = SettingsConfigDict( # Use top level .env file (one level above ./backend/) - env_file="../.env", + #env_file="../.env", env_ignore_empty=True, extra="ignore", ) diff --git a/backend/app/core/db.py b/backend/app/core/db.py index ba991fb36d..d31505f9bc 100644 --- a/backend/app/core/db.py +++ b/backend/app/core/db.py @@ -1,8 +1,12 @@ +import logging + from sqlmodel import Session, create_engine, select from app import crud from app.core.config import settings -from app.models import User, UserCreate +from app.models import OrganizationCreate, User, UserCreate + +logger = logging.getLogger(__name__) engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI)) @@ -21,13 +25,34 @@ def init_db(session: Session) -> None: # This works because the models are already imported and registered from app.models # SQLModel.metadata.create_all(engine) - user = session.exec( - select(User).where(User.email == settings.FIRST_SUPERUSER) - ).first() - if not user: - user_in = UserCreate( - email=settings.FIRST_SUPERUSER, - password=settings.FIRST_SUPERUSER_PASSWORD, - is_superuser=True, - ) - user = crud.create_user(session=session, user_create=user_in) + try: + # Check if superuser exists + user = session.exec( + select(User).where(User.email == settings.FIRST_SUPERUSER) + ).first() + + if not user: + logger.info(f"Creating superuser: {settings.FIRST_SUPERUSER}") + # 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 + ) + logger.info(f"Created organization: {organization.name} (id: {organization.id})") + + # 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) + logger.info(f"Created superuser: {user.email} (id: {user.id})") + else: + logger.info(f"Superuser already exists: {user.email}") + except Exception as e: + logger.error(f"Error creating superuser: {e}", exc_info=True) + raise diff --git a/backend/app/crud.py b/backend/app/crud.py index 905bf48724..3cc58c7d7e 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -1,10 +1,33 @@ 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, + Photo, + PhotoCreate, + #Item, + #ItemCreate, + Organization, + OrganizationCreate, + OrganizationUpdate, + Project, + ProjectAccess, + ProjectAccessCreate, + ProjectAccessUpdate, + ProjectCreate, + ProjectInvitation, + ProjectUpdate, + User, + UserCreate, + UserUpdate, +) def create_user(*, session: Session, user_create: UserCreate) -> User: @@ -46,9 +69,556 @@ def authenticate(*, session: Session, email: str, password: str) -> User | None: return db_user -def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item: +#def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item: db_item = Item.model_validate(item_in, update={"owner_id": owner_id}) session.add(db_item) session.commit() session.refresh(db_item) return db_item + + +# ============================================================================ +# 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() + + +def create_photo(*, session: Session, photo_in: PhotoCreate) -> Photo: + """Create a new photo record for a gallery and keep gallery.photo_count in sync.""" + db_obj = Photo.model_validate(photo_in) + session.add(db_obj) + session.commit() + session.refresh(db_obj) + + # Update gallery photo_count + gallery = session.get(Gallery, photo_in.gallery_id) + if gallery is not None: + # Recalculate from DB to avoid drift + gallery.photo_count = count_photos_in_gallery( + session=session, gallery_id=gallery.id + ) + session.add(gallery) + session.commit() + session.refresh(gallery) + + return db_obj + + +def delete_photos( + *, session: Session, gallery_id: uuid.UUID, photo_ids: list[uuid.UUID] +) -> int: + """Delete photos by ID for a gallery and update gallery.photo_count. + + Returns the number of Photo rows deleted. + """ + if not photo_ids: + return 0 + + # Only delete photos that belong to this gallery + from app.models import Photo # local import to avoid circulars in some tools + + statement = select(Photo).where( + Photo.gallery_id == gallery_id, Photo.id.in_(photo_ids) # type: ignore[arg-type] + ) + photos = list(session.exec(statement).all()) + deleted_count = 0 + + for photo in photos: + session.delete(photo) + deleted_count += 1 + + # Update gallery photo_count after deletions + gallery = session.get(Gallery, gallery_id) + if gallery is not None: + gallery.photo_count = max( + 0, count_photos_in_gallery(session=session, gallery_id=gallery.id) + ) + session.add(gallery) + + session.commit() + + return deleted_count + + +def get_photos_by_gallery( + *, session: Session, gallery_id: uuid.UUID, skip: int = 0, limit: int = 100 +) -> list[Photo]: + """Get photos belonging to a specific gallery.""" + statement = ( + select(Photo) + .where(Photo.gallery_id == gallery_id) + .offset(skip) + .limit(limit) + .order_by(desc(Photo.created_at)) + ) + return list(session.exec(statement).all()) + + +def count_photos_in_gallery(*, session: Session, gallery_id: uuid.UUID) -> int: + """Count how many photos are in a gallery.""" + statement = ( + select(func.count()) + .select_from(Photo) + .where(Photo.gallery_id == gallery_id) + ) + return session.exec(statement).one() + + +# ============================================================================ +# 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 + + +def invite_client_by_email( + *, + session: Session, + project_id: uuid.UUID, + email: str, + role: str = "viewer", + can_comment: bool = True, + can_download: bool = True, +) -> tuple[ProjectAccess | None, bool]: + """ + Invite a client to a project by email. + If user exists: grants immediate access and returns (access, False) + If user doesn't exist: creates a pending ProjectInvitation and returns (None, True) + """ + # Check if user exists + user = get_user_by_email(session=session, email=email) + + if user: + # User exists - grant immediate access + # Check if access already exists + existing_access = get_project_access( + session=session, project_id=project_id, user_id=user.id + ) + if existing_access: + # Update existing access + existing_access.role = role + existing_access.can_comment = can_comment + existing_access.can_download = can_download + session.add(existing_access) + session.commit() + session.refresh(existing_access) + return existing_access, False + + # Create new access + access_in = ProjectAccessCreate( + project_id=project_id, + user_id=user.id, + role=role, + can_comment=can_comment, + can_download=can_download, + ) + access = create_project_access(session=session, access_in=access_in) + return access, False + else: + # User doesn't exist - create pending invitation + # Check if invitation already exists + existing_invitation = session.exec( + select(ProjectInvitation).where( + ProjectInvitation.project_id == project_id, + ProjectInvitation.email == email, + ) + ).first() + + if existing_invitation: + # Update existing invitation + existing_invitation.role = role + existing_invitation.can_comment = can_comment + existing_invitation.can_download = can_download + session.add(existing_invitation) + session.commit() + session.refresh(existing_invitation) + return None, True + + # Create new invitation + invitation = ProjectInvitation( + project_id=project_id, + email=email, + role=role, + can_comment=can_comment, + can_download=can_download, + ) + session.add(invitation) + session.commit() + session.refresh(invitation) + return None, True + + +def process_pending_project_invitations( + *, session: Session, user_id: uuid.UUID, email: str +) -> None: + """ + Process pending project invitations for a newly created client user. + Finds all ProjectInvitation records for the email, creates ProjectAccess for each, + and deletes the invitations. + """ + # Find all pending invitations for this email + statement = select(ProjectInvitation).where(ProjectInvitation.email == email) + invitations = session.exec(statement).all() + + for invitation in invitations: + # Create project access + access_in = ProjectAccessCreate( + project_id=invitation.project_id, + user_id=user_id, + role=invitation.role, + can_comment=invitation.can_comment, + can_download=invitation.can_download, + ) + create_project_access(session=session, access_in=access_in) + + # Delete the invitation + session.delete(invitation) + + # Commit all changes + session.commit() + + +# ============================================================================ +# 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/initial_data.py b/backend/app/initial_data.py index d806c3d381..4cd9d6ebfc 100644 --- a/backend/app/initial_data.py +++ b/backend/app/initial_data.py @@ -1,4 +1,5 @@ import logging +import sys from sqlmodel import Session @@ -9,14 +10,22 @@ def init() -> None: - with Session(engine) as session: - init_db(session) + try: + with Session(engine) as session: + init_db(session) + except Exception as e: + logger.error(f"Error in init_db: {e}", exc_info=True) + raise def main() -> None: - logger.info("Creating initial data") - init() - logger.info("Initial data created") + try: + logger.info("Creating initial data") + init() + logger.info("Initial data created") + except Exception as e: + logger.error(f"Failed to create initial data: {e}", exc_info=True) + sys.exit(1) if __name__ == "__main__": 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 2d060ba0b4..c4d99cfc63 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,23 +13,26 @@ 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 class UserCreate(UserBase): - password: str = Field(min_length=8, max_length=128) + password: str = Field(min_length=8, max_length=40) class UserRegister(SQLModel): email: EmailStr = Field(max_length=255) - password: str = Field(min_length=8, max_length=128) + 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 class UserUpdate(UserBase): email: EmailStr | None = Field(default=None, max_length=255) # type: ignore - password: str | None = Field(default=None, min_length=8, max_length=128) + password: str | None = Field(default=None, min_length=8, max_length=40) class UserUpdateMe(SQLModel): @@ -35,15 +41,21 @@ class UserUpdateMe(SQLModel): class UpdatePassword(SQLModel): - current_password: str = Field(min_length=8, max_length=128) - new_password: str = Field(min_length=8, max_length=128) + current_password: str = Field(min_length=8, max_length=40) + new_password: str = Field(min_length=8, max_length=40) # Database model, database table inferred from class name 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 @@ -56,58 +68,338 @@ class UsersPublic(SQLModel): count: int -# Shared properties -class ItemBase(SQLModel): - title: str = Field(min_length=1, max_length=255) - description: str | None = Field(default=None, max_length=255) +# Generic message +class Message(SQLModel): + message: str + + +# JSON payload containing access token +class Token(SQLModel): + access_token: str + token_type: str = "bearer" + + +# Contents of JWT token +class TokenPayload(SQLModel): + sub: str | None = None + + +class NewPassword(SQLModel): + token: str + new_password: str = Field(min_length=8, max_length=40) + + +# ============================================================================ +# ORGANIZATION MODELS +# ============================================================================ -# Properties to receive on item creation -class ItemCreate(ItemBase): +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 -# Properties to receive on item update -class ItemUpdate(ItemBase): - title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore +class OrganizationUpdate(SQLModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=1000) -# Database model, database table inferred from class name -class Item(ItemBase, table=True): +class Organization(OrganizationBase, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) - owner_id: uuid.UUID = Field( + 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 + + +# ============================================================================ +# PHOTO MODELS +# ============================================================================ + + +class PhotoBase(SQLModel): + filename: str = Field(min_length=1, max_length=255) + url: str = Field(min_length=1, max_length=500) + + +class PhotoCreate(SQLModel): + gallery_id: uuid.UUID + filename: str = Field(min_length=1, max_length=255) + url: str = Field(min_length=1, max_length=500) + file_size: int = Field(ge=0) # File size in bytes + + +class Photo(PhotoBase, table=True): + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + created_at: datetime = Field(default_factory=datetime.utcnow) + uploaded_at: datetime = Field(default_factory=datetime.utcnow) # When photo was uploaded + file_size: int = Field(default=0, ge=0) # File size in bytes + gallery_id: uuid.UUID = Field( + foreign_key="gallery.id", nullable=False, ondelete="CASCADE" + ) + + +class PhotoPublic(PhotoBase): + id: uuid.UUID + created_at: datetime + uploaded_at: datetime + file_size: int + gallery_id: uuid.UUID + + +class PhotosPublic(SQLModel): + data: list[PhotoPublic] + 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 ProjectAccessInviteByEmail(ProjectAccessBase): + """Invite a client to a project by email - creates user if needed""" + + email: EmailStr = Field(max_length=255) + + +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" ) - owner: User | None = Relationship(back_populates="items") + project: Optional["Project"] = Relationship(back_populates="access_list") + user: Optional["User"] = Relationship(back_populates="project_access") -# Properties to return via API, id is always required -class ItemPublic(ItemBase): +class ProjectAccessPublic(ProjectAccessBase): id: uuid.UUID - owner_id: uuid.UUID + created_at: datetime + project_id: uuid.UUID + user_id: uuid.UUID + +class ProjectAccessWithUser(ProjectAccessPublic): + user: UserPublic -class ItemsPublic(SQLModel): - data: list[ItemPublic] + +class ProjectAccessesPublic(SQLModel): + data: list[ProjectAccessPublic] count: int -# Generic message -class Message(SQLModel): - message: str +# ============================================================================ +# DASHBOARD STATS +# ============================================================================ -# JSON payload containing access token -class Token(SQLModel): - access_token: str - token_type: str = "bearer" +class DashboardStats(SQLModel): + active_projects: int + upcoming_deadlines: int + team_members: int + completed_this_month: int -# Contents of JWT token -class TokenPayload(SQLModel): - sub: str | None = None +# ============================================================================ +# Organization Invitation Models +# ============================================================================ -class NewPassword(SQLModel): - token: str - new_password: str = Field(min_length=8, max_length=128) +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 + + +# ============================================================================ +# PROJECT INVITATION MODELS (Pending Client Access) +# ============================================================================ + + +class ProjectInvitationBase(SQLModel): + email: EmailStr = Field(max_length=255, index=True) + role: str = Field(default="viewer", max_length=50) + can_comment: bool = Field(default=True) + can_download: bool = Field(default=True) + + +class ProjectInvitation(ProjectInvitationBase, 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() + + +class ProjectInvitationPublic(ProjectInvitationBase): + id: uuid.UUID + created_at: datetime + project_id: uuid.UUID 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/scripts/prestart.sh b/backend/scripts/prestart.sh index 1b395d513f..27bbbe5faa 100644 --- a/backend/scripts/prestart.sh +++ b/backend/scripts/prestart.sh @@ -3,11 +3,27 @@ set -e set -x +echo "Starting prestart script..." + # Let the DB start -python app/backend_pre_start.py +echo "Waiting for database to be ready..." +python app/backend_pre_start.py || { + echo "ERROR: Failed to connect to database" + exit 1 +} # Run migrations -alembic upgrade head +echo "Running database migrations..." +alembic upgrade head || { + echo "ERROR: Database migrations failed" + exit 1 +} # Create initial data in DB -python app/initial_data.py +echo "Creating initial data..." +python app/initial_data.py || { + echo "ERROR: Failed to create initial data" + exit 1 +} + +echo "Prestart script completed successfully!" diff --git a/backend/tests/api/routes/test_items.py b/backend/tests/api/routes/test_items.py deleted file mode 100644 index db950b4535..0000000000 --- a/backend/tests/api/routes/test_items.py +++ /dev/null @@ -1,164 +0,0 @@ -import uuid - -from fastapi.testclient import TestClient -from sqlmodel import Session - -from app.core.config import settings -from tests.utils.item import create_random_item - - -def test_create_item( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - data = {"title": "Foo", "description": "Fighters"} - response = client.post( - f"{settings.API_V1_STR}/items/", - headers=superuser_token_headers, - json=data, - ) - assert response.status_code == 200 - content = response.json() - assert content["title"] == data["title"] - assert content["description"] == data["description"] - assert "id" in content - assert "owner_id" in content - - -def test_read_item( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - item = create_random_item(db) - response = client.get( - f"{settings.API_V1_STR}/items/{item.id}", - headers=superuser_token_headers, - ) - assert response.status_code == 200 - content = response.json() - assert content["title"] == item.title - assert content["description"] == item.description - assert content["id"] == str(item.id) - assert content["owner_id"] == str(item.owner_id) - - -def test_read_item_not_found( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - response = client.get( - f"{settings.API_V1_STR}/items/{uuid.uuid4()}", - headers=superuser_token_headers, - ) - assert response.status_code == 404 - content = response.json() - assert content["detail"] == "Item not found" - - -def test_read_item_not_enough_permissions( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session -) -> None: - item = create_random_item(db) - response = client.get( - f"{settings.API_V1_STR}/items/{item.id}", - headers=normal_user_token_headers, - ) - assert response.status_code == 400 - content = response.json() - assert content["detail"] == "Not enough permissions" - - -def test_read_items( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - create_random_item(db) - create_random_item(db) - response = client.get( - f"{settings.API_V1_STR}/items/", - headers=superuser_token_headers, - ) - assert response.status_code == 200 - content = response.json() - assert len(content["data"]) >= 2 - - -def test_update_item( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - item = create_random_item(db) - data = {"title": "Updated title", "description": "Updated description"} - response = client.put( - f"{settings.API_V1_STR}/items/{item.id}", - headers=superuser_token_headers, - json=data, - ) - assert response.status_code == 200 - content = response.json() - assert content["title"] == data["title"] - assert content["description"] == data["description"] - assert content["id"] == str(item.id) - assert content["owner_id"] == str(item.owner_id) - - -def test_update_item_not_found( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - data = {"title": "Updated title", "description": "Updated description"} - response = client.put( - f"{settings.API_V1_STR}/items/{uuid.uuid4()}", - headers=superuser_token_headers, - json=data, - ) - assert response.status_code == 404 - content = response.json() - assert content["detail"] == "Item not found" - - -def test_update_item_not_enough_permissions( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session -) -> None: - item = create_random_item(db) - data = {"title": "Updated title", "description": "Updated description"} - response = client.put( - f"{settings.API_V1_STR}/items/{item.id}", - headers=normal_user_token_headers, - json=data, - ) - assert response.status_code == 400 - content = response.json() - assert content["detail"] == "Not enough permissions" - - -def test_delete_item( - client: TestClient, superuser_token_headers: dict[str, str], db: Session -) -> None: - item = create_random_item(db) - response = client.delete( - f"{settings.API_V1_STR}/items/{item.id}", - headers=superuser_token_headers, - ) - assert response.status_code == 200 - content = response.json() - assert content["message"] == "Item deleted successfully" - - -def test_delete_item_not_found( - client: TestClient, superuser_token_headers: dict[str, str] -) -> None: - response = client.delete( - f"{settings.API_V1_STR}/items/{uuid.uuid4()}", - headers=superuser_token_headers, - ) - assert response.status_code == 404 - content = response.json() - assert content["detail"] == "Item not found" - - -def test_delete_item_not_enough_permissions( - client: TestClient, normal_user_token_headers: dict[str, str], db: Session -) -> None: - item = create_random_item(db) - response = client.delete( - f"{settings.API_V1_STR}/items/{item.id}", - headers=normal_user_token_headers, - ) - assert response.status_code == 400 - content = response.json() - assert content["detail"] == "Not enough permissions" 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..6e87adb2c2 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 Organization, User from tests.utils.user import authentication_token_from_email from tests.utils.utils import get_superuser_token_headers @@ -17,10 +17,11 @@ def db() -> Generator[Session, None, None]: with Session(engine) as session: init_db(session) yield session - statement = delete(Item) - session.execute(statement) + # Clean up in proper order due to foreign key constraints 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/backend/tests/utils/item.py b/backend/tests/utils/item.py deleted file mode 100644 index ee51b351a6..0000000000 --- a/backend/tests/utils/item.py +++ /dev/null @@ -1,16 +0,0 @@ -from sqlmodel import Session - -from app import crud -from app.models import Item, ItemCreate -from tests.utils.user import create_random_user -from tests.utils.utils import random_lower_string - - -def create_random_item(db: Session) -> Item: - user = create_random_user(db) - owner_id = user.id - assert owner_id is not None - title = random_lower_string() - description = random_lower_string() - item_in = ItemCreate(title=title, description=description) - return crud.create_item(session=db, item_in=item_in, owner_id=owner_id) 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..d237c583c6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,6 @@ +x-backend-build: &backend-build + context: ./backend + services: db: @@ -44,8 +47,7 @@ services: prestart: image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}' - build: - context: ./backend + build: *backend-build networks: - traefik-public - default @@ -74,9 +76,11 @@ 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}' + build: *backend-build restart: always networks: - traefik-public @@ -89,6 +93,8 @@ services: condition: service_completed_successfully env_file: - .env + volumes: + - ./backend/app_data:/app/app_data environment: - DOMAIN=${DOMAIN} - FRONTEND_HOST=${FRONTEND_HOST?Variable not set} @@ -107,15 +113,14 @@ 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} + command: fastapi run --workers 4 app/main.py healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"] interval: 10s timeout: 5s retries: 5 - build: - context: ./backend labels: - traefik.enable=true - traefik.docker.network=traefik-public 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/Dockerfile b/frontend/Dockerfile index ee30d000f3..06d430588d 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -9,7 +9,8 @@ RUN npm install COPY ./ /app/ -ARG VITE_API_URL=${VITE_API_URL} +ARG VITE_API_URL +ENV VITE_API_URL=$VITE_API_URL RUN npm run build 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 a924713d37..703e5b3f23 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/ItemActionsMenu.tsx b/frontend/src/components/Common/ItemActionsMenu.tsx deleted file mode 100644 index 18e424fdd4..0000000000 --- a/frontend/src/components/Common/ItemActionsMenu.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { IconButton } from "@chakra-ui/react" -import { BsThreeDotsVertical } from "react-icons/bs" -import type { ItemPublic } from "@/client" -import DeleteItem from "../Items/DeleteItem" -import EditItem from "../Items/EditItem" -import { MenuContent, MenuRoot, MenuTrigger } from "../ui/menu" - -interface ItemActionsMenuProps { - item: ItemPublic -} - -export const ItemActionsMenu = ({ item }: ItemActionsMenuProps) => { - return ( - - - - - - - - - - - - ) -} 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/Items/AddItem.tsx b/frontend/src/components/Items/AddItem.tsx deleted file mode 100644 index 5a377b952a..0000000000 --- a/frontend/src/components/Items/AddItem.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { - Button, - DialogActionTrigger, - DialogTitle, - Input, - Text, - VStack, -} from "@chakra-ui/react" -import { useMutation, useQueryClient } from "@tanstack/react-query" -import { useState } from "react" -import { type SubmitHandler, useForm } from "react-hook-form" -import { FaPlus } from "react-icons/fa" - -import { type ItemCreate, ItemsService } from "@/client" -import type { ApiError } from "@/client/core/ApiError" -import useCustomToast from "@/hooks/useCustomToast" -import { handleError } from "@/utils" -import { - DialogBody, - DialogCloseTrigger, - DialogContent, - DialogFooter, - DialogHeader, - DialogRoot, - DialogTrigger, -} from "../ui/dialog" -import { Field } from "../ui/field" - -const AddItem = () => { - const [isOpen, setIsOpen] = useState(false) - const queryClient = useQueryClient() - const { showSuccessToast } = useCustomToast() - const { - register, - handleSubmit, - reset, - formState: { errors, isValid, isSubmitting }, - } = useForm({ - mode: "onBlur", - criteriaMode: "all", - defaultValues: { - title: "", - description: "", - }, - }) - - const mutation = useMutation({ - mutationFn: (data: ItemCreate) => - ItemsService.createItem({ requestBody: data }), - onSuccess: () => { - showSuccessToast("Item created successfully.") - reset() - setIsOpen(false) - }, - onError: (err: ApiError) => { - handleError(err) - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["items"] }) - }, - }) - - const onSubmit: SubmitHandler = (data) => { - mutation.mutate(data) - } - - return ( - setIsOpen(open)} - > - - - - -
- - Add Item - - - Fill in the details to add a new item. - - - - - - - - - - - - - - - - - -
- -
-
- ) -} - -export default AddItem diff --git a/frontend/src/components/Items/DeleteItem.tsx b/frontend/src/components/Items/DeleteItem.tsx deleted file mode 100644 index ea3b7fdc7e..0000000000 --- a/frontend/src/components/Items/DeleteItem.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { Button, DialogTitle, Text } from "@chakra-ui/react" -import { useMutation, useQueryClient } from "@tanstack/react-query" -import { useState } from "react" -import { useForm } from "react-hook-form" -import { FiTrash2 } from "react-icons/fi" - -import { ItemsService } from "@/client" -import { - DialogActionTrigger, - DialogBody, - DialogCloseTrigger, - DialogContent, - DialogFooter, - DialogHeader, - DialogRoot, - DialogTrigger, -} from "@/components/ui/dialog" -import useCustomToast from "@/hooks/useCustomToast" - -const DeleteItem = ({ id }: { id: string }) => { - const [isOpen, setIsOpen] = useState(false) - const queryClient = useQueryClient() - const { showSuccessToast, showErrorToast } = useCustomToast() - const { - handleSubmit, - formState: { isSubmitting }, - } = useForm() - - const deleteItem = async (id: string) => { - await ItemsService.deleteItem({ id: id }) - } - - const mutation = useMutation({ - mutationFn: deleteItem, - onSuccess: () => { - showSuccessToast("The item was deleted successfully") - setIsOpen(false) - }, - onError: () => { - showErrorToast("An error occurred while deleting the item") - }, - onSettled: () => { - queryClient.invalidateQueries() - }, - }) - - const onSubmit = async () => { - mutation.mutate(id) - } - - return ( - setIsOpen(open)} - > - - - - - -
- - - Delete Item - - - - This item will be permanently deleted. Are you sure? You will not - be able to undo this action. - - - - - - - - - - -
-
- ) -} - -export default DeleteItem diff --git a/frontend/src/components/Items/EditItem.tsx b/frontend/src/components/Items/EditItem.tsx deleted file mode 100644 index e23c92b422..0000000000 --- a/frontend/src/components/Items/EditItem.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { - Button, - ButtonGroup, - DialogActionTrigger, - Input, - Text, - VStack, -} from "@chakra-ui/react" -import { useMutation, useQueryClient } from "@tanstack/react-query" -import { useState } from "react" -import { type SubmitHandler, useForm } from "react-hook-form" -import { FaExchangeAlt } from "react-icons/fa" - -import { type ApiError, type ItemPublic, ItemsService } from "@/client" -import useCustomToast from "@/hooks/useCustomToast" -import { handleError } from "@/utils" -import { - DialogBody, - DialogCloseTrigger, - DialogContent, - DialogFooter, - DialogHeader, - DialogRoot, - DialogTitle, - DialogTrigger, -} from "../ui/dialog" -import { Field } from "../ui/field" - -interface EditItemProps { - item: ItemPublic -} - -interface ItemUpdateForm { - title: string - description?: string -} - -const EditItem = ({ item }: EditItemProps) => { - const [isOpen, setIsOpen] = useState(false) - const queryClient = useQueryClient() - const { showSuccessToast } = useCustomToast() - const { - register, - handleSubmit, - reset, - formState: { errors, isSubmitting }, - } = useForm({ - mode: "onBlur", - criteriaMode: "all", - defaultValues: { - ...item, - description: item.description ?? undefined, - }, - }) - - const mutation = useMutation({ - mutationFn: (data: ItemUpdateForm) => - ItemsService.updateItem({ id: item.id, requestBody: data }), - onSuccess: () => { - showSuccessToast("Item updated successfully.") - reset() - setIsOpen(false) - }, - onError: (err: ApiError) => { - handleError(err) - }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["items"] }) - }, - }) - - const onSubmit: SubmitHandler = async (data) => { - mutation.mutate(data) - } - - return ( - setIsOpen(open)} - > - - - - -
- - Edit Item - - - Update the item details below. - - - - - - - - - - - - - - - - - - - -
- -
-
- ) -} - -export default EditItem diff --git a/frontend/src/components/Pending/PendingUsers.tsx b/frontend/src/components/Pending/PendingUsers.tsx deleted file mode 100644 index c7ac1c73ec..0000000000 --- a/frontend/src/components/Pending/PendingUsers.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Table } from "@chakra-ui/react" -import { SkeletonText } from "../ui/skeleton" - -const PendingUsers = () => ( - - - - Full name - Email - Role - Status - Actions - - - - {[...Array(5)].map((_, index) => ( - - - - - - - - - - - - - - - - - - ))} - - -) - -export default PendingUsers diff --git a/frontend/src/components/Projects/ClientAccessList.tsx b/frontend/src/components/Projects/ClientAccessList.tsx new file mode 100644 index 0000000000..a0647a36b1 --- /dev/null +++ b/frontend/src/components/Projects/ClientAccessList.tsx @@ -0,0 +1,137 @@ +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 (now returns array with user data) + const { data: accessList, 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") + } + const data = await response.json() + // Backend now returns array directly + return Array.isArray(data) ? data : [] + }, + }) + + 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... + } + + if (!accessList || !Array.isArray(accessList) || accessList.length === 0) { + return ( + + + Invited Clients + + + No clients invited yet + + + ) + } + + return ( + + + Invited Clients + + + + {accessList.map((access: any) => ( + + + + + + + + {access.user?.full_name || "No name"} + + + {access.user?.email} + + + 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..428033ed0b --- /dev/null +++ b/frontend/src/components/Projects/CreateProject.tsx @@ -0,0 +1,201 @@ +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"] }) + // Also refresh projects list so the new project appears immediately + queryClient.invalidateQueries({ queryKey: ["projects"] }) + 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 + + + +
+ +
+ + + + + +