diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000000..bf278f841e
Binary files /dev/null and b/.DS_Store differ
diff --git a/.env b/.env
deleted file mode 100644
index 1d44286e25..0000000000
--- a/.env
+++ /dev/null
@@ -1,45 +0,0 @@
-# Domain
-# This would be set to the production domain with an env var on deployment
-# used by Traefik to transmit traffic and aqcuire TLS certificates
-DOMAIN=localhost
-# To test the local Traefik config
-# DOMAIN=localhost.tiangolo.com
-
-# Used by the backend to generate links in emails to the frontend
-FRONTEND_HOST=http://localhost:5173
-# In staging and production, set this env var to the frontend host, e.g.
-# FRONTEND_HOST=https://dashboard.example.com
-
-# Environment: local, staging, production
-ENVIRONMENT=local
-
-PROJECT_NAME="Full Stack FastAPI Project"
-STACK_NAME=full-stack-fastapi-project
-
-# Backend
-BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com"
-SECRET_KEY=changethis
-FIRST_SUPERUSER=admin@example.com
-FIRST_SUPERUSER_PASSWORD=changethis
-
-# Emails
-SMTP_HOST=
-SMTP_USER=
-SMTP_PASSWORD=
-EMAILS_FROM_EMAIL=info@example.com
-SMTP_TLS=True
-SMTP_SSL=False
-SMTP_PORT=587
-
-# Postgres
-POSTGRES_SERVER=localhost
-POSTGRES_PORT=5432
-POSTGRES_DB=app
-POSTGRES_USER=postgres
-POSTGRES_PASSWORD=changethis
-
-SENTRY_DSN=
-
-# Configure these with your own Docker registry images
-DOCKER_IMAGE_BACKEND=backend
-DOCKER_IMAGE_FRONTEND=frontend
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000000..5d778a0115
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,138 @@
+name: Deploy to EC2
+
+on:
+ push:
+ branches: [ main, master ]
+ pull_request:
+ branches: [ main, master ]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: test_db
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v4
+ with:
+ enable-cache: true
+
+ - name: Set up Python
+ run: uv python install 3.10
+
+ - name: Install dependencies
+ run: |
+ cd backend
+ uv sync
+
+ - name: Run migrations
+ env:
+ PROJECT_NAME: Mosaic Test
+ POSTGRES_SERVER: localhost
+ POSTGRES_PORT: 5432
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: test_db
+ SECRET_KEY: test-secret-key-for-testing
+ FIRST_SUPERUSER: admin@example.com
+ FIRST_SUPERUSER_PASSWORD: testpassword
+ ENVIRONMENT: local
+ run: |
+ cd backend
+ uv run alembic upgrade head
+
+ - name: Run tests
+ env:
+ PROJECT_NAME: Test Project
+ POSTGRES_SERVER: localhost
+ POSTGRES_PORT: 5432
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: test_db
+ SECRET_KEY: test-secret-key-for-testing
+ FIRST_SUPERUSER: admin@example.com
+ FIRST_SUPERUSER_PASSWORD: testpassword
+ ENVIRONMENT: local
+ run: |
+ cd backend
+ uv run pytest tests/ -v
+
+ deploy:
+ needs: test
+ runs-on: ubuntu-latest
+ if: always() && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup SSH
+ uses: webfactory/ssh-agent@v0.7.0
+ with:
+ ssh-private-key: ${{ secrets.EC2_SSH_KEY }}
+
+ - name: Add EC2 to known hosts
+ run: |
+ ssh-keyscan -H ${{ secrets.EC2_HOST }} >> ~/.ssh/known_hosts
+
+ - name: Deploy to EC2
+ run: |
+ ssh ec2-user@${{ secrets.EC2_HOST }} 'bash -s' << 'ENDSSH'
+ cd mosaic-project-cs4800 || cd mosaic-project-cs4800-main
+ git pull origin main || git pull origin master
+ chmod +x deploy-ip.sh
+ ./deploy-ip.sh ${{ secrets.EC2_HOST }}
+ cat > .env << 'ENVEOF'
+ ENVIRONMENT=production
+ DOMAIN=${{ secrets.EC2_HOST }}
+ PROJECT_NAME=Mosaic Project
+ STACK_NAME=mosaic-project-production
+ BACKEND_CORS_ORIGINS=http://${{ secrets.EC2_HOST }}:5173,http://${{ secrets.EC2_HOST }}:80,http://${{ secrets.EC2_HOST }}
+ FRONTEND_HOST=http://${{ secrets.EC2_HOST }}:5173
+ SECRET_KEY=${{ secrets.SECRET_KEY }}
+ FIRST_SUPERUSER=${{ secrets.FIRST_SUPERUSER }}
+ FIRST_SUPERUSER_PASSWORD=${{ secrets.FIRST_SUPERUSER_PASSWORD }}
+ POSTGRES_SERVER=db
+ POSTGRES_PORT=5432
+ POSTGRES_USER=postgres
+ POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}
+ POSTGRES_DB=app
+ SMTP_HOST=
+ SMTP_USER=
+ SMTP_PASSWORD=
+ EMAILS_FROM_EMAIL=
+ DOCKER_IMAGE_BACKEND=mosaic-backend
+ DOCKER_IMAGE_FRONTEND=mosaic-frontend
+ TAG=latest
+ ENVEOF
+ export DOCKER_BUILDKIT=1
+ export COMPOSE_DOCKER_CLI_BUILD=1
+ docker compose -f docker-compose.production.yml down
+ docker compose -f docker-compose.production.yml up -d --build
+ sleep 30
+ docker compose -f docker-compose.production.yml ps
+ curl -f http://localhost:8000/api/v1/utils/health-check/ || echo "Backend health check failed"
+ ENDSSH
+
+ - name: Verify deployment
+ run: |
+ # Wait a bit for services to fully start
+ sleep 10
+
+ # Test if the application is accessible
+ curl -f http://${{ secrets.EC2_HOST }}:8000/api/v1/utils/health-check/ || echo "Backend not accessible"
+ curl -f http://${{ secrets.EC2_HOST }} || echo "Frontend not accessible"
diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
index 431d6fcf06..849b87bb92 100644
--- a/.github/workflows/playwright.yml
+++ b/.github/workflows/playwright.yml
@@ -41,6 +41,28 @@ jobs:
if: ${{ needs.changes.outputs.changed == 'true' }}
timeout-minutes: 60
runs-on: ubuntu-latest
+ env:
+ DOMAIN: localhost
+ ENVIRONMENT: local
+ FRONTEND_HOST: http://localhost:5173
+ BACKEND_CORS_ORIGINS: http://localhost:5173,http://localhost:8000
+ SECRET_KEY: ${{ secrets.SECRET_KEY }}
+ FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
+ FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
+ POSTGRES_SERVER: db
+ POSTGRES_PORT: 5432
+ POSTGRES_DB: app
+ POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
+ POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
+ SMTP_HOST: ""
+ SMTP_USER: ""
+ SMTP_PASSWORD: ""
+ EMAILS_FROM_EMAIL: noreply@example.com
+ SENTRY_DSN: ""
+ PROJECT_NAME: "Mosaic Project"
+ STACK_NAME: mosaic-project-local
+ DOCKER_IMAGE_BACKEND: backend
+ DOCKER_IMAGE_FRONTEND: frontend
strategy:
matrix:
shardIndex: [1, 2, 3, 4]
@@ -48,6 +70,8 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v5
+ - name: Create .env file
+ run: touch .env
- uses: actions/setup-node@v5
with:
node-version: lts/*
@@ -75,6 +99,7 @@ jobs:
- run: docker compose down -v --remove-orphans
- name: Run Playwright tests
run: docker compose run --rm playwright npx playwright test --fail-on-flaky-tests --trace=retain-on-failure --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
+
- run: docker compose down -v --remove-orphans
- name: Upload blob report to GitHub Actions Artifacts
if: ${{ !cancelled() }}
diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml
index 8084d04bbc..7e7943c87f 100644
--- a/.github/workflows/test-backend.yml
+++ b/.github/workflows/test-backend.yml
@@ -12,9 +12,33 @@ on:
jobs:
test-backend:
runs-on: ubuntu-latest
+ env:
+ DOMAIN: localhost
+ ENVIRONMENT: local
+ FRONTEND_HOST: http://localhost:5173
+ BACKEND_CORS_ORIGINS: http://localhost:5173,http://localhost:8000
+ SECRET_KEY: ${{ secrets.SECRET_KEY }}
+ FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
+ FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
+ POSTGRES_SERVER: localhost
+ POSTGRES_PORT: 5432
+ POSTGRES_DB: app
+ POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
+ POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
+ SMTP_HOST: ""
+ SMTP_USER: ""
+ SMTP_PASSWORD: ""
+ EMAILS_FROM_EMAIL: noreply@example.com
+ SENTRY_DSN: ""
+ PROJECT_NAME: "Mosaic Project"
+ STACK_NAME: mosaic-project-local
+ DOCKER_IMAGE_BACKEND: backend
+ DOCKER_IMAGE_FRONTEND: frontend
steps:
- name: Checkout
uses: actions/checkout@v5
+ - name: Create .env file
+ run: touch .env
- name: Set up Python
uses: actions/setup-python@v6
with:
diff --git a/.github/workflows/test-docker-compose.yml b/.github/workflows/test-docker-compose.yml
index c14d9dd630..ed996feda9 100644
--- a/.github/workflows/test-docker-compose.yml
+++ b/.github/workflows/test-docker-compose.yml
@@ -13,9 +13,33 @@ jobs:
test-docker-compose:
runs-on: ubuntu-latest
+ env:
+ DOMAIN: localhost
+ ENVIRONMENT: local
+ FRONTEND_HOST: http://localhost:5173
+ BACKEND_CORS_ORIGINS: http://localhost:5173,http://localhost:8000
+ SECRET_KEY: ${{ secrets.SECRET_KEY }}
+ FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
+ FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
+ POSTGRES_SERVER: db
+ POSTGRES_PORT: 5432
+ POSTGRES_DB: app
+ POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
+ POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
+ SMTP_HOST: ""
+ SMTP_USER: ""
+ SMTP_PASSWORD: ""
+ EMAILS_FROM_EMAIL: noreply@example.com
+ SENTRY_DSN: ""
+ PROJECT_NAME: "Mosaic Project"
+ STACK_NAME: mosaic-project-local
+ DOCKER_IMAGE_BACKEND: backend
+ DOCKER_IMAGE_FRONTEND: frontend
steps:
- name: Checkout
uses: actions/checkout@v5
+ - name: Create .env file
+ run: touch .env
- run: docker compose build
- run: docker compose down -v --remove-orphans
- run: docker compose up -d --wait backend frontend adminer
diff --git a/.gitignore b/.gitignore
index a6dd346572..3681abd707 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,15 @@
+.env
.vscode
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
+
+# OpenAPI generated file
+frontend/openapi.json
+
+# Editor backup files
+*~
+*.swp
+*.swo
\ No newline at end of file
diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md
new file mode 100644
index 0000000000..d04433b194
--- /dev/null
+++ b/DEPLOYMENT_GUIDE.md
@@ -0,0 +1,129 @@
+# AWS EC2 Deployment Guide (IP-based)
+
+This guide will help you deploy your FastAPI project to AWS EC2 using the public IP address.
+
+## Prerequisites
+
+1. AWS EC2 instance running Amazon Linux 2023
+2. Security group configured to allow:
+ - SSH (port 22)
+ - HTTP (port 80)
+ - Backend API (port 8000)
+ - Adminer (port 8080)
+ - Database (port 5432) - optional for external access
+
+## Step 1: Set up EC2 Instance
+
+Connect to your EC2 instance and run these commands:
+
+```bash
+# Update the system
+sudo yum update -y
+
+# Install Docker
+sudo yum install -y docker
+sudo systemctl start docker
+sudo systemctl enable docker
+sudo usermod -a -G docker ec2-user
+
+# Install Docker Compose
+sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
+sudo chmod +x /usr/local/bin/docker-compose
+
+# Logout and login again to apply docker group changes
+exit
+```
+
+## Step 2: Upload Project Files
+
+Upload your project files to the EC2 instance. You can use:
+
+```bash
+# From your local machine, upload the project
+scp -r -i your-key.pem . ec2-user@YOUR_EC2_IP:/home/ec2-user/mosaic-project/
+```
+
+Or clone from Git if your project is in a repository:
+
+```bash
+# On the EC2 instance
+git clone YOUR_REPOSITORY_URL
+cd mosaic-project-cs4800
+```
+
+## Step 3: Deploy the Application
+
+1. **Run the deployment script** (replace YOUR_EC2_IP with your actual IP):
+
+```bash
+./deploy-ip.sh YOUR_EC2_IP
+```
+
+2. **Start the services**:
+
+```bash
+docker compose -f docker-compose.production.yml up -d
+```
+
+## Step 4: Verify Deployment
+
+Your application will be available at:
+
+- **Frontend**: `http://YOUR_EC2_IP`
+- **Backend API**: `http://YOUR_EC2_IP:8000`
+- **API Documentation**: `http://YOUR_EC2_IP:8000/docs`
+- **Adminer (Database UI)**: `http://YOUR_EC2_IP:8080`
+
+## Step 5: Access the Application
+
+1. **Create your first admin user**:
+ - Go to `http://YOUR_EC2_IP:8000/docs`
+ - Use the `/api/v1/users/` endpoint to create a user
+ - Or use the frontend registration
+
+2. **Login and start using the application**:
+ - Frontend: `http://YOUR_EC2_IP`
+ - API docs: `http://YOUR_EC2_IP:8000/docs`
+
+## Troubleshooting
+
+### Check if services are running:
+```bash
+docker compose -f docker-compose.production.yml ps
+```
+
+### View logs:
+```bash
+# All services
+docker compose -f docker-compose.production.yml logs
+
+# Specific service
+docker compose -f docker-compose.production.yml logs backend
+docker compose -f docker-compose.production.yml logs frontend
+```
+
+### Restart services:
+```bash
+docker compose -f docker-compose.production.yml restart
+```
+
+### Stop services:
+```bash
+docker compose -f docker-compose.production.yml down
+```
+
+## Security Notes
+
+- The deployment uses HTTP (not HTTPS) since we're using IP addresses
+- Database is accessible on port 5432 - consider restricting this in production
+- Adminer is accessible on port 8080 - consider restricting this in production
+- All passwords are generated securely using Python's secrets module
+
+## Environment Variables
+
+The deployment script automatically generates:
+- `SECRET_KEY`: Secure random key for JWT tokens
+- `FIRST_SUPERUSER_PASSWORD`: Secure random password for admin user
+- `POSTGRES_PASSWORD`: Secure random password for database
+
+You can modify these in the `.env` file if needed.
diff --git a/README.md b/README.md
index afe124f3fb..e306da02f8 100644
--- a/README.md
+++ b/README.md
@@ -1,239 +1 @@
-# Full Stack FastAPI Template
-
-
-
-
-## 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
-
-[](https://github.com/fastapi/full-stack-fastapi-template)
-
-### Dashboard - Admin
-
-[](https://github.com/fastapi/full-stack-fastapi-template)
-
-### Dashboard - Create User
-
-[](https://github.com/fastapi/full-stack-fastapi-template)
-
-### Dashboard - Items
-
-[](https://github.com/fastapi/full-stack-fastapi-template)
-
-### Dashboard - User Settings
-
-[](https://github.com/fastapi/full-stack-fastapi-template)
-
-### Dashboard - Dark Mode
-
-[](https://github.com/fastapi/full-stack-fastapi-template)
-
-### Interactive API Documentation
-
-[](https://github.com/fastapi/full-stack-fastapi-template)
-
-## How To Use It
-
-You can **just fork or clone** this repository and use it as is.
-
-โจ It just works. โจ
-
-### How to Use a Private Repository
-
-If you want to have a private repository, GitHub won't allow you to simply fork it as it doesn't allow changing the visibility of forks.
-
-But you can do the following:
-
-- Create a new GitHub repo, for example `my-full-stack`.
-- Clone this repository manually, set the name with the name of the project you want to use, for example `my-full-stack`:
-
-```bash
-git clone git@github.com:fastapi/full-stack-fastapi-template.git my-full-stack
-```
-
-- Enter into the new directory:
-
-```bash
-cd my-full-stack
-```
-
-- Set the new origin to your new repository, copy it from the GitHub interface, for example:
-
-```bash
-git remote set-url origin git@github.com:octocat/my-full-stack.git
-```
-
-- Add this repo as another "remote" to allow you to get updates later:
-
-```bash
-git remote add upstream git@github.com:fastapi/full-stack-fastapi-template.git
-```
-
-- Push the code to your new repository:
-
-```bash
-git push -u origin master
-```
-
-### Update From the Original Template
-
-After cloning the repository, and after doing changes, you might want to get the latest changes from this original template.
-
-- Make sure you added the original repository as a remote, you can check it with:
-
-```bash
-git remote -v
-
-origin git@github.com:octocat/my-full-stack.git (fetch)
-origin git@github.com:octocat/my-full-stack.git (push)
-upstream git@github.com:fastapi/full-stack-fastapi-template.git (fetch)
-upstream git@github.com:fastapi/full-stack-fastapi-template.git (push)
-```
-
-- Pull the latest changes without merging:
-
-```bash
-git pull --no-commit upstream master
-```
-
-This will download the latest changes from this template without committing them, that way you can check everything is right before committing.
-
-- If there are conflicts, solve them in your editor.
-
-- Once you are done, commit the changes:
-
-```bash
-git merge --continue
-```
-
-### Configure
-
-You can then update configs in the `.env` files to customize your configurations.
-
-Before deploying it, make sure you change at least the values for:
-
-- `SECRET_KEY`
-- `FIRST_SUPERUSER_PASSWORD`
-- `POSTGRES_PASSWORD`
-
-You can (and should) pass these as environment variables from secrets.
-
-Read the [deployment.md](./deployment.md) docs for more details.
-
-### Generate Secret Keys
-
-Some environment variables in the `.env` file have a default value of `changethis`.
-
-You have to change them with a secret key, to generate secret keys you can run the following command:
-
-```bash
-python -c "import secrets; print(secrets.token_urlsafe(32))"
-```
-
-Copy the content and use that as password / secret key. And run that again to generate another secure key.
-
-## How To Use It - Alternative With Copier
-
-This repository also supports generating a new project using [Copier](https://copier.readthedocs.io).
-
-It will copy all the files, ask you configuration questions, and update the `.env` files with your answers.
-
-### Install Copier
-
-You can install Copier with:
-
-```bash
-pip install copier
-```
-
-Or better, if you have [`pipx`](https://pipx.pypa.io/), you can run it with:
-
-```bash
-pipx install copier
-```
-
-**Note**: If you have `pipx`, installing copier is optional, you could run it directly.
-
-### Generate a Project With Copier
-
-Decide a name for your new project's directory, you will use it below. For example, `my-awesome-project`.
-
-Go to the directory that will be the parent of your project, and run the command with your project's name:
-
-```bash
-copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust
-```
-
-If you have `pipx` and you didn't install `copier`, you can run it directly:
-
-```bash
-pipx run copier copy https://github.com/fastapi/full-stack-fastapi-template my-awesome-project --trust
-```
-
-**Note** the `--trust` option is necessary to be able to execute a [post-creation script](https://github.com/fastapi/full-stack-fastapi-template/blob/master/.copier/update_dotenv.py) that updates your `.env` files.
-
-### Input Variables
-
-Copier will ask you for some data, you might want to have at hand before generating the project.
-
-But don't worry, you can just update any of that in the `.env` files afterwards.
-
-The input variables, with their default values (some auto generated) are:
-
-- `project_name`: (default: `"FastAPI Project"`) The name of the project, shown to API users (in .env).
-- `stack_name`: (default: `"fastapi-project"`) The name of the stack used for Docker Compose labels and project name (no spaces, no periods) (in .env).
-- `secret_key`: (default: `"changethis"`) The secret key for the project, used for security, stored in .env, you can generate one with the method above.
-- `first_superuser`: (default: `"admin@example.com"`) The email of the first superuser (in .env).
-- `first_superuser_password`: (default: `"changethis"`) The password of the first superuser (in .env).
-- `smtp_host`: (default: "") The SMTP server host to send emails, you can set it later in .env.
-- `smtp_user`: (default: "") The SMTP server user to send emails, you can set it later in .env.
-- `smtp_password`: (default: "") The SMTP server password to send emails, you can set it later in .env.
-- `emails_from_email`: (default: `"info@example.com"`) The email account to send emails from, you can set it later in .env.
-- `postgres_password`: (default: `"changethis"`) The password for the PostgreSQL database, stored in .env, you can generate one with the method above.
-- `sentry_dsn`: (default: "") The DSN for Sentry, if you are using it, you can set it later in .env.
-
-## Backend Development
-
-Backend docs: [backend/README.md](./backend/README.md).
-
-## Frontend Development
-
-Frontend docs: [frontend/README.md](./frontend/README.md).
-
-## Deployment
-
-Deployment docs: [deployment.md](./deployment.md).
-
-## Development
-
-General development docs: [development.md](./development.md).
-
-This includes using Docker Compose, custom local domains, `.env` configurations, etc.
-
-## Release Notes
-
-Check the file [release-notes.md](./release-notes.md).
-
-## License
-
-The Full Stack FastAPI Template is licensed under the terms of the MIT license.
+Mosaic, a project for CS4800 Software Engineering.
\ No newline at end of file
diff --git a/a3_test_api/arthur_test_main.py b/a3_test_api/arthur_test_main.py
new file mode 100644
index 0000000000..6d077c3b00
--- /dev/null
+++ b/a3_test_api/arthur_test_main.py
@@ -0,0 +1,14 @@
+# Arthur Nguyen
+# Assignment 3, Exercise 3
+# test_main.py
+from fastapi import FastAPI
+
+app = FastAPI()
+
+@app.get("/")
+def read_root():
+ return {"message": "Hello from your test API!"}
+
+@app.get("/ping")
+def ping():
+ return {"status": "ok"}
\ No newline at end of file
diff --git a/a3_test_api/nathan_test_api b/a3_test_api/nathan_test_api
new file mode 100644
index 0000000000..690ad79b73
--- /dev/null
+++ b/a3_test_api/nathan_test_api
@@ -0,0 +1,12 @@
+from fastapi import FastAPI
+import time
+app = FastAPI()
+
+
+@app.get("/")
+async def root():
+ return {"message": "Hello World"}
+
+@app.get("/nathan_ping"):
+async def nathan_ping():
+ return {"message": f"Nathan says hi to you at {time.ctime()}"}
\ No newline at end of file
diff --git a/backend/app/alembic/versions/2025010801_add_organizations_projects_galleries.py b/backend/app/alembic/versions/2025010801_add_organizations_projects_galleries.py
new file mode 100644
index 0000000000..3a306f79f8
--- /dev/null
+++ b/backend/app/alembic/versions/2025010801_add_organizations_projects_galleries.py
@@ -0,0 +1,89 @@
+"""Add organizations, projects, and galleries
+
+Revision ID: 2025010801
+Revises: 1a31ce608336
+Create Date: 2025-01-08 00:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel.sql.sqltypes
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = '2025010801'
+down_revision = '1a31ce608336'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+
+ # Create organization table
+ op.create_table(
+ 'organization',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
+ sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=1000), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+
+ # Create project table
+ op.create_table(
+ 'project',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
+ sa.Column('client_name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
+ sa.Column('client_email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column('description', sqlmodel.sql.sqltypes.AutoString(length=2000), nullable=True),
+ sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False),
+ sa.Column('deadline', sa.Date(), nullable=True),
+ sa.Column('start_date', sa.Date(), nullable=True),
+ sa.Column('budget', sqlmodel.sql.sqltypes.AutoString(length=100), nullable=True),
+ sa.Column('progress', sa.Integer(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.Column('organization_id', sa.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(['organization_id'], ['organization.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+
+ # Create gallery table
+ op.create_table(
+ 'gallery',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('name', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
+ sa.Column('date', sa.Date(), nullable=True),
+ sa.Column('photo_count', sa.Integer(), nullable=False),
+ sa.Column('photographer', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=True),
+ sa.Column('status', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False),
+ sa.Column('cover_image_url', sqlmodel.sql.sqltypes.AutoString(length=500), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('project_id', sa.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(['project_id'], ['project.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+
+ # Add organization_id to user table
+ op.add_column('user', sa.Column('organization_id', sa.UUID(), nullable=True))
+ op.create_foreign_key(None, 'user', 'organization', ['organization_id'], ['id'])
+
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+
+ # Remove organization_id from user table
+ op.drop_constraint(None, 'user', type_='foreignkey')
+ op.drop_column('user', 'organization_id')
+
+ # Drop tables in reverse order (due to foreign keys)
+ op.drop_table('gallery')
+ op.drop_table('project')
+ op.drop_table('organization')
+
+ # ### end Alembic commands ###
+
diff --git a/backend/app/alembic/versions/2025110201_add_user_type_field.py b/backend/app/alembic/versions/2025110201_add_user_type_field.py
new file mode 100644
index 0000000000..2d042126b3
--- /dev/null
+++ b/backend/app/alembic/versions/2025110201_add_user_type_field.py
@@ -0,0 +1,27 @@
+"""Add user_type field to User table
+
+Revision ID: 2025110201
+Revises: 2025010801
+Create Date: 2025-11-02 00:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel.sql.sqltypes
+
+# revision identifiers, used by Alembic.
+revision = '2025110201'
+down_revision = '2025010801'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # Add user_type column to user table with default value
+ op.add_column('user', sa.Column('user_type', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False, server_default='team_member'))
+
+
+def downgrade():
+ # Remove user_type column from user table
+ op.drop_column('user', 'user_type')
+
diff --git a/backend/app/alembic/versions/2025110301_add_project_access_table.py b/backend/app/alembic/versions/2025110301_add_project_access_table.py
new file mode 100644
index 0000000000..6fad24d4ee
--- /dev/null
+++ b/backend/app/alembic/versions/2025110301_add_project_access_table.py
@@ -0,0 +1,51 @@
+"""Add project_access table for client invitations
+
+Revision ID: 2025110301
+Revises: 2025110201
+Create Date: 2025-11-03 00:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel.sql.sqltypes
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = '2025110301'
+down_revision = '2025110201'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # Create project_access table
+ op.create_table(
+ 'projectaccess',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('role', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False, server_default='viewer'),
+ sa.Column('can_comment', sa.Boolean(), nullable=False, server_default='true'),
+ sa.Column('can_download', sa.Boolean(), nullable=False, server_default='true'),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('project_id', sa.UUID(), nullable=False),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(['project_id'], ['project.id'], ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+
+ # Create index for faster lookups
+ op.create_index('ix_projectaccess_project_id', 'projectaccess', ['project_id'])
+ op.create_index('ix_projectaccess_user_id', 'projectaccess', ['user_id'])
+
+ # Create unique constraint to prevent duplicate access entries
+ op.create_unique_constraint('uq_projectaccess_project_user', 'projectaccess', ['project_id', 'user_id'])
+
+
+def downgrade():
+ # Drop indexes
+ op.drop_index('ix_projectaccess_user_id', 'projectaccess')
+ op.drop_index('ix_projectaccess_project_id', 'projectaccess')
+
+ # Drop table
+ op.drop_table('projectaccess')
+
diff --git a/backend/app/alembic/versions/2025110302_add_organization_invitation_table.py b/backend/app/alembic/versions/2025110302_add_organization_invitation_table.py
new file mode 100644
index 0000000000..21067412e4
--- /dev/null
+++ b/backend/app/alembic/versions/2025110302_add_organization_invitation_table.py
@@ -0,0 +1,36 @@
+"""add organization invitation table
+
+Revision ID: 2025110302
+Revises: 2025110301
+Create Date: 2025-11-03
+
+"""
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = '2025110302'
+down_revision = '2025110301'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table(
+ 'organizationinvitation',
+ sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
+ sa.Column('id', sa.Uuid(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('organization_id', sa.Uuid(), nullable=False),
+ sa.ForeignKeyConstraint(['organization_id'], ['organization.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_organizationinvitation_email'), 'organizationinvitation', ['email'], unique=False)
+
+
+def downgrade():
+ op.drop_index(op.f('ix_organizationinvitation_email'), table_name='organizationinvitation')
+ op.drop_table('organizationinvitation')
+
diff --git a/backend/app/api/main.py b/backend/app/api/main.py
index eac18c8e8f..26c842cd28 100644
--- a/backend/app/api/main.py
+++ b/backend/app/api/main.py
@@ -1,6 +1,17 @@
from fastapi import APIRouter
-from app.api.routes import items, login, private, users, utils
+from app.api.routes import (
+ galleries,
+ invitations,
+ items,
+ login,
+ organizations,
+ private,
+ project_access,
+ projects,
+ users,
+ utils,
+)
from app.core.config import settings
api_router = APIRouter()
@@ -8,6 +19,17 @@
api_router.include_router(users.router)
api_router.include_router(utils.router)
api_router.include_router(items.router)
+api_router.include_router(
+ organizations.router, prefix="/organizations", tags=["organizations"]
+)
+api_router.include_router(projects.router, prefix="/projects", tags=["projects"])
+api_router.include_router(
+ project_access.router, prefix="/projects", tags=["project-access"]
+)
+api_router.include_router(
+ invitations.router, prefix="/invitations", tags=["invitations"]
+)
+api_router.include_router(galleries.router, prefix="/galleries", tags=["galleries"])
if settings.ENVIRONMENT == "local":
diff --git a/backend/app/api/routes/galleries.py b/backend/app/api/routes/galleries.py
new file mode 100644
index 0000000000..68c5b02335
--- /dev/null
+++ b/backend/app/api/routes/galleries.py
@@ -0,0 +1,225 @@
+import uuid
+from typing import Any
+
+from fastapi import APIRouter, HTTPException
+
+from app import crud
+from app.api.deps import CurrentUser, SessionDep
+from app.models import (
+ GalleriesPublic,
+ GalleryCreate,
+ GalleryPublic,
+ GalleryUpdate,
+ Message,
+)
+
+router = APIRouter()
+
+
+@router.get("/", response_model=GalleriesPublic)
+def read_galleries(
+ session: SessionDep,
+ current_user: CurrentUser,
+ project_id: uuid.UUID | None = None,
+ skip: int = 0,
+ limit: int = 100,
+) -> Any:
+ """
+ Retrieve galleries. If project_id is provided, get galleries for that project.
+ Otherwise, get all galleries based on user type:
+ - Team members: all galleries from their organization
+ - Clients: galleries from projects they have access to
+ """
+ user_type = getattr(current_user, "user_type", None)
+
+ if project_id:
+ # Verify user has access to this project
+ project = crud.get_project(session=session, project_id=project_id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Check access based on user type
+ if user_type == "client":
+ # Client must have explicit access
+ if not crud.user_has_project_access(
+ session=session, project_id=project_id, user_id=current_user.id
+ ):
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+ else:
+ # Team member must be in same organization
+ if (
+ not current_user.organization_id
+ or project.organization_id != current_user.organization_id
+ ):
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ galleries = crud.get_galleries_by_project(
+ session=session, project_id=project_id, skip=skip, limit=limit
+ )
+ count = len(galleries) # Simple count for project galleries
+ else:
+ # No specific project - list all accessible galleries
+ if user_type == "client":
+ # Get galleries from all projects the client has access to
+ accessible_projects = crud.get_user_accessible_projects(
+ session=session, user_id=current_user.id, skip=0, limit=1000
+ )
+ project_ids = [p.id for p in accessible_projects]
+
+ # Get galleries for all accessible projects
+ galleries = []
+ for pid in project_ids[skip : skip + limit]:
+ project_galleries = crud.get_galleries_by_project(
+ session=session, project_id=pid, skip=0, limit=100
+ )
+ galleries.extend(project_galleries)
+
+ count = sum(
+ len(
+ crud.get_galleries_by_project(
+ session=session, project_id=pid, skip=0, limit=1000
+ )
+ )
+ for pid in project_ids
+ )
+ else:
+ # Team member - get all galleries from organization
+ if not current_user.organization_id:
+ raise HTTPException(
+ status_code=400, detail="User is not part of an organization"
+ )
+
+ galleries = crud.get_galleries_by_organization(
+ session=session,
+ organization_id=current_user.organization_id,
+ skip=skip,
+ limit=limit,
+ )
+ count = crud.count_galleries_by_organization(
+ session=session, organization_id=current_user.organization_id
+ )
+
+ return GalleriesPublic(data=galleries, count=count)
+
+
+@router.post("/", response_model=GalleryPublic)
+def create_gallery(
+ *, session: SessionDep, current_user: CurrentUser, gallery_in: GalleryCreate
+) -> Any:
+ """
+ Create new gallery. Only team members can create galleries.
+ """
+ user_type = getattr(current_user, "user_type", None)
+
+ # Only team members can create galleries
+ if user_type != "team_member":
+ raise HTTPException(
+ status_code=403, detail="Only team members can create galleries"
+ )
+
+ if not current_user.organization_id:
+ raise HTTPException(
+ status_code=400, detail="User is not part of an organization"
+ )
+
+ # Verify project belongs to user's organization
+ project = crud.get_project(session=session, project_id=gallery_in.project_id)
+ if not project or project.organization_id != current_user.organization_id:
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ gallery = crud.create_gallery(session=session, gallery_in=gallery_in)
+ return gallery
+
+
+@router.get("/{id}", response_model=GalleryPublic)
+def read_gallery(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
+ """
+ Get gallery by ID.
+ """
+ gallery = crud.get_gallery(session=session, gallery_id=id)
+ if not gallery:
+ raise HTTPException(status_code=404, detail="Gallery not found")
+
+ # Check access based on user type
+ user_type = getattr(current_user, "user_type", None)
+ project = crud.get_project(session=session, project_id=gallery.project_id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ if user_type == "client":
+ # Client must have access to the project
+ if not crud.user_has_project_access(
+ session=session, project_id=project.id, user_id=current_user.id
+ ):
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+ else:
+ # Team member must be in same organization
+ if (
+ not current_user.organization_id
+ or project.organization_id != current_user.organization_id
+ ):
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ return gallery
+
+
+@router.put("/{id}", response_model=GalleryPublic)
+def update_gallery(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ id: uuid.UUID,
+ gallery_in: GalleryUpdate,
+) -> Any:
+ """
+ Update a gallery. Only team members can update galleries.
+ """
+ user_type = getattr(current_user, "user_type", None)
+
+ # Only team members can update galleries
+ if user_type != "team_member":
+ raise HTTPException(
+ status_code=403, detail="Only team members can update galleries"
+ )
+
+ gallery = crud.get_gallery(session=session, gallery_id=id)
+ if not gallery:
+ raise HTTPException(status_code=404, detail="Gallery not found")
+
+ # Check if gallery's project belongs to user's organization
+ project = crud.get_project(session=session, project_id=gallery.project_id)
+ if not project or project.organization_id != current_user.organization_id:
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ gallery = crud.update_gallery(
+ session=session, db_gallery=gallery, gallery_in=gallery_in
+ )
+ return gallery
+
+
+@router.delete("/{id}")
+def delete_gallery(
+ session: SessionDep, current_user: CurrentUser, id: uuid.UUID
+) -> Message:
+ """
+ Delete a gallery. Only team members can delete galleries.
+ """
+ user_type = getattr(current_user, "user_type", None)
+
+ # Only team members can delete galleries
+ if user_type != "team_member":
+ raise HTTPException(
+ status_code=403, detail="Only team members can delete galleries"
+ )
+
+ gallery = crud.get_gallery(session=session, gallery_id=id)
+ if not gallery:
+ raise HTTPException(status_code=404, detail="Gallery not found")
+
+ # Check if gallery's project belongs to user's organization
+ project = crud.get_project(session=session, project_id=gallery.project_id)
+ if not project or project.organization_id != current_user.organization_id:
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ crud.delete_gallery(session=session, gallery_id=id)
+ return Message(message="Gallery deleted successfully")
diff --git a/backend/app/api/routes/invitations.py b/backend/app/api/routes/invitations.py
new file mode 100644
index 0000000000..250ff3a408
--- /dev/null
+++ b/backend/app/api/routes/invitations.py
@@ -0,0 +1,104 @@
+import uuid
+from typing import Any
+
+from fastapi import APIRouter, HTTPException
+from sqlmodel import func, select
+
+from app.api.deps import CurrentUser, SessionDep
+from app.models import (
+ OrganizationInvitation,
+ OrganizationInvitationCreate,
+ OrganizationInvitationPublic,
+ OrganizationInvitationsPublic,
+)
+
+router = APIRouter()
+
+
+@router.post("/", response_model=OrganizationInvitationPublic)
+def create_invitation(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ invitation_in: OrganizationInvitationCreate,
+) -> Any:
+ """
+ Create an organization invitation.
+ Team members can invite people to their organization.
+ """
+ if not current_user.organization_id:
+ raise HTTPException(
+ status_code=400,
+ detail="You must be part of an organization to invite others",
+ )
+
+ # Check if invitation already exists
+ statement = select(OrganizationInvitation).where(
+ OrganizationInvitation.email == invitation_in.email,
+ OrganizationInvitation.organization_id == current_user.organization_id,
+ )
+ existing = session.exec(statement).first()
+ if existing:
+ raise HTTPException(
+ status_code=400,
+ detail="An invitation has already been sent to this email",
+ )
+
+ invitation = OrganizationInvitation(
+ email=invitation_in.email,
+ organization_id=current_user.organization_id,
+ )
+ session.add(invitation)
+ session.commit()
+ session.refresh(invitation)
+ return invitation
+
+
+@router.get("/", response_model=OrganizationInvitationsPublic)
+def read_invitations(
+ session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
+) -> Any:
+ """
+ Retrieve invitations for the current user's organization.
+ """
+ if not current_user.organization_id:
+ raise HTTPException(
+ status_code=400,
+ detail="You must be part of an organization",
+ )
+
+ count_statement = (
+ select(func.count())
+ .select_from(OrganizationInvitation)
+ .where(OrganizationInvitation.organization_id == current_user.organization_id)
+ )
+ count = session.exec(count_statement).one()
+
+ statement = (
+ select(OrganizationInvitation)
+ .where(OrganizationInvitation.organization_id == current_user.organization_id)
+ .offset(skip)
+ .limit(limit)
+ )
+ invitations = session.exec(statement).all()
+
+ return OrganizationInvitationsPublic(data=invitations, count=count)
+
+
+@router.delete("/{invitation_id}")
+def delete_invitation(
+ session: SessionDep, current_user: CurrentUser, invitation_id: uuid.UUID
+) -> Any:
+ """
+ Delete an invitation.
+ """
+ invitation = session.get(OrganizationInvitation, invitation_id)
+ if not invitation:
+ raise HTTPException(status_code=404, detail="Invitation not found")
+
+ if invitation.organization_id != current_user.organization_id:
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ session.delete(invitation)
+ session.commit()
+ return {"message": "Invitation deleted"}
diff --git a/backend/app/api/routes/organizations.py b/backend/app/api/routes/organizations.py
new file mode 100644
index 0000000000..c4eadffc9c
--- /dev/null
+++ b/backend/app/api/routes/organizations.py
@@ -0,0 +1,114 @@
+import uuid
+from typing import Any
+
+from fastapi import APIRouter, HTTPException
+
+from app import crud
+from app.api.deps import CurrentUser, SessionDep
+from app.models import (
+ Organization,
+ OrganizationCreate,
+ OrganizationPublic,
+ OrganizationUpdate,
+)
+
+router = APIRouter()
+
+
+@router.post("/", response_model=OrganizationPublic)
+def create_organization(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ organization_in: OrganizationCreate,
+) -> Any:
+ """
+ Create a new organization.
+ Only team members without an organization can create one.
+ """
+ # Check user is a team member
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403,
+ detail="Only team members can create organizations",
+ )
+
+ # Check user doesn't already have an organization
+ if current_user.organization_id:
+ raise HTTPException(
+ status_code=400,
+ detail="You already belong to an organization",
+ )
+
+ # Create the organization
+ organization = crud.create_organization(
+ session=session, organization_in=organization_in
+ )
+
+ # Assign the user to the new organization
+ current_user.organization_id = organization.id
+ session.add(current_user)
+ session.commit()
+ session.refresh(current_user)
+
+ return organization
+
+
+@router.get("/{organization_id}", response_model=OrganizationPublic)
+def read_organization(
+ session: SessionDep, current_user: CurrentUser, organization_id: uuid.UUID
+) -> Any:
+ """
+ Get organization by ID.
+ Users can only view their own organization.
+ """
+ organization = session.get(Organization, organization_id)
+ if not organization:
+ raise HTTPException(status_code=404, detail="Organization not found")
+
+ # Only allow viewing own organization (unless superuser)
+ if (
+ not current_user.is_superuser
+ and current_user.organization_id != organization_id
+ ):
+ raise HTTPException(
+ status_code=403,
+ detail="Not enough permissions",
+ )
+
+ return organization
+
+
+@router.put("/{organization_id}", response_model=OrganizationPublic)
+def update_organization(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ organization_id: uuid.UUID,
+ organization_in: OrganizationUpdate,
+) -> Any:
+ """
+ Update an organization.
+ Only team members from that organization can update it.
+ """
+ organization = session.get(Organization, organization_id)
+ if not organization:
+ raise HTTPException(status_code=404, detail="Organization not found")
+
+ # Only allow updating own organization (unless superuser)
+ if (
+ not current_user.is_superuser
+ and current_user.organization_id != organization_id
+ ):
+ raise HTTPException(
+ status_code=403,
+ detail="Not enough permissions",
+ )
+
+ update_dict = organization_in.model_dump(exclude_unset=True)
+ organization.sqlmodel_update(update_dict)
+ session.add(organization)
+ session.commit()
+ session.refresh(organization)
+
+ return organization
diff --git a/backend/app/api/routes/private.py b/backend/app/api/routes/private.py
index 9f33ef1900..bd44e03b67 100644
--- a/backend/app/api/routes/private.py
+++ b/backend/app/api/routes/private.py
@@ -3,10 +3,10 @@
from fastapi import APIRouter
from pydantic import BaseModel
+from app import crud
from app.api.deps import SessionDep
-from app.core.security import get_password_hash
from app.models import (
- User,
+ UserCreate,
UserPublic,
)
@@ -26,13 +26,11 @@ def create_user(user_in: PrivateUserCreate, session: SessionDep) -> Any:
Create a new user.
"""
- user = User(
+ user_create = UserCreate(
email=user_in.email,
full_name=user_in.full_name,
- hashed_password=get_password_hash(user_in.password),
+ password=user_in.password,
)
- session.add(user)
- session.commit()
-
+ user = crud.create_user(session=session, user_create=user_create)
return user
diff --git a/backend/app/api/routes/project_access.py b/backend/app/api/routes/project_access.py
new file mode 100644
index 0000000000..1e85cca480
--- /dev/null
+++ b/backend/app/api/routes/project_access.py
@@ -0,0 +1,178 @@
+import uuid
+from typing import Any
+
+from fastapi import APIRouter, HTTPException
+
+from app import crud
+from app.api.deps import CurrentUser, SessionDep
+from app.models import (
+ Message,
+ ProjectAccessCreate,
+ ProjectAccessesPublic,
+ ProjectAccessPublic,
+ ProjectAccessUpdate,
+ User,
+)
+
+router = APIRouter()
+
+
+@router.post("/{project_id}/access", response_model=ProjectAccessPublic)
+def grant_project_access(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ project_id: uuid.UUID,
+ user_id: uuid.UUID,
+ role: str = "viewer",
+ can_comment: bool = True,
+ can_download: bool = True,
+) -> Any:
+ """
+ Grant a user access to a project (invite a client).
+ Only team members can invite clients.
+ """
+ # Check if current user is a team member
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403,
+ detail="Only team members can invite clients to projects",
+ )
+
+ # Check if project exists and user has access to it
+ project = crud.get_project(session=session, project_id=project_id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Check if current user's organization owns the project
+ if (
+ not current_user.organization_id
+ or current_user.organization_id != project.organization_id
+ ):
+ raise HTTPException(
+ status_code=403,
+ detail="You don't have permission to manage this project",
+ )
+
+ # Check if user to be invited exists
+ user_to_invite = session.get(User, user_id)
+ if not user_to_invite:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ # Create access
+ access_in = ProjectAccessCreate(
+ project_id=project_id,
+ user_id=user_id,
+ role=role,
+ can_comment=can_comment,
+ can_download=can_download,
+ )
+ access = crud.create_project_access(session=session, access_in=access_in)
+ return access
+
+
+@router.get("/{project_id}/access", response_model=ProjectAccessesPublic)
+def read_project_access_list(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ project_id: uuid.UUID,
+) -> Any:
+ """
+ Get list of users with access to a project.
+ Only team members from the project's organization can see this.
+ """
+ # Check if project exists
+ project = crud.get_project(session=session, project_id=project_id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Check permissions
+ if getattr(current_user, "user_type", None) == "team_member":
+ if current_user.organization_id != project.organization_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+ else:
+ # Clients can only see their own access
+ if not crud.user_has_project_access(
+ session=session, project_id=project_id, user_id=current_user.id
+ ):
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ access_list = crud.get_project_access_list(session=session, project_id=project_id)
+ return ProjectAccessesPublic(data=access_list, count=len(access_list))
+
+
+@router.delete("/{project_id}/access/{user_id}", response_model=Message)
+def revoke_project_access(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ project_id: uuid.UUID,
+ user_id: uuid.UUID,
+) -> Any:
+ """
+ Revoke a user's access to a project.
+ Only team members from the project's organization can do this.
+ """
+ # Check if current user is a team member
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403,
+ detail="Only team members can revoke project access",
+ )
+
+ # Check if project exists
+ project = crud.get_project(session=session, project_id=project_id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Check permissions
+ if current_user.organization_id != project.organization_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ # Revoke access
+ crud.delete_project_access(session=session, project_id=project_id, user_id=user_id)
+ return Message(message="Access revoked successfully")
+
+
+@router.patch("/{project_id}/access/{user_id}", response_model=ProjectAccessPublic)
+def update_project_access_permissions(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ project_id: uuid.UUID,
+ user_id: uuid.UUID,
+ access_in: ProjectAccessUpdate,
+) -> Any:
+ """
+ Update a user's project access permissions.
+ Only team members from the project's organization can do this.
+ """
+ # Check if current user is a team member
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403,
+ detail="Only team members can update project access",
+ )
+
+ # Check if project exists
+ project = crud.get_project(session=session, project_id=project_id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Check permissions
+ if current_user.organization_id != project.organization_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ # Get existing access
+ db_access = crud.get_project_access(
+ session=session, project_id=project_id, user_id=user_id
+ )
+ if not db_access:
+ raise HTTPException(status_code=404, detail="Access not found")
+
+ # Update access
+ access = crud.update_project_access(
+ session=session, db_access=db_access, access_in=access_in
+ )
+ return access
diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py
new file mode 100644
index 0000000000..ed1aef9ad0
--- /dev/null
+++ b/backend/app/api/routes/projects.py
@@ -0,0 +1,194 @@
+import uuid
+from typing import Any
+
+from fastapi import APIRouter, HTTPException
+
+from app import crud
+from app.api.deps import CurrentUser, SessionDep
+from app.models import (
+ DashboardStats,
+ GalleryCreate,
+ Message,
+ ProjectCreate,
+ ProjectPublic,
+ ProjectsPublic,
+ ProjectUpdate,
+)
+
+router = APIRouter()
+
+
+@router.get("/", response_model=ProjectsPublic)
+def read_projects(
+ session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
+) -> Any:
+ """
+ Retrieve projects.
+ - Team members see projects from their organization
+ - Clients see projects they have been invited to
+ """
+ if getattr(current_user, "user_type", None) == "client":
+ # Clients see only projects they have access to
+ projects = crud.get_user_accessible_projects(
+ session=session,
+ user_id=current_user.id,
+ skip=skip,
+ limit=limit,
+ )
+ count = crud.count_user_accessible_projects(
+ session=session, user_id=current_user.id
+ )
+ else:
+ # Team members see projects from their organization
+ if not current_user.organization_id:
+ raise HTTPException(
+ status_code=400, detail="User is not part of an organization"
+ )
+
+ projects = crud.get_projects_by_organization(
+ session=session,
+ organization_id=current_user.organization_id,
+ skip=skip,
+ limit=limit,
+ )
+ count = crud.count_projects_by_organization(
+ session=session, organization_id=current_user.organization_id
+ )
+
+ return ProjectsPublic(data=projects, count=count)
+
+
+@router.get("/stats", response_model=DashboardStats)
+def read_dashboard_stats(session: SessionDep, current_user: CurrentUser) -> Any:
+ """
+ Get dashboard statistics for the current user's organization.
+ Only available to team members.
+ """
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403, detail="Dashboard stats only available to team members"
+ )
+
+ if not current_user.organization_id:
+ raise HTTPException(
+ status_code=400, detail="User is not part of an organization"
+ )
+
+ return crud.get_dashboard_stats(
+ session=session, organization_id=current_user.organization_id
+ )
+
+
+@router.post("/", response_model=ProjectPublic)
+def create_project(
+ *, session: SessionDep, current_user: CurrentUser, project_in: ProjectCreate
+) -> Any:
+ """
+ Create new project.
+ Only team members can create projects.
+ """
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403, detail="Only team members can create projects"
+ )
+
+ if not current_user.organization_id:
+ raise HTTPException(
+ status_code=400, detail="User is not part of an organization"
+ )
+
+ # Ensure the project is being created for the user's organization
+ if project_in.organization_id != current_user.organization_id:
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ project = crud.create_project(session=session, project_in=project_in)
+
+ # Automatically create a gallery for the project
+ gallery_in = GalleryCreate(
+ name=f"{project.name} - Gallery",
+ project_id=project.id,
+ status="draft",
+ )
+ crud.create_gallery(session=session, gallery_in=gallery_in)
+
+ return project
+
+
+@router.get("/{id}", response_model=ProjectPublic)
+def read_project(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
+ """
+ Get project by ID.
+ """
+ project = crud.get_project(session=session, project_id=id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Check permissions based on user type
+ if getattr(current_user, "user_type", None) == "client":
+ # Clients need explicit access
+ if not crud.user_has_project_access(
+ session=session, project_id=id, user_id=current_user.id
+ ):
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+ else:
+ # Team members need organization match
+ if project.organization_id != current_user.organization_id:
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ return project
+
+
+@router.put("/{id}", response_model=ProjectPublic)
+def update_project(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ id: uuid.UUID,
+ project_in: ProjectUpdate,
+) -> Any:
+ """
+ Update a project.
+ Only team members from the project's organization can update projects.
+ """
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403, detail="Only team members can update projects"
+ )
+
+ project = crud.get_project(session=session, project_id=id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Check if project belongs to user's organization
+ if project.organization_id != current_user.organization_id:
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ project = crud.update_project(
+ session=session, db_project=project, project_in=project_in
+ )
+ return project
+
+
+@router.delete("/{id}")
+def delete_project(
+ session: SessionDep, current_user: CurrentUser, id: uuid.UUID
+) -> Message:
+ """
+ Delete a project.
+ Only team members from the project's organization can delete projects.
+ """
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403, detail="Only team members can delete projects"
+ )
+
+ project = crud.get_project(session=session, project_id=id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Check if project belongs to user's organization
+ if project.organization_id != current_user.organization_id:
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ crud.delete_project(session=session, project_id=id)
+ return Message(message="Project deleted successfully")
diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py
index 6429818458..708d4def76 100644
--- a/backend/app/api/routes/users.py
+++ b/backend/app/api/routes/users.py
@@ -48,6 +48,30 @@ def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any:
return UsersPublic(data=users, count=count)
+@router.get("/clients", response_model=UsersPublic)
+def read_clients(
+ session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
+) -> Any:
+ """
+ Retrieve client users. Team members can access this to invite clients.
+ """
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403,
+ detail="Only team members can list clients",
+ )
+
+ count_statement = (
+ select(func.count()).select_from(User).where(User.user_type == "client")
+ )
+ count = session.exec(count_statement).one()
+
+ statement = select(User).where(User.user_type == "client").offset(skip).limit(limit)
+ users = session.exec(statement).all()
+
+ return UsersPublic(data=users, count=count)
+
+
@router.post(
"/", dependencies=[Depends(get_current_active_superuser)], response_model=UserPublic
)
@@ -143,18 +167,169 @@ def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any:
def register_user(session: SessionDep, user_in: UserRegister) -> Any:
"""
Create new user without the need to be logged in.
+ Team members are assigned to an organization only if they were invited.
"""
+ from sqlmodel import select
+
+ from app.models import OrganizationInvitation
+
user = crud.get_user_by_email(session=session, email=user_in.email)
if user:
raise HTTPException(
status_code=400,
detail="The user with this email already exists in the system",
)
+
user_create = UserCreate.model_validate(user_in)
+
+ # Check if there's an invitation for this email (team members only)
+ if user_create.user_type == "team_member":
+ statement = select(OrganizationInvitation).where(
+ OrganizationInvitation.email == user_create.email
+ )
+ invitation = session.exec(statement).first()
+
+ if invitation:
+ # Auto-assign to the invited organization
+ user_create.organization_id = invitation.organization_id
+ # Delete the invitation after use
+ session.delete(invitation)
+ session.commit()
+
user = crud.create_user(session=session, user_create=user_create)
return user
+@router.get("/organization-members", response_model=UsersPublic)
+def get_organization_members(
+ session: SessionDep,
+ current_user: CurrentUser,
+ skip: int = 0,
+ limit: int = 100,
+) -> Any:
+ """
+ Get all members of the current user's organization.
+ Accessible by team members to see their organization members.
+ """
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403, detail="Only team members can view organization members"
+ )
+
+ if not current_user.organization_id:
+ raise HTTPException(
+ status_code=400,
+ detail="You must be part of an organization to view members",
+ )
+
+ count_statement = (
+ select(func.count())
+ .select_from(User)
+ .where(User.organization_id == current_user.organization_id)
+ .where(User.user_type == "team_member")
+ )
+ count = session.exec(count_statement).one()
+
+ statement = (
+ select(User)
+ .where(User.organization_id == current_user.organization_id)
+ .where(User.user_type == "team_member")
+ .offset(skip)
+ .limit(limit)
+ )
+ users = session.exec(statement).all()
+
+ return UsersPublic(data=users, count=count)
+
+
+@router.get("/pending", response_model=UsersPublic)
+def get_pending_users(
+ session: SessionDep,
+ current_user: CurrentUser,
+ skip: int = 0,
+ limit: int = 100,
+) -> Any:
+ """
+ Get users without an organization (pending approval).
+ Accessible by team members to invite people to their organization.
+ """
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403, detail="Only team members can invite users"
+ )
+
+ from sqlmodel import select
+
+ count_statement = (
+ select(func.count())
+ .select_from(User)
+ .where(User.organization_id.is_(None)) # type: ignore[union-attr]
+ .where(User.user_type == "team_member")
+ )
+ count = session.exec(count_statement).one()
+
+ statement = (
+ select(User)
+ .where(User.organization_id.is_(None)) # type: ignore[union-attr]
+ .where(User.user_type == "team_member")
+ .offset(skip)
+ .limit(limit)
+ )
+ users = session.exec(statement).all()
+
+ return UsersPublic(data=users, count=count)
+
+
+@router.patch("/{user_id}/assign-organization", response_model=UserPublic)
+def assign_user_to_organization(
+ user_id: uuid.UUID,
+ session: SessionDep,
+ current_user: CurrentUser,
+ organization_id: uuid.UUID | None = None,
+) -> Any:
+ """
+ Assign a user to an organization.
+ Team members can assign users to their own organization.
+ Superusers can assign to any organization.
+ """
+ if (
+ getattr(current_user, "user_type", None) != "team_member"
+ and not current_user.is_superuser
+ ):
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ user = session.get(User, user_id)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ # Determine which organization to assign to
+ if current_user.is_superuser and organization_id:
+ # Superuser can specify any organization
+ target_org_id = organization_id
+ else:
+ # Team members assign to their own organization
+ if not current_user.organization_id:
+ raise HTTPException(
+ status_code=400,
+ detail="You must be part of an organization to invite others",
+ )
+ target_org_id = current_user.organization_id
+
+ # Verify organization exists
+ from app.models import Organization
+
+ org = session.get(Organization, target_org_id)
+ if not org:
+ raise HTTPException(status_code=404, detail="Organization not found")
+
+ user.organization_id = target_org_id
+ session.add(user)
+ session.commit()
+ session.refresh(user)
+
+ return user
+
+
@router.get("/{user_id}", response_model=UserPublic)
def read_user_by_id(
user_id: uuid.UUID, session: SessionDep, current_user: CurrentUser
diff --git a/backend/app/api/routes/utils.py b/backend/app/api/routes/utils.py
index fc093419b3..c022f2606c 100644
--- a/backend/app/api/routes/utils.py
+++ b/backend/app/api/routes/utils.py
@@ -1,3 +1,8 @@
+import platform
+import sys
+from datetime import datetime
+from typing import Any
+
from fastapi import APIRouter, Depends
from pydantic.networks import EmailStr
@@ -29,3 +34,30 @@ def test_email(email_to: EmailStr) -> Message:
@router.get("/health-check/")
async def health_check() -> bool:
return True
+
+
+@router.get("/system-info/")
+async def get_system_info() -> dict[str, Any]:
+ """
+ Get interesting system information including current time, platform details, and Python version.
+ """
+ return {
+ "message": "System information retrieved successfully",
+ "timestamp": datetime.now().isoformat(),
+ "platform": {
+ "system": platform.system(),
+ "release": platform.release(),
+ "version": platform.version(),
+ "machine": platform.machine(),
+ "processor": platform.processor(),
+ },
+ "python": {
+ "version": sys.version,
+ "version_info": {
+ "major": sys.version_info.major,
+ "minor": sys.version_info.minor,
+ "micro": sys.version_info.micro,
+ },
+ },
+ "fun_fact": "This API endpoint was created as part of CS4800 team project exercise!",
+ }
diff --git a/backend/app/core/db.py b/backend/app/core/db.py
index ba991fb36d..a78956144c 100644
--- a/backend/app/core/db.py
+++ b/backend/app/core/db.py
@@ -2,7 +2,7 @@
from app import crud
from app.core.config import settings
-from app.models import User, UserCreate
+from app.models import OrganizationCreate, User, UserCreate
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
@@ -21,13 +21,25 @@ def init_db(session: Session) -> None:
# This works because the models are already imported and registered from app.models
# SQLModel.metadata.create_all(engine)
+ # Check if superuser exists
user = session.exec(
select(User).where(User.email == settings.FIRST_SUPERUSER)
).first()
+
if not user:
+ # Create the superuser's organization
+ organization_in = OrganizationCreate(
+ name="Admin Organization", description="Organization for admin user"
+ )
+ organization = crud.create_organization(
+ session=session, organization_in=organization_in
+ )
+
+ # Create superuser and assign to their organization
user_in = UserCreate(
email=settings.FIRST_SUPERUSER,
password=settings.FIRST_SUPERUSER_PASSWORD,
is_superuser=True,
+ organization_id=organization.id,
)
user = crud.create_user(session=session, user_create=user_in)
diff --git a/backend/app/crud.py b/backend/app/crud.py
index 905bf48724..9de6a84d6a 100644
--- a/backend/app/crud.py
+++ b/backend/app/crud.py
@@ -1,10 +1,30 @@
import uuid
+from datetime import datetime, timedelta
from typing import Any
-from sqlmodel import Session, select
+from sqlmodel import Session, desc, func, or_, select
from app.core.security import get_password_hash, verify_password
-from app.models import Item, ItemCreate, User, UserCreate, UserUpdate
+from app.models import (
+ DashboardStats,
+ Gallery,
+ GalleryCreate,
+ GalleryUpdate,
+ Item,
+ ItemCreate,
+ Organization,
+ OrganizationCreate,
+ OrganizationUpdate,
+ Project,
+ ProjectAccess,
+ ProjectAccessCreate,
+ ProjectAccessUpdate,
+ ProjectCreate,
+ ProjectUpdate,
+ User,
+ UserCreate,
+ UserUpdate,
+)
def create_user(*, session: Session, user_create: UserCreate) -> User:
@@ -52,3 +72,362 @@ def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -
session.commit()
session.refresh(db_item)
return db_item
+
+
+# ============================================================================
+# ORGANIZATION CRUD
+# ============================================================================
+
+
+def create_organization(
+ *, session: Session, organization_in: OrganizationCreate
+) -> Organization:
+ db_obj = Organization.model_validate(organization_in)
+ session.add(db_obj)
+ session.commit()
+ session.refresh(db_obj)
+ return db_obj
+
+
+def get_organization(
+ *, session: Session, organization_id: uuid.UUID
+) -> Organization | None:
+ return session.get(Organization, organization_id)
+
+
+def get_default_organization(*, session: Session) -> Organization | None:
+ """Get the default organization (typically 'Default Organization')"""
+ statement = select(Organization).where(Organization.name == "Default Organization")
+ return session.exec(statement).first()
+
+
+def update_organization(
+ *,
+ session: Session,
+ db_organization: Organization,
+ organization_in: OrganizationUpdate,
+) -> Organization:
+ organization_data = organization_in.model_dump(exclude_unset=True)
+ db_organization.sqlmodel_update(organization_data)
+ session.add(db_organization)
+ session.commit()
+ session.refresh(db_organization)
+ return db_organization
+
+
+# ============================================================================
+# PROJECT CRUD
+# ============================================================================
+
+
+def create_project(*, session: Session, project_in: ProjectCreate) -> Project:
+ db_obj = Project.model_validate(project_in)
+ session.add(db_obj)
+ session.commit()
+ session.refresh(db_obj)
+ return db_obj
+
+
+def get_project(*, session: Session, project_id: uuid.UUID) -> Project | None:
+ return session.get(Project, project_id)
+
+
+def get_projects_by_organization(
+ *, session: Session, organization_id: uuid.UUID, skip: int = 0, limit: int = 100
+) -> list[Project]:
+ statement = (
+ select(Project)
+ .where(Project.organization_id == organization_id)
+ .offset(skip)
+ .limit(limit)
+ .order_by(desc(Project.created_at))
+ )
+ return list(session.exec(statement).all())
+
+
+def count_projects_by_organization(
+ *, session: Session, organization_id: uuid.UUID
+) -> int:
+ statement = (
+ select(func.count())
+ .select_from(Project)
+ .where(Project.organization_id == organization_id)
+ )
+ return session.exec(statement).one()
+
+
+def update_project(
+ *, session: Session, db_project: Project, project_in: ProjectUpdate
+) -> Project:
+ project_data = project_in.model_dump(exclude_unset=True)
+ project_data["updated_at"] = datetime.utcnow()
+ db_project.sqlmodel_update(project_data)
+ session.add(db_project)
+ session.commit()
+ session.refresh(db_project)
+ return db_project
+
+
+def delete_project(*, session: Session, project_id: uuid.UUID) -> None:
+ project = session.get(Project, project_id)
+ if project:
+ session.delete(project)
+ session.commit()
+
+
+# ============================================================================
+# GALLERY CRUD
+# ============================================================================
+
+
+def create_gallery(*, session: Session, gallery_in: GalleryCreate) -> Gallery:
+ db_obj = Gallery.model_validate(gallery_in)
+ session.add(db_obj)
+ session.commit()
+ session.refresh(db_obj)
+ return db_obj
+
+
+def get_gallery(*, session: Session, gallery_id: uuid.UUID) -> Gallery | None:
+ return session.get(Gallery, gallery_id)
+
+
+def get_galleries_by_project(
+ *, session: Session, project_id: uuid.UUID, skip: int = 0, limit: int = 100
+) -> list[Gallery]:
+ statement = (
+ select(Gallery)
+ .where(Gallery.project_id == project_id)
+ .offset(skip)
+ .limit(limit)
+ .order_by(desc(Gallery.created_at))
+ )
+ return list(session.exec(statement).all())
+
+
+def get_galleries_by_organization(
+ *, session: Session, organization_id: uuid.UUID, skip: int = 0, limit: int = 100
+) -> list[Gallery]:
+ """Get all galleries for projects in an organization"""
+ statement = (
+ select(Gallery)
+ .join(Project)
+ .where(Project.organization_id == organization_id)
+ .offset(skip)
+ .limit(limit)
+ .order_by(desc(Gallery.created_at))
+ )
+ return list(session.exec(statement).all())
+
+
+def count_galleries_by_organization(
+ *, session: Session, organization_id: uuid.UUID
+) -> int:
+ statement = (
+ select(func.count())
+ .select_from(Gallery)
+ .join(Project)
+ .where(Project.organization_id == organization_id)
+ )
+ return session.exec(statement).one()
+
+
+def update_gallery(
+ *, session: Session, db_gallery: Gallery, gallery_in: GalleryUpdate
+) -> Gallery:
+ gallery_data = gallery_in.model_dump(exclude_unset=True)
+ db_gallery.sqlmodel_update(gallery_data)
+ session.add(db_gallery)
+ session.commit()
+ session.refresh(db_gallery)
+ return db_gallery
+
+
+def delete_gallery(*, session: Session, gallery_id: uuid.UUID) -> None:
+ gallery = session.get(Gallery, gallery_id)
+ if gallery:
+ session.delete(gallery)
+ session.commit()
+
+
+# ============================================================================
+# PROJECT ACCESS CRUD
+# ============================================================================
+
+
+def create_project_access(
+ *, session: Session, access_in: ProjectAccessCreate
+) -> ProjectAccess:
+ """Grant a user access to a project"""
+ # Check if access already exists
+ existing = session.exec(
+ select(ProjectAccess).where(
+ ProjectAccess.project_id == access_in.project_id,
+ ProjectAccess.user_id == access_in.user_id,
+ )
+ ).first()
+
+ if existing:
+ # Update existing access
+ for key, value in access_in.model_dump(exclude_unset=True).items():
+ setattr(existing, key, value)
+ session.add(existing)
+ session.commit()
+ session.refresh(existing)
+ return existing
+
+ # Create new access
+ db_obj = ProjectAccess.model_validate(access_in)
+ session.add(db_obj)
+ session.commit()
+ session.refresh(db_obj)
+ return db_obj
+
+
+def get_project_access(
+ *, session: Session, project_id: uuid.UUID, user_id: uuid.UUID
+) -> ProjectAccess | None:
+ """Get a user's access to a specific project"""
+ statement = select(ProjectAccess).where(
+ ProjectAccess.project_id == project_id,
+ ProjectAccess.user_id == user_id,
+ )
+ return session.exec(statement).first()
+
+
+def get_project_access_list(
+ *, session: Session, project_id: uuid.UUID
+) -> list[ProjectAccess]:
+ """Get all users with access to a project"""
+ statement = select(ProjectAccess).where(ProjectAccess.project_id == project_id)
+ return list(session.exec(statement).all())
+
+
+def get_user_accessible_projects(
+ *, session: Session, user_id: uuid.UUID, skip: int = 0, limit: int = 100
+) -> list[Project]:
+ """Get all projects a user has access to (for clients)"""
+ statement = (
+ select(Project)
+ .join(ProjectAccess)
+ .where(ProjectAccess.user_id == user_id)
+ .offset(skip)
+ .limit(limit)
+ .order_by(desc(Project.created_at))
+ )
+ return list(session.exec(statement).all())
+
+
+def count_user_accessible_projects(*, session: Session, user_id: uuid.UUID) -> int:
+ """Count projects a user has access to"""
+ statement = (
+ select(func.count())
+ .select_from(Project)
+ .join(ProjectAccess)
+ .where(ProjectAccess.user_id == user_id)
+ )
+ return session.exec(statement).one()
+
+
+def update_project_access(
+ *, session: Session, db_access: ProjectAccess, access_in: ProjectAccessUpdate
+) -> ProjectAccess:
+ """Update project access permissions"""
+ access_data = access_in.model_dump(exclude_unset=True)
+ db_access.sqlmodel_update(access_data)
+ session.add(db_access)
+ session.commit()
+ session.refresh(db_access)
+ return db_access
+
+
+def delete_project_access(
+ *, session: Session, project_id: uuid.UUID, user_id: uuid.UUID
+) -> None:
+ """Remove a user's access to a project"""
+ access = get_project_access(session=session, project_id=project_id, user_id=user_id)
+ if access:
+ session.delete(access)
+ session.commit()
+
+
+def user_has_project_access(
+ *, session: Session, project_id: uuid.UUID, user_id: uuid.UUID
+) -> bool:
+ """Check if a user has access to a project"""
+ statement = (
+ select(func.count())
+ .select_from(ProjectAccess)
+ .where(
+ ProjectAccess.project_id == project_id,
+ ProjectAccess.user_id == user_id,
+ )
+ )
+ count = session.exec(statement).one()
+ return count > 0
+
+
+# ============================================================================
+# DASHBOARD STATS
+# ============================================================================
+
+
+def get_dashboard_stats(
+ *, session: Session, organization_id: uuid.UUID
+) -> DashboardStats:
+ """Calculate dashboard statistics for an organization"""
+
+ # Count active projects (in_progress or review status)
+ active_projects_stmt = (
+ select(func.count())
+ .select_from(Project)
+ .where(
+ Project.organization_id == organization_id,
+ or_(Project.status == "in_progress", Project.status == "review"),
+ )
+ )
+ active_projects = session.exec(active_projects_stmt).one()
+
+ # Count upcoming deadlines (projects with deadline in next 14 days, not completed)
+ today = datetime.utcnow().date()
+ two_weeks = today + timedelta(days=14)
+ upcoming_deadlines_stmt = (
+ select(func.count())
+ .select_from(Project)
+ .where(
+ Project.organization_id == organization_id,
+ Project.deadline.isnot(None), # type: ignore[union-attr]
+ Project.deadline >= today, # type: ignore[operator]
+ Project.deadline <= two_weeks, # type: ignore[operator]
+ Project.status != "completed",
+ )
+ )
+ upcoming_deadlines = session.exec(upcoming_deadlines_stmt).one()
+
+ # Count team members in organization
+ team_members_stmt = (
+ select(func.count())
+ .select_from(User)
+ .where(User.organization_id == organization_id)
+ )
+ team_members = session.exec(team_members_stmt).one()
+
+ # Count completed projects this month
+ first_day_of_month = today.replace(day=1)
+ completed_this_month_stmt = (
+ select(func.count())
+ .select_from(Project)
+ .where(
+ Project.organization_id == organization_id,
+ Project.status == "completed",
+ Project.updated_at >= first_day_of_month,
+ )
+ )
+ completed_this_month = session.exec(completed_this_month_stmt).one()
+
+ return DashboardStats(
+ active_projects=active_projects,
+ upcoming_deadlines=upcoming_deadlines,
+ team_members=team_members,
+ completed_this_month=completed_this_month,
+ )
diff --git a/backend/app/main.py b/backend/app/main.py
index 9a95801e74..b25f93b2a8 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -8,7 +8,9 @@
def custom_generate_unique_id(route: APIRoute) -> str:
- return f"{route.tags[0]}-{route.name}"
+ if route.tags:
+ return f"{route.tags[0]}-{route.name}"
+ return route.name
if settings.SENTRY_DSN and settings.ENVIRONMENT != "local":
@@ -18,6 +20,7 @@ def custom_generate_unique_id(route: APIRoute) -> str:
title=settings.PROJECT_NAME,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
generate_unique_id_function=custom_generate_unique_id,
+ redirect_slashes=False,
)
# Set all CORS enabled origins
diff --git a/backend/app/models.py b/backend/app/models.py
index 2389b4a532..7383fd05ca 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -1,4 +1,7 @@
import uuid
+from datetime import date as DateType
+from datetime import datetime
+from typing import Optional
from pydantic import EmailStr
from sqlmodel import Field, Relationship, SQLModel
@@ -10,6 +13,8 @@ class UserBase(SQLModel):
is_active: bool = True
is_superuser: bool = False
full_name: str | None = Field(default=None, max_length=255)
+ user_type: str = Field(default="team_member", max_length=50)
+ organization_id: uuid.UUID | None = Field(default=None)
# Properties to receive via API on creation
@@ -21,6 +26,7 @@ class UserRegister(SQLModel):
email: EmailStr = Field(max_length=255)
password: str = Field(min_length=8, max_length=40)
full_name: str | None = Field(default=None, max_length=255)
+ user_type: str = Field(default="team_member", max_length=50)
# Properties to receive via API on update, all are optional
@@ -44,6 +50,13 @@ class User(UserBase, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
hashed_password: str
items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True)
+ organization_id: uuid.UUID | None = Field(
+ default=None, foreign_key="organization.id"
+ )
+ organization: Optional["Organization"] = Relationship(back_populates="users")
+ project_access: list["ProjectAccess"] = Relationship(
+ back_populates="user", cascade_delete=True
+ )
# Properties to return via API, id is always required
@@ -111,3 +124,247 @@ class TokenPayload(SQLModel):
class NewPassword(SQLModel):
token: str
new_password: str = Field(min_length=8, max_length=40)
+
+
+# ============================================================================
+# ORGANIZATION MODELS
+# ============================================================================
+
+
+class OrganizationBase(SQLModel):
+ name: str = Field(min_length=1, max_length=255)
+ description: str | None = Field(default=None, max_length=1000)
+
+
+class OrganizationCreate(OrganizationBase):
+ pass
+
+
+class OrganizationUpdate(SQLModel):
+ name: str | None = Field(default=None, min_length=1, max_length=255)
+ description: str | None = Field(default=None, max_length=1000)
+
+
+class Organization(OrganizationBase, table=True):
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
+ created_at: datetime = Field(default_factory=datetime.utcnow)
+ users: list["User"] = Relationship(back_populates="organization")
+ projects: list["Project"] = Relationship(
+ back_populates="organization", cascade_delete=True
+ )
+
+
+class OrganizationPublic(OrganizationBase):
+ id: uuid.UUID
+ created_at: datetime
+
+
+class OrganizationsPublic(SQLModel):
+ data: list[OrganizationPublic]
+ count: int
+
+
+# ============================================================================
+# PROJECT MODELS
+# ============================================================================
+
+
+class ProjectBase(SQLModel):
+ name: str = Field(min_length=1, max_length=255)
+ client_name: str = Field(min_length=1, max_length=255)
+ client_email: str | None = Field(default=None, max_length=255)
+ description: str | None = Field(default=None, max_length=2000)
+ status: str = Field(
+ default="planning", max_length=50
+ ) # planning, in_progress, review, completed
+ deadline: DateType | None = None
+ start_date: DateType | None = None
+ budget: str | None = Field(default=None, max_length=100)
+ progress: int = Field(default=0, ge=0, le=100)
+
+
+class ProjectCreate(ProjectBase):
+ organization_id: uuid.UUID
+
+
+class ProjectUpdate(SQLModel):
+ name: str | None = Field(default=None, min_length=1, max_length=255)
+ client_name: str | None = Field(default=None, min_length=1, max_length=255)
+ client_email: str | None = Field(default=None, max_length=255)
+ description: str | None = Field(default=None, max_length=2000)
+ status: str | None = Field(default=None, max_length=50)
+ deadline: DateType | None = None
+ start_date: DateType | None = None
+ budget: str | None = Field(default=None, max_length=100)
+ progress: int | None = Field(default=None, ge=0, le=100)
+
+
+class Project(ProjectBase, table=True):
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
+ created_at: datetime = Field(default_factory=datetime.utcnow)
+ updated_at: datetime = Field(default_factory=datetime.utcnow)
+ organization_id: uuid.UUID = Field(
+ foreign_key="organization.id", nullable=False, ondelete="CASCADE"
+ )
+ organization: Optional["Organization"] = Relationship(back_populates="projects")
+ galleries: list["Gallery"] = Relationship(
+ back_populates="project", cascade_delete=True
+ )
+ access_list: list["ProjectAccess"] = Relationship(
+ back_populates="project", cascade_delete=True
+ )
+
+
+class ProjectPublic(ProjectBase):
+ id: uuid.UUID
+ created_at: datetime
+ updated_at: datetime
+ organization_id: uuid.UUID
+
+
+class ProjectsPublic(SQLModel):
+ data: list[ProjectPublic]
+ count: int
+
+
+# ============================================================================
+# GALLERY MODELS
+# ============================================================================
+
+
+class GalleryBase(SQLModel):
+ name: str = Field(min_length=1, max_length=255)
+ date: DateType | None = None
+ photo_count: int = Field(default=0, ge=0)
+ photographer: str | None = Field(default=None, max_length=255)
+ status: str = Field(default="draft", max_length=50) # draft, processing, published
+ cover_image_url: str | None = Field(default=None, max_length=500)
+
+
+class GalleryCreate(GalleryBase):
+ project_id: uuid.UUID
+
+
+class GalleryUpdate(SQLModel):
+ name: str | None = Field(default=None, min_length=1, max_length=255)
+ date: DateType | None = None
+ photo_count: int | None = Field(default=None, ge=0)
+ photographer: str | None = Field(default=None, max_length=255)
+ status: str | None = Field(default=None, max_length=50)
+ cover_image_url: str | None = Field(default=None, max_length=500)
+
+
+class Gallery(GalleryBase, table=True):
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
+ created_at: datetime = Field(default_factory=datetime.utcnow)
+ project_id: uuid.UUID = Field(
+ foreign_key="project.id", nullable=False, ondelete="CASCADE"
+ )
+ project: Optional["Project"] = Relationship(back_populates="galleries")
+
+
+class GalleryPublic(GalleryBase):
+ id: uuid.UUID
+ created_at: datetime
+ project_id: uuid.UUID
+
+
+class GalleriesPublic(SQLModel):
+ data: list[GalleryPublic]
+ count: int
+
+
+# ============================================================================
+# PROJECT ACCESS (Client Invitations)
+# ============================================================================
+
+
+class ProjectAccessBase(SQLModel):
+ role: str = Field(default="viewer", max_length=50) # viewer, collaborator
+ can_comment: bool = Field(default=True)
+ can_download: bool = Field(default=True)
+
+
+class ProjectAccessCreate(ProjectAccessBase):
+ project_id: uuid.UUID
+ user_id: uuid.UUID
+
+
+class ProjectAccessUpdate(SQLModel):
+ role: str | None = Field(default=None, max_length=50)
+ can_comment: bool | None = None
+ can_download: bool | None = None
+
+
+class ProjectAccess(ProjectAccessBase, table=True):
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
+ created_at: datetime = Field(default_factory=datetime.utcnow)
+ project_id: uuid.UUID = Field(
+ foreign_key="project.id", nullable=False, ondelete="CASCADE"
+ )
+ user_id: uuid.UUID = Field(
+ foreign_key="user.id", nullable=False, ondelete="CASCADE"
+ )
+ project: Optional["Project"] = Relationship(back_populates="access_list")
+ user: Optional["User"] = Relationship(back_populates="project_access")
+
+
+class ProjectAccessPublic(ProjectAccessBase):
+ id: uuid.UUID
+ created_at: datetime
+ project_id: uuid.UUID
+ user_id: uuid.UUID
+
+
+class ProjectAccessWithUser(ProjectAccessPublic):
+ user: UserPublic
+
+
+class ProjectAccessesPublic(SQLModel):
+ data: list[ProjectAccessPublic]
+ count: int
+
+
+# ============================================================================
+# DASHBOARD STATS
+# ============================================================================
+
+
+class DashboardStats(SQLModel):
+ active_projects: int
+ upcoming_deadlines: int
+ team_members: int
+ completed_this_month: int
+
+
+# ============================================================================
+# Organization Invitation Models
+# ============================================================================
+
+
+class OrganizationInvitationBase(SQLModel):
+ email: EmailStr = Field(max_length=255, index=True)
+
+
+class OrganizationInvitationCreate(OrganizationInvitationBase):
+ pass
+
+
+class OrganizationInvitation(OrganizationInvitationBase, table=True):
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
+ created_at: datetime = Field(default_factory=datetime.utcnow)
+ organization_id: uuid.UUID = Field(
+ foreign_key="organization.id", nullable=False, ondelete="CASCADE"
+ )
+ organization: Optional["Organization"] = Relationship()
+
+
+class OrganizationInvitationPublic(OrganizationInvitationBase):
+ id: uuid.UUID
+ created_at: datetime
+ organization_id: uuid.UUID
+
+
+class OrganizationInvitationsPublic(SQLModel):
+ data: list[OrganizationInvitationPublic]
+ count: int
diff --git a/backend/app/seed_data.py b/backend/app/seed_data.py
new file mode 100644
index 0000000000..8a870fbf43
--- /dev/null
+++ b/backend/app/seed_data.py
@@ -0,0 +1,188 @@
+"""Seed script to add sample projects and galleries to the database"""
+
+import logging
+from datetime import date, timedelta
+
+from sqlmodel import Session, select
+
+from app.core.db import engine
+from app.models import Gallery, Organization, Project
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+
+def seed_data() -> None:
+ with Session(engine) as session:
+ # Get the admin organization (created during init_db)
+ organization = session.exec(
+ select(Organization).where(Organization.name == "Admin Organization")
+ ).first()
+
+ if not organization:
+ logger.error("Admin organization not found! Run initial_data.py first.")
+ return
+
+ logger.info(f"Using organization: {organization.name}")
+
+ # Check if we already have sample data
+ existing_projects = session.exec(
+ select(Project).where(Project.organization_id == organization.id)
+ ).first()
+
+ if existing_projects:
+ logger.info("Sample data already exists. Skipping seed.")
+ return
+
+ # Create sample projects
+ today = date.today()
+
+ project1 = Project(
+ name="Sarah & John Wedding Photography",
+ client_name="Sarah Thompson",
+ client_email="sarah@example.com",
+ description="Full day wedding photography coverage including ceremony, reception, and portraits. Client wants natural, candid shots with some posed family photos.",
+ status="in_progress",
+ deadline=today + timedelta(days=7),
+ start_date=today - timedelta(days=18),
+ budget="$3,500",
+ progress=65,
+ organization_id=organization.id,
+ )
+ session.add(project1)
+
+ project2 = Project(
+ name="Product Shoot - TechCorp",
+ client_name="TechCorp Inc.",
+ client_email="marketing@techcorp.com",
+ description="Product photography for new smartphone lineup. Clean white background shots and lifestyle photography.",
+ status="review",
+ deadline=today + timedelta(days=4),
+ start_date=today - timedelta(days=7),
+ budget="$2,000",
+ progress=90,
+ organization_id=organization.id,
+ )
+ session.add(project2)
+
+ project3 = Project(
+ name="Brand Photography - StartupX",
+ client_name="StartupX",
+ client_email="team@startupx.com",
+ description="Corporate headshots and office culture photography for startup's website and marketing materials.",
+ status="planning",
+ deadline=today + timedelta(days=12),
+ start_date=today,
+ budget="$1,800",
+ progress=15,
+ organization_id=organization.id,
+ )
+ session.add(project3)
+
+ project4 = Project(
+ name="Corporate Headshots - Law Firm",
+ client_name="Smith & Associates",
+ client_email="office@smithlaw.com",
+ description="Professional headshots for 25 attorneys and staff members.",
+ status="planning",
+ deadline=today + timedelta(days=10),
+ start_date=today + timedelta(days=2),
+ budget="$1,250",
+ progress=10,
+ organization_id=organization.id,
+ )
+ session.add(project4)
+
+ project5 = Project(
+ name="Restaurant Menu Photography",
+ client_name="Bella Italia",
+ client_email="chef@bellaitalia.com",
+ description="Food photography for new seasonal menu. 30 dishes to be photographed.",
+ status="completed",
+ deadline=today - timedelta(days=8),
+ start_date=today - timedelta(days=30),
+ budget="$1,500",
+ progress=100,
+ organization_id=organization.id,
+ )
+ session.add(project5)
+
+ # Commit projects first so we have their IDs
+ session.commit()
+ session.refresh(project1)
+ session.refresh(project2)
+ session.refresh(project3)
+ session.refresh(project4)
+ session.refresh(project5)
+
+ logger.info("Created 5 sample projects")
+
+ # Create one gallery per project
+ gallery1 = Gallery(
+ name=f"{project1.name} - Gallery",
+ date=project1.start_date,
+ photo_count=0,
+ photographer=None,
+ status="draft",
+ cover_image_url=None,
+ project_id=project1.id,
+ )
+ session.add(gallery1)
+
+ gallery2 = Gallery(
+ name=f"{project2.name} - Gallery",
+ date=project2.start_date,
+ photo_count=0,
+ photographer=None,
+ status="draft",
+ cover_image_url=None,
+ project_id=project2.id,
+ )
+ session.add(gallery2)
+
+ gallery3 = Gallery(
+ name=f"{project3.name} - Gallery",
+ date=project3.start_date,
+ photo_count=0,
+ photographer=None,
+ status="draft",
+ cover_image_url=None,
+ project_id=project3.id,
+ )
+ session.add(gallery3)
+
+ gallery4 = Gallery(
+ name=f"{project4.name} - Gallery",
+ date=project4.start_date,
+ photo_count=0,
+ photographer=None,
+ status="draft",
+ cover_image_url=None,
+ project_id=project4.id,
+ )
+ session.add(gallery4)
+
+ gallery5 = Gallery(
+ name=f"{project5.name} - Gallery",
+ date=project5.start_date,
+ photo_count=0,
+ photographer=None,
+ status="draft",
+ cover_image_url=None,
+ project_id=project5.id,
+ )
+ session.add(gallery5)
+
+ session.commit()
+ logger.info("Created 5 galleries (one per project)")
+ logger.info("Sample data seeding complete!")
+
+
+def main() -> None:
+ logger.info("Starting sample data seeding...")
+ seed_data()
+ logger.info("Done!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/tests/api/routes/test_login.py b/backend/tests/api/routes/test_login.py
index ee166913bd..e8f092761c 100644
--- a/backend/tests/api/routes/test_login.py
+++ b/backend/tests/api/routes/test_login.py
@@ -51,6 +51,7 @@ def test_recovery_password(
with (
patch("app.core.config.settings.SMTP_HOST", "smtp.example.com"),
patch("app.core.config.settings.SMTP_USER", "admin@example.com"),
+ patch("app.core.config.settings.EMAILS_FROM_EMAIL", "noreply@example.com"),
):
email = "test@example.com"
r = client.post(
@@ -116,3 +117,40 @@ def test_reset_password_invalid_token(
assert "detail" in response
assert r.status_code == 400
assert response["detail"] == "Invalid token"
+
+
+# Arthur Nguyen, Assignment 6: added test for SQL injection protection
+def test_login_sql_injection_protection(client: TestClient) -> None:
+ """
+ Test that login endpoint is protected against SQL injection attacks.
+ Verifies that malicious SQL commands in username/password don't compromise security.
+ """
+ # Common SQL injection attack patterns
+ sql_injection_attempts = [
+ "admin' OR '1'='1",
+ "admin'--",
+ "admin' OR '1'='1'--",
+ "'; DROP TABLE users--",
+ "admin' OR 1=1#",
+ "' UNION SELECT NULL--",
+ ]
+
+ for malicious_input in sql_injection_attempts:
+ login_data = {
+ "username": malicious_input,
+ "password": "anypassword",
+ }
+ r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
+
+ # Should return authentication error, not SQL error or success
+ assert r.status_code == 400
+ response_data = r.json()
+ assert "access_token" not in response_data
+
+ # Also test in password field
+ login_data = {
+ "username": settings.FIRST_SUPERUSER,
+ "password": malicious_input,
+ }
+ r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
+ assert r.status_code == 400
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 8ddab7b321..f38afb7463 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -7,7 +7,7 @@
from app.core.config import settings
from app.core.db import engine, init_db
from app.main import app
-from app.models import Item, User
+from app.models import Item, Organization, User
from tests.utils.user import authentication_token_from_email
from tests.utils.utils import get_superuser_token_headers
@@ -17,10 +17,13 @@ def db() -> Generator[Session, None, None]:
with Session(engine) as session:
init_db(session)
yield session
+ # Clean up in proper order due to foreign key constraints
statement = delete(Item)
session.execute(statement)
statement = delete(User)
session.execute(statement)
+ statement = delete(Organization)
+ session.execute(statement)
session.commit()
diff --git a/backend/tests/crud/test_gallery.py b/backend/tests/crud/test_gallery.py
new file mode 100644
index 0000000000..22a23cebf1
--- /dev/null
+++ b/backend/tests/crud/test_gallery.py
@@ -0,0 +1,163 @@
+"""Unit tests for Gallery CRUD operations"""
+
+from datetime import date
+
+from sqlmodel import Session
+
+from app import crud
+from app.models import GalleryCreate, GalleryUpdate, OrganizationCreate, ProjectCreate
+from tests.utils.utils import random_lower_string
+
+
+def test_create_gallery(db: Session) -> None:
+ """Test creating a new gallery"""
+ # Create organization and project first
+ org_in = OrganizationCreate(name=random_lower_string())
+ organization = crud.create_organization(session=db, organization_in=org_in)
+
+ project_in = ProjectCreate(
+ name="Test Project", client_name="Test Client", organization_id=organization.id
+ )
+ project = crud.create_project(session=db, project_in=project_in)
+
+ # Create gallery
+ gallery_name = f"Test Gallery {random_lower_string()}"
+ gallery_in = GalleryCreate(
+ name=gallery_name,
+ date=date.today(),
+ photo_count=50,
+ photographer="Test Photographer",
+ status="published",
+ cover_image_url="https://example.com/image.jpg",
+ project_id=project.id,
+ )
+
+ gallery = crud.create_gallery(session=db, gallery_in=gallery_in)
+
+ assert gallery.name == gallery_name
+ assert gallery.photo_count == 50
+ assert gallery.photographer == "Test Photographer"
+ assert gallery.status == "published"
+ assert gallery.project_id == project.id
+ assert gallery.id is not None
+
+
+def test_get_gallery(db: Session) -> None:
+ """Test retrieving a gallery by ID"""
+ # Setup: create org, project, and gallery
+ org = crud.create_organization(
+ session=db, organization_in=OrganizationCreate(name=random_lower_string())
+ )
+ project = crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Test Project", client_name="Client", organization_id=org.id
+ ),
+ )
+ gallery_in = GalleryCreate(
+ name="Test Gallery", photo_count=100, status="draft", project_id=project.id
+ )
+ created_gallery = crud.create_gallery(session=db, gallery_in=gallery_in)
+
+ # Test retrieval
+ retrieved_gallery = crud.get_gallery(session=db, gallery_id=created_gallery.id)
+
+ assert retrieved_gallery is not None
+ assert retrieved_gallery.id == created_gallery.id
+ assert retrieved_gallery.name == "Test Gallery"
+ assert retrieved_gallery.photo_count == 100
+
+
+def test_update_gallery(db: Session) -> None:
+ """Test updating a gallery"""
+ # Setup
+ org = crud.create_organization(
+ session=db, organization_in=OrganizationCreate(name=random_lower_string())
+ )
+ project = crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Test Project", client_name="Client", organization_id=org.id
+ ),
+ )
+ gallery = crud.create_gallery(
+ session=db,
+ gallery_in=GalleryCreate(
+ name="Original Name", photo_count=50, status="draft", project_id=project.id
+ ),
+ )
+
+ # Update gallery
+ gallery_update = GalleryUpdate(
+ name="Updated Name", photo_count=100, status="published"
+ )
+
+ updated_gallery = crud.update_gallery(
+ session=db, db_gallery=gallery, gallery_in=gallery_update
+ )
+
+ assert updated_gallery.name == "Updated Name"
+ assert updated_gallery.photo_count == 100
+ assert updated_gallery.status == "published"
+
+
+def test_get_galleries_by_project(db: Session) -> None:
+ """Test retrieving all galleries for a project"""
+ # Setup
+ org = crud.create_organization(
+ session=db, organization_in=OrganizationCreate(name=random_lower_string())
+ )
+ project = crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Test Project", client_name="Client", organization_id=org.id
+ ),
+ )
+
+ # Create multiple galleries
+ for i in range(3):
+ crud.create_gallery(
+ session=db,
+ gallery_in=GalleryCreate(
+ name=f"Gallery {i}", photo_count=i * 10, project_id=project.id
+ ),
+ )
+
+ # Retrieve galleries
+ galleries = crud.get_galleries_by_project(session=db, project_id=project.id)
+
+ assert len(galleries) == 3
+ gallery_names = [g.name for g in galleries]
+ assert "Gallery 0" in gallery_names
+ assert "Gallery 1" in gallery_names
+ assert "Gallery 2" in gallery_names
+
+
+def test_delete_gallery(db: Session) -> None:
+ """Test deleting a gallery"""
+ # Setup
+ org = crud.create_organization(
+ session=db, organization_in=OrganizationCreate(name=random_lower_string())
+ )
+ project = crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Test Project", client_name="Client", organization_id=org.id
+ ),
+ )
+ gallery = crud.create_gallery(
+ session=db,
+ gallery_in=GalleryCreate(
+ name="Gallery to Delete", photo_count=50, project_id=project.id
+ ),
+ )
+ gallery_id = gallery.id
+
+ # Verify gallery exists
+ assert crud.get_gallery(session=db, gallery_id=gallery_id) is not None
+
+ # Delete gallery
+ crud.delete_gallery(session=db, gallery_id=gallery_id)
+
+ # Verify gallery is deleted
+ assert crud.get_gallery(session=db, gallery_id=gallery_id) is None
diff --git a/backend/tests/crud/test_project.py b/backend/tests/crud/test_project.py
new file mode 100644
index 0000000000..c84b9149f8
--- /dev/null
+++ b/backend/tests/crud/test_project.py
@@ -0,0 +1,202 @@
+"""Unit tests for Project CRUD operations"""
+
+from datetime import date, timedelta
+
+from sqlmodel import Session
+
+from app import crud
+from app.models import OrganizationCreate, ProjectCreate, ProjectUpdate
+from tests.utils.utils import random_lower_string
+
+
+def test_create_project(db: Session) -> None:
+ """Test creating a new project"""
+ # First create an organization
+ org_in = OrganizationCreate(
+ name=random_lower_string(), description="Test organization"
+ )
+ organization = crud.create_organization(session=db, organization_in=org_in)
+
+ # Create a project
+ project_name = f"Test Project {random_lower_string()}"
+ client_name = f"Client {random_lower_string()}"
+ deadline = date.today() + timedelta(days=30)
+
+ project_in = ProjectCreate(
+ name=project_name,
+ client_name=client_name,
+ description="Test project description",
+ status="planning",
+ deadline=deadline,
+ budget="$5,000",
+ progress=0,
+ organization_id=organization.id,
+ )
+
+ project = crud.create_project(session=db, project_in=project_in)
+
+ assert project.name == project_name
+ assert project.client_name == client_name
+ assert project.status == "planning"
+ assert project.deadline == deadline
+ assert project.budget == "$5,000"
+ assert project.progress == 0
+ assert project.organization_id == organization.id
+ assert project.id is not None
+
+
+def test_get_project(db: Session) -> None:
+ """Test retrieving a project by ID"""
+ # Create organization and project
+ org_in = OrganizationCreate(name=random_lower_string())
+ organization = crud.create_organization(session=db, organization_in=org_in)
+
+ project_in = ProjectCreate(
+ name="Test Project",
+ client_name="Test Client",
+ status="in_progress",
+ progress=50,
+ organization_id=organization.id,
+ )
+ created_project = crud.create_project(session=db, project_in=project_in)
+
+ # Retrieve the project
+ retrieved_project = crud.get_project(session=db, project_id=created_project.id)
+
+ assert retrieved_project is not None
+ assert retrieved_project.id == created_project.id
+ assert retrieved_project.name == "Test Project"
+ assert retrieved_project.progress == 50
+
+
+def test_update_project(db: Session) -> None:
+ """Test updating a project"""
+ # Create organization and project
+ org_in = OrganizationCreate(name=random_lower_string())
+ organization = crud.create_organization(session=db, organization_in=org_in)
+
+ project_in = ProjectCreate(
+ name="Original Name",
+ client_name="Test Client",
+ status="planning",
+ progress=0,
+ organization_id=organization.id,
+ )
+ project = crud.create_project(session=db, project_in=project_in)
+
+ # Update the project
+ project_update = ProjectUpdate(
+ name="Updated Name", status="in_progress", progress=75
+ )
+
+ updated_project = crud.update_project(
+ session=db, db_project=project, project_in=project_update
+ )
+
+ assert updated_project.name == "Updated Name"
+ assert updated_project.status == "in_progress"
+ assert updated_project.progress == 75
+ assert updated_project.client_name == "Test Client" # Unchanged field
+
+
+def test_get_projects_by_organization(db: Session) -> None:
+ """Test retrieving all projects for an organization"""
+ # Create organization
+ org_in = OrganizationCreate(name=random_lower_string())
+ organization = crud.create_organization(session=db, organization_in=org_in)
+
+ # Create multiple projects
+ _project1 = crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Project 1", client_name="Client 1", organization_id=organization.id
+ ),
+ )
+
+ _project2 = crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Project 2", client_name="Client 2", organization_id=organization.id
+ ),
+ )
+
+ # Retrieve projects
+ projects = crud.get_projects_by_organization(
+ session=db, organization_id=organization.id
+ )
+
+ assert len(projects) == 2
+ project_names = [p.name for p in projects]
+ assert "Project 1" in project_names
+ assert "Project 2" in project_names
+
+
+def test_count_projects_by_organization(db: Session) -> None:
+ """Test counting projects for an organization"""
+ # Create organization
+ org_in = OrganizationCreate(name=random_lower_string())
+ organization = crud.create_organization(session=db, organization_in=org_in)
+
+ # Initially should have 0 projects
+ count = crud.count_projects_by_organization(
+ session=db, organization_id=organization.id
+ )
+ assert count == 0
+
+ # Create 3 projects
+ for i in range(3):
+ crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name=f"Project {i}",
+ client_name=f"Client {i}",
+ organization_id=organization.id,
+ ),
+ )
+
+ # Should now have 3 projects
+ count = crud.count_projects_by_organization(
+ session=db, organization_id=organization.id
+ )
+ assert count == 3
+
+
+def test_delete_project(db: Session) -> None:
+ """Test deleting a project"""
+ # Create organization and project
+ org_in = OrganizationCreate(name=random_lower_string())
+ organization = crud.create_organization(session=db, organization_in=org_in)
+
+ project_in = ProjectCreate(
+ name="Project to Delete",
+ client_name="Test Client",
+ organization_id=organization.id,
+ )
+ project = crud.create_project(session=db, project_in=project_in)
+ project_id = project.id
+
+ # Verify project exists
+ assert crud.get_project(session=db, project_id=project_id) is not None
+
+ # Delete project
+ crud.delete_project(session=db, project_id=project_id)
+
+ # Verify project is deleted
+ assert crud.get_project(session=db, project_id=project_id) is None
+
+
+def test_project_progress_validation(db: Session) -> None:
+ """Test that project progress is validated (0-100)"""
+ org_in = OrganizationCreate(name=random_lower_string())
+ organization = crud.create_organization(session=db, organization_in=org_in)
+
+ # Valid progress values
+ for progress_val in [0, 50, 100]:
+ project_in = ProjectCreate(
+ name=f"Project {progress_val}",
+ client_name="Test Client",
+ progress=progress_val,
+ organization_id=organization.id,
+ )
+ project = crud.create_project(session=db, project_in=project_in)
+ assert project.progress == progress_val
diff --git a/backend/tests/crud/test_stats.py b/backend/tests/crud/test_stats.py
new file mode 100644
index 0000000000..b149869dbc
--- /dev/null
+++ b/backend/tests/crud/test_stats.py
@@ -0,0 +1,156 @@
+"""Unit tests for Dashboard Statistics"""
+
+from datetime import date, timedelta
+
+from sqlmodel import Session
+
+from app import crud
+from app.models import OrganizationCreate, ProjectCreate
+from tests.utils.utils import random_lower_string
+
+
+def test_dashboard_stats_empty_organization(db: Session) -> None:
+ """Test dashboard stats for an organization with no projects"""
+ org_in = OrganizationCreate(name=random_lower_string())
+ organization = crud.create_organization(session=db, organization_in=org_in)
+
+ stats = crud.get_dashboard_stats(session=db, organization_id=organization.id)
+
+ assert stats.active_projects == 0
+ assert stats.upcoming_deadlines == 0
+ assert stats.team_members == 0
+ assert stats.completed_this_month == 0
+
+
+def test_dashboard_stats_active_projects(db: Session) -> None:
+ """Test counting active projects (in_progress and review status)"""
+ org = crud.create_organization(
+ session=db, organization_in=OrganizationCreate(name=random_lower_string())
+ )
+
+ # Create projects with different statuses
+ crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Project 1",
+ client_name="Client 1",
+ status="in_progress",
+ organization_id=org.id,
+ ),
+ )
+ crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Project 2",
+ client_name="Client 2",
+ status="review",
+ organization_id=org.id,
+ ),
+ )
+ crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Project 3",
+ client_name="Client 3",
+ status="planning", # Not active
+ organization_id=org.id,
+ ),
+ )
+ crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Project 4",
+ client_name="Client 4",
+ status="completed", # Not active
+ organization_id=org.id,
+ ),
+ )
+
+ stats = crud.get_dashboard_stats(session=db, organization_id=org.id)
+
+ # Should count only in_progress and review
+ assert stats.active_projects == 2
+
+
+def test_dashboard_stats_upcoming_deadlines(db: Session) -> None:
+ """Test counting upcoming deadlines (within next 14 days)"""
+ org = crud.create_organization(
+ session=db, organization_in=OrganizationCreate(name=random_lower_string())
+ )
+
+ today = date.today()
+
+ # Project with deadline in 5 days - should count
+ crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Project Soon",
+ client_name="Client",
+ status="in_progress",
+ deadline=today + timedelta(days=5),
+ organization_id=org.id,
+ ),
+ )
+
+ # Project with deadline in 30 days - should NOT count (too far)
+ crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Project Later",
+ client_name="Client",
+ status="in_progress",
+ deadline=today + timedelta(days=30),
+ organization_id=org.id,
+ ),
+ )
+
+ # Completed project with deadline in 7 days - should NOT count (completed)
+ crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Project Done",
+ client_name="Client",
+ status="completed",
+ deadline=today + timedelta(days=7),
+ organization_id=org.id,
+ ),
+ )
+
+ stats = crud.get_dashboard_stats(session=db, organization_id=org.id)
+
+ # Should only count the first project
+ assert stats.upcoming_deadlines == 1
+
+
+def test_dashboard_stats_completed_this_month(db: Session) -> None:
+ """Test counting projects completed this month"""
+ org = crud.create_organization(
+ session=db, organization_in=OrganizationCreate(name=random_lower_string())
+ )
+
+ # Create completed projects
+ _project1 = crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Completed Project",
+ client_name="Client",
+ status="completed",
+ organization_id=org.id,
+ ),
+ )
+
+ # Also create a non-completed project
+ crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="In Progress Project",
+ client_name="Client",
+ status="in_progress",
+ organization_id=org.id,
+ ),
+ )
+
+ stats = crud.get_dashboard_stats(session=db, organization_id=org.id)
+
+ # Should count only completed projects
+ assert stats.completed_this_month >= 1 # At least the one we created
diff --git a/deploy-ip.sh b/deploy-ip.sh
new file mode 100644
index 0000000000..a30ac40696
--- /dev/null
+++ b/deploy-ip.sh
@@ -0,0 +1,168 @@
+#!/bin/bash
+
+# Deployment script for FastAPI project on EC2 with IP address
+# Usage: ./deploy-ip.sh YOUR_EC2_IP
+
+if [ -z "$1" ]; then
+ echo "Usage: ./deploy-ip.sh YOUR_EC2_IP"
+ echo "Example: ./deploy-ip.sh 54.123.45.67"
+ exit 1
+fi
+
+EC2_IP=$1
+
+echo "Deploying to EC2 IP: $EC2_IP"
+
+# Create environment file
+cat > .env << EOF
+# Production Environment Variables for IP-based deployment
+ENVIRONMENT=production
+DOMAIN=$EC2_IP
+PROJECT_NAME=Mosaic Project
+STACK_NAME=mosaic-project-production
+BACKEND_CORS_ORIGINS=http://$EC2_IP:5173,http://$EC2_IP:80,http://$EC2_IP
+FRONTEND_HOST=http://$EC2_IP:5173
+SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
+FIRST_SUPERUSER=admin@example.com
+FIRST_SUPERUSER_PASSWORD=$(python3 -c "import secrets; print(secrets.token_urlsafe(16))")
+POSTGRES_SERVER=db
+POSTGRES_PORT=5432
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=$(python3 -c "import secrets; print(secrets.token_urlsafe(16))")
+POSTGRES_DB=app
+SMTP_HOST=
+SMTP_USER=
+SMTP_PASSWORD=
+EMAILS_FROM_EMAIL=
+DOCKER_IMAGE_BACKEND=mosaic-backend
+DOCKER_IMAGE_FRONTEND=mosaic-frontend
+TAG=latest
+EOF
+
+echo "Environment file created with secure random keys"
+
+# Create simplified docker-compose for IP deployment
+cat > docker-compose.production.yml << EOF
+services:
+ db:
+ image: postgres:17
+ restart: always
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER} -d \${POSTGRES_DB}"]
+ interval: 10s
+ retries: 5
+ start_period: 30s
+ timeout: 10s
+ volumes:
+ - app-db-data:/var/lib/postgresql/data/pgdata
+ environment:
+ - PGDATA=/var/lib/postgresql/data/pgdata
+ - POSTGRES_PASSWORD=\${POSTGRES_PASSWORD}
+ - POSTGRES_USER=\${POSTGRES_USER}
+ - POSTGRES_DB=\${POSTGRES_DB}
+ ports:
+ - "5432:5432"
+
+ adminer:
+ image: adminer
+ restart: always
+ depends_on:
+ - db
+ environment:
+ - ADMINER_DESIGN=pepa-linha-dark
+ ports:
+ - "8080:8080"
+
+ prestart:
+ image: \${DOCKER_IMAGE_BACKEND}:\${TAG}
+ build:
+ context: ./backend
+ depends_on:
+ db:
+ condition: service_healthy
+ restart: true
+ command: bash scripts/prestart.sh
+ environment:
+ - PROJECT_NAME=\${PROJECT_NAME}
+ - DOMAIN=\${DOMAIN}
+ - FRONTEND_HOST=\${FRONTEND_HOST}
+ - ENVIRONMENT=\${ENVIRONMENT}
+ - BACKEND_CORS_ORIGINS=\${BACKEND_CORS_ORIGINS}
+ - SECRET_KEY=\${SECRET_KEY}
+ - FIRST_SUPERUSER=\${FIRST_SUPERUSER}
+ - FIRST_SUPERUSER_PASSWORD=\${FIRST_SUPERUSER_PASSWORD}
+ - SMTP_HOST=\${SMTP_HOST}
+ - SMTP_USER=\${SMTP_USER}
+ - SMTP_PASSWORD=\${SMTP_PASSWORD}
+ - EMAILS_FROM_EMAIL=\${EMAILS_FROM_EMAIL}
+ - POSTGRES_SERVER=db
+ - POSTGRES_PORT=\${POSTGRES_PORT}
+ - POSTGRES_DB=\${POSTGRES_DB}
+ - POSTGRES_USER=\${POSTGRES_USER}
+ - POSTGRES_PASSWORD=\${POSTGRES_PASSWORD}
+
+ backend:
+ image: \${DOCKER_IMAGE_BACKEND}:\${TAG}
+ restart: always
+ depends_on:
+ db:
+ condition: service_healthy
+ restart: true
+ prestart:
+ condition: service_completed_successfully
+ build:
+ context: ./backend
+ environment:
+ - PROJECT_NAME=\${PROJECT_NAME}
+ - DOMAIN=\${DOMAIN}
+ - FRONTEND_HOST=\${FRONTEND_HOST}
+ - ENVIRONMENT=\${ENVIRONMENT}
+ - BACKEND_CORS_ORIGINS=\${BACKEND_CORS_ORIGINS}
+ - SECRET_KEY=\${SECRET_KEY}
+ - FIRST_SUPERUSER=\${FIRST_SUPERUSER}
+ - FIRST_SUPERUSER_PASSWORD=\${FIRST_SUPERUSER_PASSWORD}
+ - SMTP_HOST=\${SMTP_HOST}
+ - SMTP_USER=\${SMTP_USER}
+ - SMTP_PASSWORD=\${SMTP_PASSWORD}
+ - EMAILS_FROM_EMAIL=\${EMAILS_FROM_EMAIL}
+ - POSTGRES_SERVER=db
+ - POSTGRES_PORT=\${POSTGRES_PORT}
+ - POSTGRES_DB=\${POSTGRES_DB}
+ - POSTGRES_USER=\${POSTGRES_USER}
+ - POSTGRES_PASSWORD=\${POSTGRES_PASSWORD}
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ ports:
+ - "8000:8000"
+
+ frontend:
+ image: \${DOCKER_IMAGE_FRONTEND}:\${TAG}
+ restart: always
+ build:
+ context: ./frontend
+ args:
+ - VITE_API_URL=http://$EC2_IP:8000
+ - NODE_ENV=production
+ ports:
+ - "80:80"
+
+volumes:
+ app-db-data:
+EOF
+
+echo "Production docker-compose file created"
+
+echo "Deployment files created successfully!"
+echo ""
+echo "Next steps:"
+echo "1. Copy your project files to the EC2 instance"
+echo "2. Run: docker compose -f docker-compose.production.yml up -d"
+echo ""
+echo "Your application will be available at:"
+echo "- Frontend: http://$EC2_IP"
+echo "- Backend API: http://$EC2_IP:8000"
+echo "- API Docs: http://$EC2_IP:8000/docs"
+echo "- Adminer: http://$EC2_IP:8080"
diff --git a/docker-compose.override.yml b/docker-compose.override.yml
index 0751abe901..8d9fbd7468 100644
--- a/docker-compose.override.yml
+++ b/docker-compose.override.yml
@@ -121,6 +121,9 @@ services:
# For the reports when run locally
- PLAYWRIGHT_HTML_HOST=0.0.0.0
- CI=${CI}
+ # Test credentials
+ - FIRST_SUPERUSER=${FIRST_SUPERUSER}
+ - FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD}
volumes:
- ./frontend/blob-report:/app/blob-report
- ./frontend/test-results:/app/test-results
diff --git a/docker-compose.yml b/docker-compose.yml
index b1aa17ed43..4ba11e968d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -74,6 +74,7 @@ services:
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
- SENTRY_DSN=${SENTRY_DSN}
+ - PROJECT_NAME=${PROJECT_NAME?Variable not set}
backend:
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
@@ -107,6 +108,7 @@ services:
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
- SENTRY_DSN=${SENTRY_DSN}
+ - PROJECT_NAME=${PROJECT_NAME?Variable not set}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"]
diff --git a/frontend/.env b/frontend/.env
deleted file mode 100644
index 27fcbfe8c8..0000000000
--- a/frontend/.env
+++ /dev/null
@@ -1,2 +0,0 @@
-VITE_API_URL=http://localhost:8000
-MAILCATCHER_HOST=http://localhost:1080
diff --git a/frontend/index.html b/frontend/index.html
index 57621a268b..2f52280354 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,8 +4,8 @@
-
+ No clients found +
+ ) : ( + filteredClients.map((user: any) => ( +