diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 0000000000..bf278f841e
Binary files /dev/null and b/.DS_Store differ
diff --git a/.env b/.env
deleted file mode 100644
index 1d44286e25..0000000000
--- a/.env
+++ /dev/null
@@ -1,45 +0,0 @@
-# Domain
-# This would be set to the production domain with an env var on deployment
-# used by Traefik to transmit traffic and aqcuire TLS certificates
-DOMAIN=localhost
-# To test the local Traefik config
-# DOMAIN=localhost.tiangolo.com
-
-# Used by the backend to generate links in emails to the frontend
-FRONTEND_HOST=http://localhost:5173
-# In staging and production, set this env var to the frontend host, e.g.
-# FRONTEND_HOST=https://dashboard.example.com
-
-# Environment: local, staging, production
-ENVIRONMENT=local
-
-PROJECT_NAME="Full Stack FastAPI Project"
-STACK_NAME=full-stack-fastapi-project
-
-# Backend
-BACKEND_CORS_ORIGINS="http://localhost,http://localhost:5173,https://localhost,https://localhost:5173,http://localhost.tiangolo.com"
-SECRET_KEY=changethis
-FIRST_SUPERUSER=admin@example.com
-FIRST_SUPERUSER_PASSWORD=changethis
-
-# Emails
-SMTP_HOST=
-SMTP_USER=
-SMTP_PASSWORD=
-EMAILS_FROM_EMAIL=info@example.com
-SMTP_TLS=True
-SMTP_SSL=False
-SMTP_PORT=587
-
-# Postgres
-POSTGRES_SERVER=localhost
-POSTGRES_PORT=5432
-POSTGRES_DB=app
-POSTGRES_USER=postgres
-POSTGRES_PASSWORD=changethis
-
-SENTRY_DSN=
-
-# Configure these with your own Docker registry images
-DOCKER_IMAGE_BACKEND=backend
-DOCKER_IMAGE_FRONTEND=frontend
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000000..5d778a0115
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,138 @@
+name: Deploy to EC2
+
+on:
+ push:
+ branches: [ main, master ]
+ pull_request:
+ branches: [ main, master ]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16
+ env:
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: test_db
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@v4
+ with:
+ enable-cache: true
+
+ - name: Set up Python
+ run: uv python install 3.10
+
+ - name: Install dependencies
+ run: |
+ cd backend
+ uv sync
+
+ - name: Run migrations
+ env:
+ PROJECT_NAME: Mosaic Test
+ POSTGRES_SERVER: localhost
+ POSTGRES_PORT: 5432
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: test_db
+ SECRET_KEY: test-secret-key-for-testing
+ FIRST_SUPERUSER: admin@example.com
+ FIRST_SUPERUSER_PASSWORD: testpassword
+ ENVIRONMENT: local
+ run: |
+ cd backend
+ uv run alembic upgrade head
+
+ - name: Run tests
+ env:
+ PROJECT_NAME: Test Project
+ POSTGRES_SERVER: localhost
+ POSTGRES_PORT: 5432
+ POSTGRES_USER: postgres
+ POSTGRES_PASSWORD: postgres
+ POSTGRES_DB: test_db
+ SECRET_KEY: test-secret-key-for-testing
+ FIRST_SUPERUSER: admin@example.com
+ FIRST_SUPERUSER_PASSWORD: testpassword
+ ENVIRONMENT: local
+ run: |
+ cd backend
+ uv run pytest tests/ -v
+
+ deploy:
+ needs: test
+ runs-on: ubuntu-latest
+ if: always() && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master')
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup SSH
+ uses: webfactory/ssh-agent@v0.7.0
+ with:
+ ssh-private-key: ${{ secrets.EC2_SSH_KEY }}
+
+ - name: Add EC2 to known hosts
+ run: |
+ ssh-keyscan -H ${{ secrets.EC2_HOST }} >> ~/.ssh/known_hosts
+
+ - name: Deploy to EC2
+ run: |
+ ssh ec2-user@${{ secrets.EC2_HOST }} 'bash -s' << 'ENDSSH'
+ cd mosaic-project-cs4800 || cd mosaic-project-cs4800-main
+ git pull origin main || git pull origin master
+ chmod +x deploy-ip.sh
+ ./deploy-ip.sh ${{ secrets.EC2_HOST }}
+ cat > .env << 'ENVEOF'
+ ENVIRONMENT=production
+ DOMAIN=${{ secrets.EC2_HOST }}
+ PROJECT_NAME=Mosaic Project
+ STACK_NAME=mosaic-project-production
+ BACKEND_CORS_ORIGINS=http://${{ secrets.EC2_HOST }}:5173,http://${{ secrets.EC2_HOST }}:80,http://${{ secrets.EC2_HOST }}
+ FRONTEND_HOST=http://${{ secrets.EC2_HOST }}:5173
+ SECRET_KEY=${{ secrets.SECRET_KEY }}
+ FIRST_SUPERUSER=${{ secrets.FIRST_SUPERUSER }}
+ FIRST_SUPERUSER_PASSWORD=${{ secrets.FIRST_SUPERUSER_PASSWORD }}
+ POSTGRES_SERVER=db
+ POSTGRES_PORT=5432
+ POSTGRES_USER=postgres
+ POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}
+ POSTGRES_DB=app
+ SMTP_HOST=
+ SMTP_USER=
+ SMTP_PASSWORD=
+ EMAILS_FROM_EMAIL=
+ DOCKER_IMAGE_BACKEND=mosaic-backend
+ DOCKER_IMAGE_FRONTEND=mosaic-frontend
+ TAG=latest
+ ENVEOF
+ export DOCKER_BUILDKIT=1
+ export COMPOSE_DOCKER_CLI_BUILD=1
+ docker compose -f docker-compose.production.yml down
+ docker compose -f docker-compose.production.yml up -d --build
+ sleep 30
+ docker compose -f docker-compose.production.yml ps
+ curl -f http://localhost:8000/api/v1/utils/health-check/ || echo "Backend health check failed"
+ ENDSSH
+
+ - name: Verify deployment
+ run: |
+ # Wait a bit for services to fully start
+ sleep 10
+
+ # Test if the application is accessible
+ curl -f http://${{ secrets.EC2_HOST }}:8000/api/v1/utils/health-check/ || echo "Backend not accessible"
+ curl -f http://${{ secrets.EC2_HOST }} || echo "Frontend not accessible"
diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml
index 72df638d56..849b87bb92 100644
--- a/.github/workflows/playwright.yml
+++ b/.github/workflows/playwright.yml
@@ -41,6 +41,28 @@ jobs:
if: ${{ needs.changes.outputs.changed == 'true' }}
timeout-minutes: 60
runs-on: ubuntu-latest
+ env:
+ DOMAIN: localhost
+ ENVIRONMENT: local
+ FRONTEND_HOST: http://localhost:5173
+ BACKEND_CORS_ORIGINS: http://localhost:5173,http://localhost:8000
+ SECRET_KEY: ${{ secrets.SECRET_KEY }}
+ FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
+ FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
+ POSTGRES_SERVER: db
+ POSTGRES_PORT: 5432
+ POSTGRES_DB: app
+ POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
+ POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
+ SMTP_HOST: ""
+ SMTP_USER: ""
+ SMTP_PASSWORD: ""
+ EMAILS_FROM_EMAIL: noreply@example.com
+ SENTRY_DSN: ""
+ PROJECT_NAME: "Mosaic Project"
+ STACK_NAME: mosaic-project-local
+ DOCKER_IMAGE_BACKEND: backend
+ DOCKER_IMAGE_FRONTEND: frontend
strategy:
matrix:
shardIndex: [1, 2, 3, 4]
@@ -48,7 +70,9 @@ jobs:
fail-fast: false
steps:
- uses: actions/checkout@v5
- - uses: actions/setup-node@v6
+ - name: Create .env file
+ run: touch .env
+ - uses: actions/setup-node@v5
with:
node-version: lts/*
- uses: actions/setup-python@v6
@@ -60,7 +84,7 @@ jobs:
with:
limit-access-to-actor: true
- name: Install uv
- uses: astral-sh/setup-uv@v7
+ uses: astral-sh/setup-uv@v6
with:
version: "0.4.15"
enable-cache: true
@@ -75,10 +99,11 @@ jobs:
- run: docker compose down -v --remove-orphans
- name: Run Playwright tests
run: docker compose run --rm playwright npx playwright test --fail-on-flaky-tests --trace=retain-on-failure --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
+
- run: docker compose down -v --remove-orphans
- name: Upload blob report to GitHub Actions Artifacts
if: ${{ !cancelled() }}
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v4
with:
name: blob-report-${{ matrix.shardIndex }}
path: frontend/blob-report
@@ -94,14 +119,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- - uses: actions/setup-node@v6
+ - uses: actions/setup-node@v5
with:
node-version: 20
- name: Install dependencies
run: npm ci
working-directory: frontend
- name: Download blob reports from GitHub Actions Artifacts
- uses: actions/download-artifact@v6
+ uses: actions/download-artifact@v5
with:
path: frontend/all-blob-reports
pattern: blob-report-*
@@ -110,7 +135,7 @@ jobs:
run: npx playwright merge-reports --reporter html ./all-blob-reports
working-directory: frontend
- name: Upload HTML report
- uses: actions/upload-artifact@v5
+ uses: actions/upload-artifact@v4
with:
name: html-report--attempt-${{ github.run_attempt }}
path: frontend/playwright-report
diff --git a/.github/workflows/test-backend.yml b/.github/workflows/test-backend.yml
index 5b0d8aa8d5..c755337389 100644
--- a/.github/workflows/test-backend.yml
+++ b/.github/workflows/test-backend.yml
@@ -12,9 +12,33 @@ on:
jobs:
test-backend:
runs-on: ubuntu-latest
+ env:
+ DOMAIN: localhost
+ ENVIRONMENT: local
+ FRONTEND_HOST: http://localhost:5173
+ BACKEND_CORS_ORIGINS: http://localhost:5173,http://localhost:8000
+ SECRET_KEY: ${{ secrets.SECRET_KEY }}
+ FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
+ FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
+ POSTGRES_SERVER: localhost
+ POSTGRES_PORT: 5432
+ POSTGRES_DB: app
+ POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
+ POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
+ SMTP_HOST: ""
+ SMTP_USER: ""
+ SMTP_PASSWORD: ""
+ EMAILS_FROM_EMAIL: noreply@example.com
+ SENTRY_DSN: ""
+ PROJECT_NAME: "Mosaic Project"
+ STACK_NAME: mosaic-project-local
+ DOCKER_IMAGE_BACKEND: backend
+ DOCKER_IMAGE_FRONTEND: frontend
steps:
- name: Checkout
uses: actions/checkout@v5
+ - name: Create .env file
+ run: touch .env
- name: Set up Python
uses: actions/setup-python@v6
with:
diff --git a/.github/workflows/test-docker-compose.yml b/.github/workflows/test-docker-compose.yml
index c14d9dd630..ed996feda9 100644
--- a/.github/workflows/test-docker-compose.yml
+++ b/.github/workflows/test-docker-compose.yml
@@ -13,9 +13,33 @@ jobs:
test-docker-compose:
runs-on: ubuntu-latest
+ env:
+ DOMAIN: localhost
+ ENVIRONMENT: local
+ FRONTEND_HOST: http://localhost:5173
+ BACKEND_CORS_ORIGINS: http://localhost:5173,http://localhost:8000
+ SECRET_KEY: ${{ secrets.SECRET_KEY }}
+ FIRST_SUPERUSER: ${{ secrets.FIRST_SUPERUSER }}
+ FIRST_SUPERUSER_PASSWORD: ${{ secrets.FIRST_SUPERUSER_PASSWORD }}
+ POSTGRES_SERVER: db
+ POSTGRES_PORT: 5432
+ POSTGRES_DB: app
+ POSTGRES_USER: ${{ secrets.POSTGRES_USER }}
+ POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
+ SMTP_HOST: ""
+ SMTP_USER: ""
+ SMTP_PASSWORD: ""
+ EMAILS_FROM_EMAIL: noreply@example.com
+ SENTRY_DSN: ""
+ PROJECT_NAME: "Mosaic Project"
+ STACK_NAME: mosaic-project-local
+ DOCKER_IMAGE_BACKEND: backend
+ DOCKER_IMAGE_FRONTEND: frontend
steps:
- name: Checkout
uses: actions/checkout@v5
+ - name: Create .env file
+ run: touch .env
- run: docker compose build
- run: docker compose down -v --remove-orphans
- run: docker compose up -d --wait backend frontend adminer
diff --git a/.gitignore b/.gitignore
index a6dd346572..3681abd707 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,15 @@
+.env
.vscode
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
+
+# OpenAPI generated file
+frontend/openapi.json
+
+# Editor backup files
+*~
+*.swp
+*.swo
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000000..13566b81b0
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000000..919471704b
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
-
-
-## 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/2025011501_add_photo_file_size_uploaded_at.py b/backend/app/alembic/versions/2025011501_add_photo_file_size_uploaded_at.py
new file mode 100644
index 0000000000..51389c75da
--- /dev/null
+++ b/backend/app/alembic/versions/2025011501_add_photo_file_size_uploaded_at.py
@@ -0,0 +1,34 @@
+"""add file_size and uploaded_at to photo table
+
+Revision ID: 2025011501
+Revises: 2025111201
+Create Date: 2025-01-15
+"""
+
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = "2025011501"
+down_revision = "2025111201"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # Add file_size column (in bytes, default 0 for existing photos)
+ op.add_column("photo", sa.Column("file_size", sa.Integer(), nullable=False, server_default="0"))
+
+ # Add uploaded_at column (use created_at as default for existing photos)
+ # First add as nullable, then update existing rows, then make non-nullable
+ op.add_column("photo", sa.Column("uploaded_at", sa.DateTime(), nullable=True))
+ op.execute("UPDATE photo SET uploaded_at = created_at WHERE uploaded_at IS NULL")
+ op.alter_column("photo", "uploaded_at", nullable=False)
+
+
+def downgrade():
+ op.drop_column("photo", "uploaded_at")
+ op.drop_column("photo", "file_size")
+
diff --git a/backend/app/alembic/versions/2025110201_add_user_type_field.py b/backend/app/alembic/versions/2025110201_add_user_type_field.py
new file mode 100644
index 0000000000..2d042126b3
--- /dev/null
+++ b/backend/app/alembic/versions/2025110201_add_user_type_field.py
@@ -0,0 +1,27 @@
+"""Add user_type field to User table
+
+Revision ID: 2025110201
+Revises: 2025010801
+Create Date: 2025-11-02 00:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel.sql.sqltypes
+
+# revision identifiers, used by Alembic.
+revision = '2025110201'
+down_revision = '2025010801'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # Add user_type column to user table with default value
+ op.add_column('user', sa.Column('user_type', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False, server_default='team_member'))
+
+
+def downgrade():
+ # Remove user_type column from user table
+ op.drop_column('user', 'user_type')
+
diff --git a/backend/app/alembic/versions/2025110301_add_project_access_table.py b/backend/app/alembic/versions/2025110301_add_project_access_table.py
new file mode 100644
index 0000000000..6fad24d4ee
--- /dev/null
+++ b/backend/app/alembic/versions/2025110301_add_project_access_table.py
@@ -0,0 +1,51 @@
+"""Add project_access table for client invitations
+
+Revision ID: 2025110301
+Revises: 2025110201
+Create Date: 2025-11-03 00:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel.sql.sqltypes
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = '2025110301'
+down_revision = '2025110201'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # Create project_access table
+ op.create_table(
+ 'projectaccess',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('role', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False, server_default='viewer'),
+ sa.Column('can_comment', sa.Boolean(), nullable=False, server_default='true'),
+ sa.Column('can_download', sa.Boolean(), nullable=False, server_default='true'),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('project_id', sa.UUID(), nullable=False),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(['project_id'], ['project.id'], ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+
+ # Create index for faster lookups
+ op.create_index('ix_projectaccess_project_id', 'projectaccess', ['project_id'])
+ op.create_index('ix_projectaccess_user_id', 'projectaccess', ['user_id'])
+
+ # Create unique constraint to prevent duplicate access entries
+ op.create_unique_constraint('uq_projectaccess_project_user', 'projectaccess', ['project_id', 'user_id'])
+
+
+def downgrade():
+ # Drop indexes
+ op.drop_index('ix_projectaccess_user_id', 'projectaccess')
+ op.drop_index('ix_projectaccess_project_id', 'projectaccess')
+
+ # Drop table
+ op.drop_table('projectaccess')
+
diff --git a/backend/app/alembic/versions/2025110302_add_organization_invitation_table.py b/backend/app/alembic/versions/2025110302_add_organization_invitation_table.py
new file mode 100644
index 0000000000..21067412e4
--- /dev/null
+++ b/backend/app/alembic/versions/2025110302_add_organization_invitation_table.py
@@ -0,0 +1,36 @@
+"""add organization invitation table
+
+Revision ID: 2025110302
+Revises: 2025110301
+Create Date: 2025-11-03
+
+"""
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = '2025110302'
+down_revision = '2025110301'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table(
+ 'organizationinvitation',
+ sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
+ sa.Column('id', sa.Uuid(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('organization_id', sa.Uuid(), nullable=False),
+ sa.ForeignKeyConstraint(['organization_id'], ['organization.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_organizationinvitation_email'), 'organizationinvitation', ['email'], unique=False)
+
+
+def downgrade():
+ op.drop_index(op.f('ix_organizationinvitation_email'), table_name='organizationinvitation')
+ op.drop_table('organizationinvitation')
+
diff --git a/backend/app/alembic/versions/2025111101_add_photo_table.py b/backend/app/alembic/versions/2025111101_add_photo_table.py
new file mode 100644
index 0000000000..95d0fba807
--- /dev/null
+++ b/backend/app/alembic/versions/2025111101_add_photo_table.py
@@ -0,0 +1,36 @@
+"""add photo table
+
+Revision ID: 2025111101
+Revises: 2025110302
+Create Date: 2025-11-11
+"""
+
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel
+
+
+# revision identifiers, used by Alembic.
+revision = "2025111101"
+down_revision = "2025110302"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table(
+ "photo",
+ sa.Column("id", sa.UUID(), nullable=False),
+ sa.Column("filename", sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
+ sa.Column("url", sqlmodel.sql.sqltypes.AutoString(length=500), nullable=False),
+ sa.Column("created_at", sa.DateTime(), nullable=False),
+ sa.Column("gallery_id", sa.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(["gallery_id"], ["gallery.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ )
+
+
+def downgrade():
+ op.drop_table("photo")
+
+
diff --git a/backend/app/alembic/versions/2025111201_add_project_invitation_table.py b/backend/app/alembic/versions/2025111201_add_project_invitation_table.py
new file mode 100644
index 0000000000..f63f9304ae
--- /dev/null
+++ b/backend/app/alembic/versions/2025111201_add_project_invitation_table.py
@@ -0,0 +1,40 @@
+"""add project invitation table
+
+Revision ID: 2025111201
+Revises: 2025110302
+Create Date: 2025-11-12
+
+"""
+from alembic import op
+import sqlalchemy as sa
+import sqlmodel
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision = '2025111201'
+# changed from '2025110302' to '2025111101'
+down_revision = '2025111101'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table(
+ 'projectinvitation',
+ sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
+ sa.Column('role', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False),
+ sa.Column('can_comment', sa.Boolean(), nullable=False),
+ sa.Column('can_download', sa.Boolean(), nullable=False),
+ sa.Column('id', sa.Uuid(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('project_id', sa.Uuid(), nullable=False),
+ sa.ForeignKeyConstraint(['project_id'], ['project.id'], ondelete='CASCADE'),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_projectinvitation_email'), 'projectinvitation', ['email'], unique=False)
+
+
+def downgrade():
+ op.drop_index(op.f('ix_projectinvitation_email'), table_name='projectinvitation')
+ op.drop_table('projectinvitation')
+
diff --git a/backend/app/api/main.py b/backend/app/api/main.py
index eac18c8e8f..336ff8d0e5 100644
--- a/backend/app/api/main.py
+++ b/backend/app/api/main.py
@@ -1,13 +1,38 @@
from fastapi import APIRouter
-from app.api.routes import items, login, private, users, utils
+from app.api.routes import (
+ galleries,
+ invitations,
+ login,
+ organizations,
+ private,
+ project_access,
+ projects,
+ users,
+ utils,
+)
from app.core.config import settings
api_router = APIRouter()
api_router.include_router(login.router)
api_router.include_router(users.router)
api_router.include_router(utils.router)
-api_router.include_router(items.router)
+
+
+api_router.include_router(
+ organizations.router, prefix="/organizations", tags=["organizations"]
+)
+
+api_router.include_router(
+ project_access.router, prefix="/projects", tags=["project-access"]
+)
+
+api_router.include_router(projects.router, prefix="/projects", tags=["projects"])
+
+api_router.include_router(
+ invitations.router, prefix="/invitations", tags=["invitations"]
+)
+api_router.include_router(galleries.router, prefix="/galleries", tags=["galleries"])
if settings.ENVIRONMENT == "local":
diff --git a/backend/app/api/routes/galleries.py b/backend/app/api/routes/galleries.py
new file mode 100644
index 0000000000..50dc84c844
--- /dev/null
+++ b/backend/app/api/routes/galleries.py
@@ -0,0 +1,475 @@
+import uuid
+from typing import Any
+
+import os
+from io import BytesIO
+import zipfile
+from pathlib import Path
+
+from fastapi import APIRouter, HTTPException, UploadFile, File, Body
+from fastapi.responses import FileResponse, StreamingResponse
+
+from app import crud
+from app.api.deps import CurrentUser, SessionDep
+from app.models import (
+ GalleriesPublic,
+ GalleryCreate,
+ GalleryPublic,
+ GalleryUpdate,
+ Message,
+ PhotosPublic,
+ PhotoCreate,
+ PhotoPublic,
+)
+
+router = APIRouter()
+
+
+STORAGE_ROOT = Path(os.getenv("GALLERY_STORAGE_ROOT", "app_data/galleries")).resolve()
+
+
+def _gallery_storage_dir(gallery_id: uuid.UUID) -> Path:
+ return STORAGE_ROOT / str(gallery_id)
+
+
+@router.get("/", response_model=GalleriesPublic)
+def read_galleries(
+ session: SessionDep,
+ current_user: CurrentUser,
+ project_id: uuid.UUID | None = None,
+ skip: int = 0,
+ limit: int = 100,
+) -> Any:
+ """
+ Retrieve galleries. If project_id is provided, get galleries for that project.
+ Otherwise, get all galleries based on user type:
+ - Team members: all galleries from their organization
+ - Clients: galleries from projects they have access to
+ """
+ user_type = getattr(current_user, "user_type", None)
+
+ if project_id:
+ # Verify user has access to this project
+ project = crud.get_project(session=session, project_id=project_id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Check access based on user type
+ if user_type == "client":
+ # Client must have explicit access
+ if not crud.user_has_project_access(
+ session=session, project_id=project_id, user_id=current_user.id
+ ):
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+ else:
+ # Team member must be in same organization
+ if (
+ not current_user.organization_id
+ or project.organization_id != current_user.organization_id
+ ):
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ galleries = crud.get_galleries_by_project(
+ session=session, project_id=project_id, skip=skip, limit=limit
+ )
+ count = len(galleries) # Simple count for project galleries
+ else:
+ # No specific project - list all accessible galleries
+ if user_type == "client":
+ # Get galleries from all projects the client has access to
+ accessible_projects = crud.get_user_accessible_projects(
+ session=session, user_id=current_user.id, skip=0, limit=1000
+ )
+ project_ids = [p.id for p in accessible_projects]
+
+ # Get galleries for all accessible projects
+ galleries = []
+ for pid in project_ids[skip : skip + limit]:
+ project_galleries = crud.get_galleries_by_project(
+ session=session, project_id=pid, skip=0, limit=100
+ )
+ galleries.extend(project_galleries)
+
+ count = sum(
+ len(
+ crud.get_galleries_by_project(
+ session=session, project_id=pid, skip=0, limit=1000
+ )
+ )
+ for pid in project_ids
+ )
+ else:
+ # Team member - get all galleries from organization
+ if not current_user.organization_id:
+ raise HTTPException(
+ status_code=400, detail="User is not part of an organization"
+ )
+
+ galleries = crud.get_galleries_by_organization(
+ session=session,
+ organization_id=current_user.organization_id,
+ skip=skip,
+ limit=limit,
+ )
+ count = crud.count_galleries_by_organization(
+ session=session, organization_id=current_user.organization_id
+ )
+
+ return GalleriesPublic(data=galleries, count=count)
+
+
+@router.post("/", response_model=GalleryPublic)
+def create_gallery(
+ *, session: SessionDep, current_user: CurrentUser, gallery_in: GalleryCreate
+) -> Any:
+ """
+ Create new gallery. Only team members can create galleries.
+ """
+ user_type = getattr(current_user, "user_type", None)
+
+ # Only team members can create galleries
+ if user_type != "team_member":
+ raise HTTPException(
+ status_code=403, detail="Only team members can create galleries"
+ )
+
+ if not current_user.organization_id:
+ raise HTTPException(
+ status_code=400, detail="User is not part of an organization"
+ )
+
+ # Verify project belongs to user's organization
+ project = crud.get_project(session=session, project_id=gallery_in.project_id)
+ if not project or project.organization_id != current_user.organization_id:
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ gallery = crud.create_gallery(session=session, gallery_in=gallery_in)
+ return gallery
+
+
+@router.get("/{id}/photos", response_model=PhotosPublic)
+def list_gallery_photos(
+ session: SessionDep, current_user: CurrentUser, id: uuid.UUID, skip: int = 0, limit: int = 100
+) -> Any:
+ """List photos in a gallery. Visible to clients with access and team in org."""
+ gallery = crud.get_gallery(session=session, gallery_id=id)
+ if not gallery:
+ raise HTTPException(status_code=404, detail="Gallery not found")
+
+ # Access check (same as read_gallery)
+ user_type = getattr(current_user, "user_type", None)
+ project = crud.get_project(session=session, project_id=gallery.project_id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ if user_type == "client":
+ if not crud.user_has_project_access(
+ session=session, project_id=project.id, user_id=current_user.id
+ ):
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+ else:
+ if (
+ not current_user.organization_id
+ or project.organization_id != current_user.organization_id
+ ):
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ photos = crud.get_photos_by_gallery(session=session, gallery_id=id, skip=skip, limit=limit)
+ return PhotosPublic(
+ data=[PhotoPublic.model_validate(p) for p in photos], count=len(photos)
+ )
+
+
+@router.post("/{id}/photos", response_model=PhotosPublic)
+async def upload_gallery_photos(
+ id: uuid.UUID,
+ session: SessionDep,
+ current_user: CurrentUser,
+ files: list[UploadFile] = File(...),
+) -> Any:
+ """Upload one or more photos to a gallery. Only team members; max 20 photos total per gallery."""
+ # Permission: only team members
+ user_type = getattr(current_user, "user_type", None)
+ if user_type != "team_member":
+ raise HTTPException(status_code=403, detail="Only team members can upload photos")
+
+ gallery = crud.get_gallery(session=session, gallery_id=id)
+ if not gallery:
+ raise HTTPException(status_code=404, detail="Gallery not found")
+
+ # Org check
+ project = crud.get_project(session=session, project_id=gallery.project_id)
+ if not project or project.organization_id != current_user.organization_id:
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ # Enforce max 20 photos
+ existing_count = crud.count_photos_in_gallery(session=session, gallery_id=id)
+ if existing_count + len(files) > 20:
+ raise HTTPException(
+ status_code=400, detail="Gallery can hold at most 20 photos"
+ )
+
+ storage_dir = _gallery_storage_dir(id)
+ storage_dir.mkdir(parents=True, exist_ok=True)
+
+ saved = []
+ for uf in files:
+ # Normalize filename
+ safe_name = os.path.basename(uf.filename or "photo")
+ target_path = storage_dir / safe_name
+ # If duplicate filename, add suffix
+ counter = 1
+ while target_path.exists():
+ stem = Path(safe_name).stem
+ ext = Path(safe_name).suffix
+ target_path = storage_dir / f"{stem}-{counter}{ext}"
+ counter += 1
+ content = await uf.read()
+ file_size = len(content) # Get file size in bytes
+ with open(target_path, "wb") as out:
+ out.write(content)
+
+ # Create photo record; url points to file-serving endpoint
+ rel_filename = target_path.name
+ photo = crud.create_photo(
+ session=session,
+ photo_in=PhotoCreate(
+ gallery_id=id,
+ filename=rel_filename,
+ url=f"/api/v1/galleries/{id}/photos/files/{rel_filename}",
+ file_size=file_size,
+ ),
+ )
+ saved.append(photo)
+
+ return PhotosPublic(
+ data=[PhotoPublic.model_validate(p) for p in saved], count=len(saved)
+ )
+
+
+@router.delete("/{id}/photos")
+def delete_gallery_photos(
+ id: uuid.UUID,
+ session: SessionDep,
+ current_user: CurrentUser,
+ photo_ids: list[uuid.UUID] = Body(..., embed=True),
+) -> Message:
+ """Delete selected photos. Only team members. Also removes files from storage."""
+ user_type = getattr(current_user, "user_type", None)
+ if user_type != "team_member":
+ raise HTTPException(status_code=403, detail="Only team members can delete photos")
+
+ gallery = crud.get_gallery(session=session, gallery_id=id)
+ if not gallery:
+ raise HTTPException(status_code=404, detail="Gallery not found")
+
+ project = crud.get_project(session=session, project_id=gallery.project_id)
+ if not project or project.organization_id != current_user.organization_id:
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ # Remove files
+ storage_dir = _gallery_storage_dir(id)
+ deleted = 0
+ from sqlmodel import select
+ from app.models import Photo
+
+ for pid in photo_ids:
+ photo = session.get(Photo, pid)
+ if photo and photo.gallery_id == id:
+ file_path = storage_dir / photo.filename
+ try:
+ if file_path.exists():
+ file_path.unlink()
+ except Exception:
+ pass
+ deleted += 1
+
+ # Remove db rows
+ crud.delete_photos(session=session, gallery_id=id, photo_ids=photo_ids)
+
+ return Message(message=f"Deleted {deleted} photos")
+
+
+@router.get("/{id}/photos/files/{filename}")
+def get_photo_file(id: uuid.UUID, filename: str) -> Any:
+ """Serve a stored photo file publicly."""
+ file_path = _gallery_storage_dir(id) / filename
+ if not file_path.exists():
+ raise HTTPException(status_code=404, detail="File not found")
+ return FileResponse(file_path)
+
+
+@router.get("/{id}/download-all")
+def download_all_photos(id: uuid.UUID, session: SessionDep) -> Any:
+ """Download all photos in the gallery as a ZIP, publicly accessible."""
+ storage_dir = _gallery_storage_dir(id)
+ if not storage_dir.exists():
+ raise HTTPException(status_code=404, detail="Gallery not found or empty")
+
+ # Get project name for filename
+ gallery = crud.get_gallery(session=session, gallery_id=id)
+ project_name = "Project"
+ if gallery:
+ project = crud.get_project(session=session, project_id=gallery.project_id)
+ if project:
+ # Sanitize project name for filename
+ project_name = "".join(c if c.isalnum() or c in (" ", "-", "_") else "_" for c in project.name)
+ project_name = project_name.replace(" ", "_")
+
+ memfile = BytesIO()
+ with zipfile.ZipFile(memfile, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
+ for path in storage_dir.glob("*"):
+ if path.is_file():
+ zf.write(path, arcname=path.name)
+ memfile.seek(0)
+ filename = f"Mosaic-{project_name}-Photos.zip"
+ return StreamingResponse(
+ memfile,
+ media_type="application/zip",
+ headers={
+ "Content-Disposition": f'attachment; filename="{filename}"'
+ },
+ )
+
+
+@router.post("/{id}/photos/download")
+def download_selected_photos(
+ id: uuid.UUID,
+ session: SessionDep,
+ photo_ids: list[uuid.UUID] = Body(..., embed=True),
+) -> Any:
+ """Download selected photos in the gallery as a ZIP, publicly accessible."""
+ storage_dir = _gallery_storage_dir(id)
+ if not storage_dir.exists():
+ raise HTTPException(status_code=404, detail="Gallery not found or empty")
+
+ # Get project name for filename
+ gallery = crud.get_gallery(session=session, gallery_id=id)
+ project_name = "Project"
+ if gallery:
+ project = crud.get_project(session=session, project_id=gallery.project_id)
+ if project:
+ # Sanitize project name for filename
+ project_name = "".join(c if c.isalnum() or c in (" ", "-", "_") else "_" for c in project.name)
+ project_name = project_name.replace(" ", "_")
+
+ # Collect filenames by reading DB
+ from app.models import Photo
+ files: list[Path] = []
+ for pid in photo_ids:
+ photo = session.get(Photo, pid)
+ # Fallback: construct by filename only if not found
+ if photo and photo.gallery_id == id:
+ fp = storage_dir / photo.filename
+ if fp.exists():
+ files.append(fp)
+
+ if not files:
+ raise HTTPException(status_code=404, detail="No matching photos found")
+
+ memfile = BytesIO()
+ with zipfile.ZipFile(memfile, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
+ for path in files:
+ zf.write(path, arcname=path.name)
+ memfile.seek(0)
+ filename = f"Mosaic-{project_name}-Photos.zip"
+ return StreamingResponse(
+ memfile,
+ media_type="application/zip",
+ headers={
+ "Content-Disposition": f'attachment; filename="{filename}"'
+ },
+ )
+
+@router.get("/{id}", response_model=GalleryPublic)
+def read_gallery(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
+ """
+ Get gallery by ID.
+ """
+ gallery = crud.get_gallery(session=session, gallery_id=id)
+ if not gallery:
+ raise HTTPException(status_code=404, detail="Gallery not found")
+
+ # Check access based on user type
+ user_type = getattr(current_user, "user_type", None)
+ project = crud.get_project(session=session, project_id=gallery.project_id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ if user_type == "client":
+ # Client must have access to the project
+ if not crud.user_has_project_access(
+ session=session, project_id=project.id, user_id=current_user.id
+ ):
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+ else:
+ # Team member must be in same organization
+ if (
+ not current_user.organization_id
+ or project.organization_id != current_user.organization_id
+ ):
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ return gallery
+
+
+@router.put("/{id}", response_model=GalleryPublic)
+def update_gallery(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ id: uuid.UUID,
+ gallery_in: GalleryUpdate,
+) -> Any:
+ """
+ Update a gallery. Only team members can update galleries.
+ """
+ user_type = getattr(current_user, "user_type", None)
+
+ # Only team members can update galleries
+ if user_type != "team_member":
+ raise HTTPException(
+ status_code=403, detail="Only team members can update galleries"
+ )
+
+ gallery = crud.get_gallery(session=session, gallery_id=id)
+ if not gallery:
+ raise HTTPException(status_code=404, detail="Gallery not found")
+
+ # Check if gallery's project belongs to user's organization
+ project = crud.get_project(session=session, project_id=gallery.project_id)
+ if not project or project.organization_id != current_user.organization_id:
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ gallery = crud.update_gallery(
+ session=session, db_gallery=gallery, gallery_in=gallery_in
+ )
+ return gallery
+
+
+@router.delete("/{id}")
+def delete_gallery(
+ session: SessionDep, current_user: CurrentUser, id: uuid.UUID
+) -> Message:
+ """
+ Delete a gallery. Only team members can delete galleries.
+ """
+ user_type = getattr(current_user, "user_type", None)
+
+ # Only team members can delete galleries
+ if user_type != "team_member":
+ raise HTTPException(
+ status_code=403, detail="Only team members can delete galleries"
+ )
+
+ gallery = crud.get_gallery(session=session, gallery_id=id)
+ if not gallery:
+ raise HTTPException(status_code=404, detail="Gallery not found")
+
+ # Check if gallery's project belongs to user's organization
+ project = crud.get_project(session=session, project_id=gallery.project_id)
+ if not project or project.organization_id != current_user.organization_id:
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ crud.delete_gallery(session=session, gallery_id=id)
+ return Message(message="Gallery deleted successfully")
diff --git a/backend/app/api/routes/invitations.py b/backend/app/api/routes/invitations.py
new file mode 100644
index 0000000000..250ff3a408
--- /dev/null
+++ b/backend/app/api/routes/invitations.py
@@ -0,0 +1,104 @@
+import uuid
+from typing import Any
+
+from fastapi import APIRouter, HTTPException
+from sqlmodel import func, select
+
+from app.api.deps import CurrentUser, SessionDep
+from app.models import (
+ OrganizationInvitation,
+ OrganizationInvitationCreate,
+ OrganizationInvitationPublic,
+ OrganizationInvitationsPublic,
+)
+
+router = APIRouter()
+
+
+@router.post("/", response_model=OrganizationInvitationPublic)
+def create_invitation(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ invitation_in: OrganizationInvitationCreate,
+) -> Any:
+ """
+ Create an organization invitation.
+ Team members can invite people to their organization.
+ """
+ if not current_user.organization_id:
+ raise HTTPException(
+ status_code=400,
+ detail="You must be part of an organization to invite others",
+ )
+
+ # Check if invitation already exists
+ statement = select(OrganizationInvitation).where(
+ OrganizationInvitation.email == invitation_in.email,
+ OrganizationInvitation.organization_id == current_user.organization_id,
+ )
+ existing = session.exec(statement).first()
+ if existing:
+ raise HTTPException(
+ status_code=400,
+ detail="An invitation has already been sent to this email",
+ )
+
+ invitation = OrganizationInvitation(
+ email=invitation_in.email,
+ organization_id=current_user.organization_id,
+ )
+ session.add(invitation)
+ session.commit()
+ session.refresh(invitation)
+ return invitation
+
+
+@router.get("/", response_model=OrganizationInvitationsPublic)
+def read_invitations(
+ session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
+) -> Any:
+ """
+ Retrieve invitations for the current user's organization.
+ """
+ if not current_user.organization_id:
+ raise HTTPException(
+ status_code=400,
+ detail="You must be part of an organization",
+ )
+
+ count_statement = (
+ select(func.count())
+ .select_from(OrganizationInvitation)
+ .where(OrganizationInvitation.organization_id == current_user.organization_id)
+ )
+ count = session.exec(count_statement).one()
+
+ statement = (
+ select(OrganizationInvitation)
+ .where(OrganizationInvitation.organization_id == current_user.organization_id)
+ .offset(skip)
+ .limit(limit)
+ )
+ invitations = session.exec(statement).all()
+
+ return OrganizationInvitationsPublic(data=invitations, count=count)
+
+
+@router.delete("/{invitation_id}")
+def delete_invitation(
+ session: SessionDep, current_user: CurrentUser, invitation_id: uuid.UUID
+) -> Any:
+ """
+ Delete an invitation.
+ """
+ invitation = session.get(OrganizationInvitation, invitation_id)
+ if not invitation:
+ raise HTTPException(status_code=404, detail="Invitation not found")
+
+ if invitation.organization_id != current_user.organization_id:
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ session.delete(invitation)
+ session.commit()
+ return {"message": "Invitation deleted"}
diff --git a/backend/app/api/routes/items.py b/backend/app/api/routes/items.py
deleted file mode 100644
index 177dc1e476..0000000000
--- a/backend/app/api/routes/items.py
+++ /dev/null
@@ -1,109 +0,0 @@
-import uuid
-from typing import Any
-
-from fastapi import APIRouter, HTTPException
-from sqlmodel import func, select
-
-from app.api.deps import CurrentUser, SessionDep
-from app.models import Item, ItemCreate, ItemPublic, ItemsPublic, ItemUpdate, Message
-
-router = APIRouter(prefix="/items", tags=["items"])
-
-
-@router.get("/", response_model=ItemsPublic)
-def read_items(
- session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
-) -> Any:
- """
- Retrieve items.
- """
-
- if current_user.is_superuser:
- count_statement = select(func.count()).select_from(Item)
- count = session.exec(count_statement).one()
- statement = select(Item).offset(skip).limit(limit)
- items = session.exec(statement).all()
- else:
- count_statement = (
- select(func.count())
- .select_from(Item)
- .where(Item.owner_id == current_user.id)
- )
- count = session.exec(count_statement).one()
- statement = (
- select(Item)
- .where(Item.owner_id == current_user.id)
- .offset(skip)
- .limit(limit)
- )
- items = session.exec(statement).all()
-
- return ItemsPublic(data=items, count=count)
-
-
-@router.get("/{id}", response_model=ItemPublic)
-def read_item(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
- """
- Get item by ID.
- """
- item = session.get(Item, id)
- if not item:
- raise HTTPException(status_code=404, detail="Item not found")
- if not current_user.is_superuser and (item.owner_id != current_user.id):
- raise HTTPException(status_code=400, detail="Not enough permissions")
- return item
-
-
-@router.post("/", response_model=ItemPublic)
-def create_item(
- *, session: SessionDep, current_user: CurrentUser, item_in: ItemCreate
-) -> Any:
- """
- Create new item.
- """
- item = Item.model_validate(item_in, update={"owner_id": current_user.id})
- session.add(item)
- session.commit()
- session.refresh(item)
- return item
-
-
-@router.put("/{id}", response_model=ItemPublic)
-def update_item(
- *,
- session: SessionDep,
- current_user: CurrentUser,
- id: uuid.UUID,
- item_in: ItemUpdate,
-) -> Any:
- """
- Update an item.
- """
- item = session.get(Item, id)
- if not item:
- raise HTTPException(status_code=404, detail="Item not found")
- if not current_user.is_superuser and (item.owner_id != current_user.id):
- raise HTTPException(status_code=400, detail="Not enough permissions")
- update_dict = item_in.model_dump(exclude_unset=True)
- item.sqlmodel_update(update_dict)
- session.add(item)
- session.commit()
- session.refresh(item)
- return item
-
-
-@router.delete("/{id}")
-def delete_item(
- session: SessionDep, current_user: CurrentUser, id: uuid.UUID
-) -> Message:
- """
- Delete an item.
- """
- item = session.get(Item, id)
- if not item:
- raise HTTPException(status_code=404, detail="Item not found")
- if not current_user.is_superuser and (item.owner_id != current_user.id):
- raise HTTPException(status_code=400, detail="Not enough permissions")
- session.delete(item)
- session.commit()
- return Message(message="Item deleted successfully")
diff --git a/backend/app/api/routes/organizations.py b/backend/app/api/routes/organizations.py
new file mode 100644
index 0000000000..c4eadffc9c
--- /dev/null
+++ b/backend/app/api/routes/organizations.py
@@ -0,0 +1,114 @@
+import uuid
+from typing import Any
+
+from fastapi import APIRouter, HTTPException
+
+from app import crud
+from app.api.deps import CurrentUser, SessionDep
+from app.models import (
+ Organization,
+ OrganizationCreate,
+ OrganizationPublic,
+ OrganizationUpdate,
+)
+
+router = APIRouter()
+
+
+@router.post("/", response_model=OrganizationPublic)
+def create_organization(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ organization_in: OrganizationCreate,
+) -> Any:
+ """
+ Create a new organization.
+ Only team members without an organization can create one.
+ """
+ # Check user is a team member
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403,
+ detail="Only team members can create organizations",
+ )
+
+ # Check user doesn't already have an organization
+ if current_user.organization_id:
+ raise HTTPException(
+ status_code=400,
+ detail="You already belong to an organization",
+ )
+
+ # Create the organization
+ organization = crud.create_organization(
+ session=session, organization_in=organization_in
+ )
+
+ # Assign the user to the new organization
+ current_user.organization_id = organization.id
+ session.add(current_user)
+ session.commit()
+ session.refresh(current_user)
+
+ return organization
+
+
+@router.get("/{organization_id}", response_model=OrganizationPublic)
+def read_organization(
+ session: SessionDep, current_user: CurrentUser, organization_id: uuid.UUID
+) -> Any:
+ """
+ Get organization by ID.
+ Users can only view their own organization.
+ """
+ organization = session.get(Organization, organization_id)
+ if not organization:
+ raise HTTPException(status_code=404, detail="Organization not found")
+
+ # Only allow viewing own organization (unless superuser)
+ if (
+ not current_user.is_superuser
+ and current_user.organization_id != organization_id
+ ):
+ raise HTTPException(
+ status_code=403,
+ detail="Not enough permissions",
+ )
+
+ return organization
+
+
+@router.put("/{organization_id}", response_model=OrganizationPublic)
+def update_organization(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ organization_id: uuid.UUID,
+ organization_in: OrganizationUpdate,
+) -> Any:
+ """
+ Update an organization.
+ Only team members from that organization can update it.
+ """
+ organization = session.get(Organization, organization_id)
+ if not organization:
+ raise HTTPException(status_code=404, detail="Organization not found")
+
+ # Only allow updating own organization (unless superuser)
+ if (
+ not current_user.is_superuser
+ and current_user.organization_id != organization_id
+ ):
+ raise HTTPException(
+ status_code=403,
+ detail="Not enough permissions",
+ )
+
+ update_dict = organization_in.model_dump(exclude_unset=True)
+ organization.sqlmodel_update(update_dict)
+ session.add(organization)
+ session.commit()
+ session.refresh(organization)
+
+ return organization
diff --git a/backend/app/api/routes/private.py b/backend/app/api/routes/private.py
index 9f33ef1900..bd44e03b67 100644
--- a/backend/app/api/routes/private.py
+++ b/backend/app/api/routes/private.py
@@ -3,10 +3,10 @@
from fastapi import APIRouter
from pydantic import BaseModel
+from app import crud
from app.api.deps import SessionDep
-from app.core.security import get_password_hash
from app.models import (
- User,
+ UserCreate,
UserPublic,
)
@@ -26,13 +26,11 @@ def create_user(user_in: PrivateUserCreate, session: SessionDep) -> Any:
Create a new user.
"""
- user = User(
+ user_create = UserCreate(
email=user_in.email,
full_name=user_in.full_name,
- hashed_password=get_password_hash(user_in.password),
+ password=user_in.password,
)
- session.add(user)
- session.commit()
-
+ user = crud.create_user(session=session, user_create=user_create)
return user
diff --git a/backend/app/api/routes/project_access.py b/backend/app/api/routes/project_access.py
new file mode 100644
index 0000000000..670e00ab30
--- /dev/null
+++ b/backend/app/api/routes/project_access.py
@@ -0,0 +1,294 @@
+import uuid
+from typing import Any
+
+from fastapi import APIRouter, HTTPException
+
+from app import crud
+from app.api.deps import CurrentUser, SessionDep
+from app.models import (
+ Message,
+ ProjectAccessCreate,
+ ProjectAccessInviteByEmail,
+ ProjectAccessPublic,
+ ProjectAccessUpdate,
+ ProjectAccessWithUser,
+ User,
+)
+
+router = APIRouter()
+
+
+# AN - New endpoint to get all projects the current user has access to
+@router.get("/my-projects")
+def read_my_projects(
+ session: SessionDep,
+ current_user: CurrentUser,
+) -> Any:
+ """
+ Get all projects the current user has access to.
+ For clients: returns projects they've been invited to.
+ For team members: returns all projects in their organization.
+ """
+ if getattr(current_user, "user_type", None) == "client":
+ # Use the existing function - perfect!
+ projects = crud.get_user_accessible_projects(
+ session=session, user_id=current_user.id, skip=0, limit=1000
+ )
+ return {"data": projects, "count": len(projects)}
+ elif getattr(current_user, "user_type", None) == "team_member":
+ if not current_user.organization_id:
+ return {"data": [], "count": 0}
+ projects = crud.get_projects_by_organization(
+ session=session,
+ organization_id=current_user.organization_id,
+ skip=0,
+ limit=1000
+ )
+ return {"data": projects, "count": len(projects)}
+ else:
+ return {"data": [], "count": 0}
+
+@router.post("/{project_id}/access/invite-by-email")
+def invite_client_by_email(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ project_id: uuid.UUID,
+ invite_data: ProjectAccessInviteByEmail,
+) -> Any:
+ """
+ Invite a client to a project by email.
+ If user exists: grants immediate access
+ If user doesn't exist: creates a pending invitation
+ Only team members can invite clients.
+ """
+ # Check if current user is a team member
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403,
+ detail="Only team members can invite clients to projects",
+ )
+
+ # Check if project exists and user has access to it
+ project = crud.get_project(session=session, project_id=project_id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Check if current user's organization owns the project
+ if (
+ not current_user.organization_id
+ or current_user.organization_id != project.organization_id
+ ):
+ raise HTTPException(
+ status_code=403,
+ detail="You don't have permission to manage this project",
+ )
+
+ # Invite client by email
+ try:
+ access, is_pending = crud.invite_client_by_email(
+ session=session,
+ project_id=project_id,
+ email=invite_data.email,
+ role=invite_data.role,
+ can_comment=invite_data.can_comment,
+ can_download=invite_data.can_download,
+ )
+
+ if is_pending:
+ return {
+ "message": "Invitation sent. Client will get access when they sign up with this email.",
+ "is_pending": True,
+ "email": invite_data.email,
+ }
+ else:
+ return {
+ "message": "Client invited successfully",
+ "is_pending": False,
+ "access": ProjectAccessPublic.model_validate(access),
+ }
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@router.post("/{project_id}/access", response_model=ProjectAccessPublic)
+def grant_project_access(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ project_id: uuid.UUID,
+ user_id: uuid.UUID,
+ role: str = "viewer",
+ can_comment: bool = True,
+ can_download: bool = True,
+) -> Any:
+ """
+ Grant a user access to a project (invite a client).
+ Only team members can invite clients.
+ DEPRECATED: Use /invite-by-email endpoint instead.
+ """
+ # Check if current user is a team member
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403,
+ detail="Only team members can invite clients to projects",
+ )
+
+ # Check if project exists and user has access to it
+ project = crud.get_project(session=session, project_id=project_id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Check if current user's organization owns the project
+ if (
+ not current_user.organization_id
+ or current_user.organization_id != project.organization_id
+ ):
+ raise HTTPException(
+ status_code=403,
+ detail="You don't have permission to manage this project",
+ )
+
+ # Check if user to be invited exists
+ user_to_invite = session.get(User, user_id)
+ if not user_to_invite:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ # Create access
+ access_in = ProjectAccessCreate(
+ project_id=project_id,
+ user_id=user_id,
+ role=role,
+ can_comment=can_comment,
+ can_download=can_download,
+ )
+ access = crud.create_project_access(session=session, access_in=access_in)
+ return access
+
+
+@router.get("/{project_id}/access", response_model=list[ProjectAccessWithUser])
+def read_project_access_list(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ project_id: uuid.UUID,
+) -> Any:
+ """
+ Get list of users with access to a project.
+ Only team members from the project's organization can see this.
+ """
+ # Check if project exists
+ project = crud.get_project(session=session, project_id=project_id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Check permissions
+ if getattr(current_user, "user_type", None) == "team_member":
+ if current_user.organization_id != project.organization_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+ else:
+ # Clients can only see their own access
+ if not crud.user_has_project_access(
+ session=session, project_id=project_id, user_id=current_user.id
+ ):
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ access_list = crud.get_project_access_list(session=session, project_id=project_id)
+ # Convert to ProjectAccessWithUser
+ result = []
+ for access in access_list:
+ user = session.get(User, access.user_id)
+ if user:
+ from app.models import UserPublic
+
+ result.append(
+ ProjectAccessWithUser(
+ id=access.id,
+ created_at=access.created_at,
+ project_id=access.project_id,
+ user_id=access.user_id,
+ role=access.role,
+ can_comment=access.can_comment,
+ can_download=access.can_download,
+ user=UserPublic.model_validate(user),
+ )
+ )
+ return result
+
+
+@router.delete("/{project_id}/access/{user_id}", response_model=Message)
+def revoke_project_access(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ project_id: uuid.UUID,
+ user_id: uuid.UUID,
+) -> Any:
+ """
+ Revoke a user's access to a project.
+ Only team members from the project's organization can do this.
+ """
+ # Check if current user is a team member
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403,
+ detail="Only team members can revoke project access",
+ )
+
+ # Check if project exists
+ project = crud.get_project(session=session, project_id=project_id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Check permissions
+ if current_user.organization_id != project.organization_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ # Revoke access
+ crud.delete_project_access(session=session, project_id=project_id, user_id=user_id)
+ return Message(message="Access revoked successfully")
+
+
+@router.patch("/{project_id}/access/{user_id}", response_model=ProjectAccessPublic)
+def update_project_access_permissions(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ project_id: uuid.UUID,
+ user_id: uuid.UUID,
+ access_in: ProjectAccessUpdate,
+) -> Any:
+ """
+ Update a user's project access permissions.
+ Only team members from the project's organization can do this.
+ """
+ # Check if current user is a team member
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403,
+ detail="Only team members can update project access",
+ )
+
+ # Check if project exists
+ project = crud.get_project(session=session, project_id=project_id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Check permissions
+ if current_user.organization_id != project.organization_id:
+ raise HTTPException(status_code=403, detail="Access denied")
+
+ # Get existing access
+ db_access = crud.get_project_access(
+ session=session, project_id=project_id, user_id=user_id
+ )
+ if not db_access:
+ raise HTTPException(status_code=404, detail="Access not found")
+
+ # Update access
+ access = crud.update_project_access(
+ session=session, db_access=db_access, access_in=access_in
+ )
+ return access
+
+
diff --git a/backend/app/api/routes/projects.py b/backend/app/api/routes/projects.py
new file mode 100644
index 0000000000..b035391fc3
--- /dev/null
+++ b/backend/app/api/routes/projects.py
@@ -0,0 +1,237 @@
+import os
+import uuid
+from pathlib import Path
+from typing import Any
+
+from fastapi import APIRouter, HTTPException
+from sqlmodel import select
+
+from app import crud
+from app.api.deps import CurrentUser, SessionDep
+from app.models import (
+ DashboardStats,
+ GalleryCreate,
+ Message,
+ ProjectCreate,
+ ProjectPublic,
+ ProjectsPublic,
+ ProjectUpdate,
+)
+
+router = APIRouter()
+
+
+def _gallery_storage_root() -> Path:
+ """Return the root directory where gallery photos are stored.
+
+ Mirrors the logic in app.api.routes.galleries.STORAGE_ROOT without importing it
+ directly here to avoid any circular import issues at app startup.
+ """
+ return Path(os.getenv("GALLERY_STORAGE_ROOT", "app_data/galleries")).resolve()
+
+
+@router.get("/", response_model=ProjectsPublic)
+def read_projects(
+ session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
+) -> Any:
+ """
+ Retrieve projects.
+ - Team members see projects from their organization
+ - Clients see projects they have been invited to
+ """
+ if getattr(current_user, "user_type", None) == "client":
+ # Clients see only projects they have access to
+ projects = crud.get_user_accessible_projects(
+ session=session,
+ user_id=current_user.id,
+ skip=skip,
+ limit=limit,
+ )
+ count = crud.count_user_accessible_projects(
+ session=session, user_id=current_user.id
+ )
+ else:
+ # Team members see projects from their organization
+ if not current_user.organization_id:
+ raise HTTPException(
+ status_code=400, detail="User is not part of an organization"
+ )
+
+ projects = crud.get_projects_by_organization(
+ session=session,
+ organization_id=current_user.organization_id,
+ skip=skip,
+ limit=limit,
+ )
+ count = crud.count_projects_by_organization(
+ session=session, organization_id=current_user.organization_id
+ )
+
+ return ProjectsPublic(data=projects, count=count)
+
+
+@router.get("/stats", response_model=DashboardStats)
+def read_dashboard_stats(session: SessionDep, current_user: CurrentUser) -> Any:
+ """
+ Get dashboard statistics for the current user's organization.
+ Only available to team members.
+ """
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403, detail="Dashboard stats only available to team members"
+ )
+
+ if not current_user.organization_id:
+ raise HTTPException(
+ status_code=400, detail="User is not part of an organization"
+ )
+
+ return crud.get_dashboard_stats(
+ session=session, organization_id=current_user.organization_id
+ )
+
+
+@router.post("/", response_model=ProjectPublic)
+def create_project(
+ *, session: SessionDep, current_user: CurrentUser, project_in: ProjectCreate
+) -> Any:
+ """
+ Create new project.
+ Only team members can create projects.
+ """
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403, detail="Only team members can create projects"
+ )
+
+ if not current_user.organization_id:
+ raise HTTPException(
+ status_code=400, detail="User is not part of an organization"
+ )
+
+ # Ensure the project is being created for the user's organization
+ if project_in.organization_id != current_user.organization_id:
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ project = crud.create_project(session=session, project_in=project_in)
+
+ # Automatically create a gallery for the project
+ gallery_in = GalleryCreate(
+ name=f"{project.name} - Gallery",
+ project_id=project.id,
+ status="draft",
+ )
+ crud.create_gallery(session=session, gallery_in=gallery_in)
+
+ return project
+
+
+@router.get("/{id}", response_model=ProjectPublic)
+def read_project(session: SessionDep, current_user: CurrentUser, id: uuid.UUID) -> Any:
+ """
+ Get project by ID.
+ """
+ project = crud.get_project(session=session, project_id=id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Check permissions based on user type
+ if getattr(current_user, "user_type", None) == "client":
+ # Clients need explicit access
+ if not crud.user_has_project_access(
+ session=session, project_id=id, user_id=current_user.id
+ ):
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+ else:
+ # Team members need organization match
+ if project.organization_id != current_user.organization_id:
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ return project
+
+
+@router.put("/{id}", response_model=ProjectPublic)
+def update_project(
+ *,
+ session: SessionDep,
+ current_user: CurrentUser,
+ id: uuid.UUID,
+ project_in: ProjectUpdate,
+) -> Any:
+ """
+ Update a project.
+ Only team members from the project's organization can update projects.
+ """
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403, detail="Only team members can update projects"
+ )
+
+ project = crud.get_project(session=session, project_id=id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Check if project belongs to user's organization
+ if project.organization_id != current_user.organization_id:
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ project = crud.update_project(
+ session=session, db_project=project, project_in=project_in
+ )
+ return project
+
+
+@router.delete("/{id}")
+def delete_project(
+ session: SessionDep, current_user: CurrentUser, id: uuid.UUID
+) -> Message:
+ """
+ Delete a project.
+ Only team members from the project's organization can delete projects.
+ """
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403, detail="Only team members can delete projects"
+ )
+
+ project = crud.get_project(session=session, project_id=id)
+ if not project:
+ raise HTTPException(status_code=404, detail="Project not found")
+
+ # Check if project belongs to user's organization
+ if project.organization_id != current_user.organization_id:
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ # Before deleting the project from DB, remove gallery folders & photos from storage.
+ from app.models import Gallery
+
+ storage_root = _gallery_storage_root()
+
+ # Find all galleries for this project
+ statement = select(Gallery).where(Gallery.project_id == id)
+ galleries = session.exec(statement).all()
+
+ for gallery in galleries:
+ gallery_dir = storage_root / str(gallery.id)
+ if gallery_dir.exists() and gallery_dir.is_dir():
+ # Best-effort recursive delete of all files and subdirs
+ for root, dirs, files in os.walk(gallery_dir, topdown=False):
+ for name in files:
+ try:
+ (Path(root) / name).unlink()
+ except OSError:
+ pass
+ for name in dirs:
+ try:
+ (Path(root) / name).rmdir()
+ except OSError:
+ pass
+ try:
+ gallery_dir.rmdir()
+ except OSError:
+ # If something remains locked, ignore; DB rows will still be removed
+ pass
+
+ # Delete the project. DB-level cascading will remove galleries & photos.
+ crud.delete_project(session=session, project_id=id)
+ return Message(message="Project deleted successfully")
diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py
index 6429818458..0634c43c36 100644
--- a/backend/app/api/routes/users.py
+++ b/backend/app/api/routes/users.py
@@ -2,7 +2,7 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
-from sqlmodel import col, delete, func, select
+from sqlmodel import func, select
from app import crud
from app.api.deps import (
@@ -13,7 +13,6 @@
from app.core.config import settings
from app.core.security import get_password_hash, verify_password
from app.models import (
- Item,
Message,
UpdatePassword,
User,
@@ -48,6 +47,30 @@ def read_users(session: SessionDep, skip: int = 0, limit: int = 100) -> Any:
return UsersPublic(data=users, count=count)
+@router.get("/clients", response_model=UsersPublic)
+def read_clients(
+ session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
+) -> Any:
+ """
+ Retrieve client users. Team members can access this to invite clients.
+ """
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403,
+ detail="Only team members can list clients",
+ )
+
+ count_statement = (
+ select(func.count()).select_from(User).where(User.user_type == "client")
+ )
+ count = session.exec(count_statement).one()
+
+ statement = select(User).where(User.user_type == "client").offset(skip).limit(limit)
+ users = session.exec(statement).all()
+
+ return UsersPublic(data=users, count=count)
+
+
@router.post(
"/", dependencies=[Depends(get_current_active_superuser)], response_model=UserPublic
)
@@ -143,15 +166,175 @@ def delete_user_me(session: SessionDep, current_user: CurrentUser) -> Any:
def register_user(session: SessionDep, user_in: UserRegister) -> Any:
"""
Create new user without the need to be logged in.
+ Team members are assigned to an organization only if they were invited.
"""
+ from sqlmodel import select
+
+ from app.models import OrganizationInvitation
+
user = crud.get_user_by_email(session=session, email=user_in.email)
if user:
raise HTTPException(
status_code=400,
detail="The user with this email already exists in the system",
)
+
user_create = UserCreate.model_validate(user_in)
+
+ # Check if there's an invitation for this email (team members only)
+ if user_create.user_type == "team_member":
+ statement = select(OrganizationInvitation).where(
+ OrganizationInvitation.email == user_create.email
+ )
+ invitation = session.exec(statement).first()
+
+ if invitation:
+ # Auto-assign to the invited organization
+ user_create.organization_id = invitation.organization_id
+ # Delete the invitation after use
+ session.delete(invitation)
+ session.commit()
+
user = crud.create_user(session=session, user_create=user_create)
+
+ # Process any pending project invitations for clients
+ if user.user_type == "client":
+ crud.process_pending_project_invitations(
+ session=session,
+ user_id=user.id,
+ email=user.email,
+ )
+
+ return user
+
+
+@router.get("/organization-members", response_model=UsersPublic)
+def get_organization_members(
+ session: SessionDep,
+ current_user: CurrentUser,
+ skip: int = 0,
+ limit: int = 100,
+) -> Any:
+ """
+ Get all members of the current user's organization.
+ Accessible by team members to see their organization members.
+ """
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403, detail="Only team members can view organization members"
+ )
+
+ if not current_user.organization_id:
+ raise HTTPException(
+ status_code=400,
+ detail="You must be part of an organization to view members",
+ )
+
+ count_statement = (
+ select(func.count())
+ .select_from(User)
+ .where(User.organization_id == current_user.organization_id)
+ .where(User.user_type == "team_member")
+ )
+ count = session.exec(count_statement).one()
+
+ statement = (
+ select(User)
+ .where(User.organization_id == current_user.organization_id)
+ .where(User.user_type == "team_member")
+ .offset(skip)
+ .limit(limit)
+ )
+ users = session.exec(statement).all()
+
+ return UsersPublic(data=users, count=count)
+
+
+@router.get("/pending", response_model=UsersPublic)
+def get_pending_users(
+ session: SessionDep,
+ current_user: CurrentUser,
+ skip: int = 0,
+ limit: int = 100,
+) -> Any:
+ """
+ Get users without an organization (pending approval).
+ Accessible by team members to invite people to their organization.
+ """
+ if getattr(current_user, "user_type", None) != "team_member":
+ raise HTTPException(
+ status_code=403, detail="Only team members can invite users"
+ )
+
+ from sqlmodel import select
+
+ count_statement = (
+ select(func.count())
+ .select_from(User)
+ .where(User.organization_id.is_(None)) # type: ignore[union-attr]
+ .where(User.user_type == "team_member")
+ )
+ count = session.exec(count_statement).one()
+
+ statement = (
+ select(User)
+ .where(User.organization_id.is_(None)) # type: ignore[union-attr]
+ .where(User.user_type == "team_member")
+ .offset(skip)
+ .limit(limit)
+ )
+ users = session.exec(statement).all()
+
+ return UsersPublic(data=users, count=count)
+
+
+@router.patch("/{user_id}/assign-organization", response_model=UserPublic)
+def assign_user_to_organization(
+ user_id: uuid.UUID,
+ session: SessionDep,
+ current_user: CurrentUser,
+ organization_id: uuid.UUID | None = None,
+) -> Any:
+ """
+ Assign a user to an organization.
+ Team members can assign users to their own organization.
+ Superusers can assign to any organization.
+ """
+ if (
+ getattr(current_user, "user_type", None) != "team_member"
+ and not current_user.is_superuser
+ ):
+ raise HTTPException(status_code=403, detail="Not enough permissions")
+
+ user = session.get(User, user_id)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ # Determine which organization to assign to
+ if current_user.is_superuser and organization_id:
+ # Superuser can specify any organization
+ target_org_id = organization_id
+ else:
+ # Team members assign to their own organization
+ if not current_user.organization_id:
+ raise HTTPException(
+ status_code=400,
+ detail="You must be part of an organization to invite others",
+ )
+ target_org_id = current_user.organization_id
+
+ # Verify organization exists
+ from app.models import Organization
+
+ org = session.get(Organization, target_org_id)
+ if not org:
+ raise HTTPException(status_code=404, detail="Organization not found")
+
+ user.organization_id = target_org_id
+ session.add(user)
+ session.commit()
+ session.refresh(user)
+
return user
@@ -219,8 +402,6 @@ def delete_user(
raise HTTPException(
status_code=403, detail="Super users are not allowed to delete themselves"
)
- statement = delete(Item).where(col(Item.owner_id) == user_id)
- session.exec(statement) # type: ignore
session.delete(user)
session.commit()
return Message(message="User deleted successfully")
diff --git a/backend/app/api/routes/utils.py b/backend/app/api/routes/utils.py
index fc093419b3..c022f2606c 100644
--- a/backend/app/api/routes/utils.py
+++ b/backend/app/api/routes/utils.py
@@ -1,3 +1,8 @@
+import platform
+import sys
+from datetime import datetime
+from typing import Any
+
from fastapi import APIRouter, Depends
from pydantic.networks import EmailStr
@@ -29,3 +34,30 @@ def test_email(email_to: EmailStr) -> Message:
@router.get("/health-check/")
async def health_check() -> bool:
return True
+
+
+@router.get("/system-info/")
+async def get_system_info() -> dict[str, Any]:
+ """
+ Get interesting system information including current time, platform details, and Python version.
+ """
+ return {
+ "message": "System information retrieved successfully",
+ "timestamp": datetime.now().isoformat(),
+ "platform": {
+ "system": platform.system(),
+ "release": platform.release(),
+ "version": platform.version(),
+ "machine": platform.machine(),
+ "processor": platform.processor(),
+ },
+ "python": {
+ "version": sys.version,
+ "version_info": {
+ "major": sys.version_info.major,
+ "minor": sys.version_info.minor,
+ "micro": sys.version_info.micro,
+ },
+ },
+ "fun_fact": "This API endpoint was created as part of CS4800 team project exercise!",
+ }
diff --git a/backend/app/core/config.py b/backend/app/core/config.py
index 6a8ca50bb1..72e376a1c6 100644
--- a/backend/app/core/config.py
+++ b/backend/app/core/config.py
@@ -26,7 +26,7 @@ def parse_cors(v: Any) -> list[str] | str:
class Settings(BaseSettings):
model_config = SettingsConfigDict(
# Use top level .env file (one level above ./backend/)
- env_file="../.env",
+ #env_file="../.env",
env_ignore_empty=True,
extra="ignore",
)
diff --git a/backend/app/core/db.py b/backend/app/core/db.py
index ba991fb36d..d31505f9bc 100644
--- a/backend/app/core/db.py
+++ b/backend/app/core/db.py
@@ -1,8 +1,12 @@
+import logging
+
from sqlmodel import Session, create_engine, select
from app import crud
from app.core.config import settings
-from app.models import User, UserCreate
+from app.models import OrganizationCreate, User, UserCreate
+
+logger = logging.getLogger(__name__)
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
@@ -21,13 +25,34 @@ def init_db(session: Session) -> None:
# This works because the models are already imported and registered from app.models
# SQLModel.metadata.create_all(engine)
- user = session.exec(
- select(User).where(User.email == settings.FIRST_SUPERUSER)
- ).first()
- if not user:
- user_in = UserCreate(
- email=settings.FIRST_SUPERUSER,
- password=settings.FIRST_SUPERUSER_PASSWORD,
- is_superuser=True,
- )
- user = crud.create_user(session=session, user_create=user_in)
+ try:
+ # Check if superuser exists
+ user = session.exec(
+ select(User).where(User.email == settings.FIRST_SUPERUSER)
+ ).first()
+
+ if not user:
+ logger.info(f"Creating superuser: {settings.FIRST_SUPERUSER}")
+ # Create the superuser's organization
+ organization_in = OrganizationCreate(
+ name="Admin Organization", description="Organization for admin user"
+ )
+ organization = crud.create_organization(
+ session=session, organization_in=organization_in
+ )
+ logger.info(f"Created organization: {organization.name} (id: {organization.id})")
+
+ # Create superuser and assign to their organization
+ user_in = UserCreate(
+ email=settings.FIRST_SUPERUSER,
+ password=settings.FIRST_SUPERUSER_PASSWORD,
+ is_superuser=True,
+ organization_id=organization.id,
+ )
+ user = crud.create_user(session=session, user_create=user_in)
+ logger.info(f"Created superuser: {user.email} (id: {user.id})")
+ else:
+ logger.info(f"Superuser already exists: {user.email}")
+ except Exception as e:
+ logger.error(f"Error creating superuser: {e}", exc_info=True)
+ raise
diff --git a/backend/app/crud.py b/backend/app/crud.py
index 905bf48724..3cc58c7d7e 100644
--- a/backend/app/crud.py
+++ b/backend/app/crud.py
@@ -1,10 +1,33 @@
import uuid
+from datetime import datetime, timedelta
from typing import Any
-from sqlmodel import Session, select
+from sqlmodel import Session, desc, func, or_, select
from app.core.security import get_password_hash, verify_password
-from app.models import Item, ItemCreate, User, UserCreate, UserUpdate
+from app.models import (
+ DashboardStats,
+ Gallery,
+ GalleryCreate,
+ GalleryUpdate,
+ Photo,
+ PhotoCreate,
+ #Item,
+ #ItemCreate,
+ Organization,
+ OrganizationCreate,
+ OrganizationUpdate,
+ Project,
+ ProjectAccess,
+ ProjectAccessCreate,
+ ProjectAccessUpdate,
+ ProjectCreate,
+ ProjectInvitation,
+ ProjectUpdate,
+ User,
+ UserCreate,
+ UserUpdate,
+)
def create_user(*, session: Session, user_create: UserCreate) -> User:
@@ -46,9 +69,556 @@ def authenticate(*, session: Session, email: str, password: str) -> User | None:
return db_user
-def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item:
+#def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item:
db_item = Item.model_validate(item_in, update={"owner_id": owner_id})
session.add(db_item)
session.commit()
session.refresh(db_item)
return db_item
+
+
+# ============================================================================
+# ORGANIZATION CRUD
+# ============================================================================
+
+
+def create_organization(
+ *, session: Session, organization_in: OrganizationCreate
+) -> Organization:
+ db_obj = Organization.model_validate(organization_in)
+ session.add(db_obj)
+ session.commit()
+ session.refresh(db_obj)
+ return db_obj
+
+
+def get_organization(
+ *, session: Session, organization_id: uuid.UUID
+) -> Organization | None:
+ return session.get(Organization, organization_id)
+
+
+def get_default_organization(*, session: Session) -> Organization | None:
+ """Get the default organization (typically 'Default Organization')"""
+ statement = select(Organization).where(Organization.name == "Default Organization")
+ return session.exec(statement).first()
+
+
+def update_organization(
+ *,
+ session: Session,
+ db_organization: Organization,
+ organization_in: OrganizationUpdate,
+) -> Organization:
+ organization_data = organization_in.model_dump(exclude_unset=True)
+ db_organization.sqlmodel_update(organization_data)
+ session.add(db_organization)
+ session.commit()
+ session.refresh(db_organization)
+ return db_organization
+
+
+# ============================================================================
+# PROJECT CRUD
+# ============================================================================
+
+
+def create_project(*, session: Session, project_in: ProjectCreate) -> Project:
+ db_obj = Project.model_validate(project_in)
+ session.add(db_obj)
+ session.commit()
+ session.refresh(db_obj)
+ return db_obj
+
+
+def get_project(*, session: Session, project_id: uuid.UUID) -> Project | None:
+ return session.get(Project, project_id)
+
+
+def get_projects_by_organization(
+ *, session: Session, organization_id: uuid.UUID, skip: int = 0, limit: int = 100
+) -> list[Project]:
+ statement = (
+ select(Project)
+ .where(Project.organization_id == organization_id)
+ .offset(skip)
+ .limit(limit)
+ .order_by(desc(Project.created_at))
+ )
+ return list(session.exec(statement).all())
+
+
+def count_projects_by_organization(
+ *, session: Session, organization_id: uuid.UUID
+) -> int:
+ statement = (
+ select(func.count())
+ .select_from(Project)
+ .where(Project.organization_id == organization_id)
+ )
+ return session.exec(statement).one()
+
+
+def update_project(
+ *, session: Session, db_project: Project, project_in: ProjectUpdate
+) -> Project:
+ project_data = project_in.model_dump(exclude_unset=True)
+ project_data["updated_at"] = datetime.utcnow()
+ db_project.sqlmodel_update(project_data)
+ session.add(db_project)
+ session.commit()
+ session.refresh(db_project)
+ return db_project
+
+
+def delete_project(*, session: Session, project_id: uuid.UUID) -> None:
+ project = session.get(Project, project_id)
+ if project:
+ session.delete(project)
+ session.commit()
+
+
+# ============================================================================
+# GALLERY CRUD
+# ============================================================================
+
+
+def create_gallery(*, session: Session, gallery_in: GalleryCreate) -> Gallery:
+ db_obj = Gallery.model_validate(gallery_in)
+ session.add(db_obj)
+ session.commit()
+ session.refresh(db_obj)
+ return db_obj
+
+
+def get_gallery(*, session: Session, gallery_id: uuid.UUID) -> Gallery | None:
+ return session.get(Gallery, gallery_id)
+
+
+def get_galleries_by_project(
+ *, session: Session, project_id: uuid.UUID, skip: int = 0, limit: int = 100
+) -> list[Gallery]:
+ statement = (
+ select(Gallery)
+ .where(Gallery.project_id == project_id)
+ .offset(skip)
+ .limit(limit)
+ .order_by(desc(Gallery.created_at))
+ )
+ return list(session.exec(statement).all())
+
+
+def get_galleries_by_organization(
+ *, session: Session, organization_id: uuid.UUID, skip: int = 0, limit: int = 100
+) -> list[Gallery]:
+ """Get all galleries for projects in an organization"""
+ statement = (
+ select(Gallery)
+ .join(Project)
+ .where(Project.organization_id == organization_id)
+ .offset(skip)
+ .limit(limit)
+ .order_by(desc(Gallery.created_at))
+ )
+ return list(session.exec(statement).all())
+
+
+def count_galleries_by_organization(
+ *, session: Session, organization_id: uuid.UUID
+) -> int:
+ statement = (
+ select(func.count())
+ .select_from(Gallery)
+ .join(Project)
+ .where(Project.organization_id == organization_id)
+ )
+ return session.exec(statement).one()
+
+
+def update_gallery(
+ *, session: Session, db_gallery: Gallery, gallery_in: GalleryUpdate
+) -> Gallery:
+ gallery_data = gallery_in.model_dump(exclude_unset=True)
+ db_gallery.sqlmodel_update(gallery_data)
+ session.add(db_gallery)
+ session.commit()
+ session.refresh(db_gallery)
+ return db_gallery
+
+
+def delete_gallery(*, session: Session, gallery_id: uuid.UUID) -> None:
+ gallery = session.get(Gallery, gallery_id)
+ if gallery:
+ session.delete(gallery)
+ session.commit()
+
+
+def create_photo(*, session: Session, photo_in: PhotoCreate) -> Photo:
+ """Create a new photo record for a gallery and keep gallery.photo_count in sync."""
+ db_obj = Photo.model_validate(photo_in)
+ session.add(db_obj)
+ session.commit()
+ session.refresh(db_obj)
+
+ # Update gallery photo_count
+ gallery = session.get(Gallery, photo_in.gallery_id)
+ if gallery is not None:
+ # Recalculate from DB to avoid drift
+ gallery.photo_count = count_photos_in_gallery(
+ session=session, gallery_id=gallery.id
+ )
+ session.add(gallery)
+ session.commit()
+ session.refresh(gallery)
+
+ return db_obj
+
+
+def delete_photos(
+ *, session: Session, gallery_id: uuid.UUID, photo_ids: list[uuid.UUID]
+) -> int:
+ """Delete photos by ID for a gallery and update gallery.photo_count.
+
+ Returns the number of Photo rows deleted.
+ """
+ if not photo_ids:
+ return 0
+
+ # Only delete photos that belong to this gallery
+ from app.models import Photo # local import to avoid circulars in some tools
+
+ statement = select(Photo).where(
+ Photo.gallery_id == gallery_id, Photo.id.in_(photo_ids) # type: ignore[arg-type]
+ )
+ photos = list(session.exec(statement).all())
+ deleted_count = 0
+
+ for photo in photos:
+ session.delete(photo)
+ deleted_count += 1
+
+ # Update gallery photo_count after deletions
+ gallery = session.get(Gallery, gallery_id)
+ if gallery is not None:
+ gallery.photo_count = max(
+ 0, count_photos_in_gallery(session=session, gallery_id=gallery.id)
+ )
+ session.add(gallery)
+
+ session.commit()
+
+ return deleted_count
+
+
+def get_photos_by_gallery(
+ *, session: Session, gallery_id: uuid.UUID, skip: int = 0, limit: int = 100
+) -> list[Photo]:
+ """Get photos belonging to a specific gallery."""
+ statement = (
+ select(Photo)
+ .where(Photo.gallery_id == gallery_id)
+ .offset(skip)
+ .limit(limit)
+ .order_by(desc(Photo.created_at))
+ )
+ return list(session.exec(statement).all())
+
+
+def count_photos_in_gallery(*, session: Session, gallery_id: uuid.UUID) -> int:
+ """Count how many photos are in a gallery."""
+ statement = (
+ select(func.count())
+ .select_from(Photo)
+ .where(Photo.gallery_id == gallery_id)
+ )
+ return session.exec(statement).one()
+
+
+# ============================================================================
+# PROJECT ACCESS CRUD
+# ============================================================================
+
+
+def create_project_access(
+ *, session: Session, access_in: ProjectAccessCreate
+) -> ProjectAccess:
+ """Grant a user access to a project"""
+ # Check if access already exists
+ existing = session.exec(
+ select(ProjectAccess).where(
+ ProjectAccess.project_id == access_in.project_id,
+ ProjectAccess.user_id == access_in.user_id,
+ )
+ ).first()
+
+ if existing:
+ # Update existing access
+ for key, value in access_in.model_dump(exclude_unset=True).items():
+ setattr(existing, key, value)
+ session.add(existing)
+ session.commit()
+ session.refresh(existing)
+ return existing
+
+ # Create new access
+ db_obj = ProjectAccess.model_validate(access_in)
+ session.add(db_obj)
+ session.commit()
+ session.refresh(db_obj)
+ return db_obj
+
+
+def get_project_access(
+ *, session: Session, project_id: uuid.UUID, user_id: uuid.UUID
+) -> ProjectAccess | None:
+ """Get a user's access to a specific project"""
+ statement = select(ProjectAccess).where(
+ ProjectAccess.project_id == project_id,
+ ProjectAccess.user_id == user_id,
+ )
+ return session.exec(statement).first()
+
+
+def get_project_access_list(
+ *, session: Session, project_id: uuid.UUID
+) -> list[ProjectAccess]:
+ """Get all users with access to a project"""
+ statement = select(ProjectAccess).where(ProjectAccess.project_id == project_id)
+ return list(session.exec(statement).all())
+
+
+def get_user_accessible_projects(
+ *, session: Session, user_id: uuid.UUID, skip: int = 0, limit: int = 100
+) -> list[Project]:
+ """Get all projects a user has access to (for clients)"""
+ statement = (
+ select(Project)
+ .join(ProjectAccess)
+ .where(ProjectAccess.user_id == user_id)
+ .offset(skip)
+ .limit(limit)
+ .order_by(desc(Project.created_at))
+ )
+ return list(session.exec(statement).all())
+
+
+def count_user_accessible_projects(*, session: Session, user_id: uuid.UUID) -> int:
+ """Count projects a user has access to"""
+ statement = (
+ select(func.count())
+ .select_from(Project)
+ .join(ProjectAccess)
+ .where(ProjectAccess.user_id == user_id)
+ )
+ return session.exec(statement).one()
+
+
+def update_project_access(
+ *, session: Session, db_access: ProjectAccess, access_in: ProjectAccessUpdate
+) -> ProjectAccess:
+ """Update project access permissions"""
+ access_data = access_in.model_dump(exclude_unset=True)
+ db_access.sqlmodel_update(access_data)
+ session.add(db_access)
+ session.commit()
+ session.refresh(db_access)
+ return db_access
+
+
+def delete_project_access(
+ *, session: Session, project_id: uuid.UUID, user_id: uuid.UUID
+) -> None:
+ """Remove a user's access to a project"""
+ access = get_project_access(session=session, project_id=project_id, user_id=user_id)
+ if access:
+ session.delete(access)
+ session.commit()
+
+
+def user_has_project_access(
+ *, session: Session, project_id: uuid.UUID, user_id: uuid.UUID
+) -> bool:
+ """Check if a user has access to a project"""
+ statement = (
+ select(func.count())
+ .select_from(ProjectAccess)
+ .where(
+ ProjectAccess.project_id == project_id,
+ ProjectAccess.user_id == user_id,
+ )
+ )
+ count = session.exec(statement).one()
+ return count > 0
+
+
+def invite_client_by_email(
+ *,
+ session: Session,
+ project_id: uuid.UUID,
+ email: str,
+ role: str = "viewer",
+ can_comment: bool = True,
+ can_download: bool = True,
+) -> tuple[ProjectAccess | None, bool]:
+ """
+ Invite a client to a project by email.
+ If user exists: grants immediate access and returns (access, False)
+ If user doesn't exist: creates a pending ProjectInvitation and returns (None, True)
+ """
+ # Check if user exists
+ user = get_user_by_email(session=session, email=email)
+
+ if user:
+ # User exists - grant immediate access
+ # Check if access already exists
+ existing_access = get_project_access(
+ session=session, project_id=project_id, user_id=user.id
+ )
+ if existing_access:
+ # Update existing access
+ existing_access.role = role
+ existing_access.can_comment = can_comment
+ existing_access.can_download = can_download
+ session.add(existing_access)
+ session.commit()
+ session.refresh(existing_access)
+ return existing_access, False
+
+ # Create new access
+ access_in = ProjectAccessCreate(
+ project_id=project_id,
+ user_id=user.id,
+ role=role,
+ can_comment=can_comment,
+ can_download=can_download,
+ )
+ access = create_project_access(session=session, access_in=access_in)
+ return access, False
+ else:
+ # User doesn't exist - create pending invitation
+ # Check if invitation already exists
+ existing_invitation = session.exec(
+ select(ProjectInvitation).where(
+ ProjectInvitation.project_id == project_id,
+ ProjectInvitation.email == email,
+ )
+ ).first()
+
+ if existing_invitation:
+ # Update existing invitation
+ existing_invitation.role = role
+ existing_invitation.can_comment = can_comment
+ existing_invitation.can_download = can_download
+ session.add(existing_invitation)
+ session.commit()
+ session.refresh(existing_invitation)
+ return None, True
+
+ # Create new invitation
+ invitation = ProjectInvitation(
+ project_id=project_id,
+ email=email,
+ role=role,
+ can_comment=can_comment,
+ can_download=can_download,
+ )
+ session.add(invitation)
+ session.commit()
+ session.refresh(invitation)
+ return None, True
+
+
+def process_pending_project_invitations(
+ *, session: Session, user_id: uuid.UUID, email: str
+) -> None:
+ """
+ Process pending project invitations for a newly created client user.
+ Finds all ProjectInvitation records for the email, creates ProjectAccess for each,
+ and deletes the invitations.
+ """
+ # Find all pending invitations for this email
+ statement = select(ProjectInvitation).where(ProjectInvitation.email == email)
+ invitations = session.exec(statement).all()
+
+ for invitation in invitations:
+ # Create project access
+ access_in = ProjectAccessCreate(
+ project_id=invitation.project_id,
+ user_id=user_id,
+ role=invitation.role,
+ can_comment=invitation.can_comment,
+ can_download=invitation.can_download,
+ )
+ create_project_access(session=session, access_in=access_in)
+
+ # Delete the invitation
+ session.delete(invitation)
+
+ # Commit all changes
+ session.commit()
+
+
+# ============================================================================
+# DASHBOARD STATS
+# ============================================================================
+
+
+def get_dashboard_stats(
+ *, session: Session, organization_id: uuid.UUID
+) -> DashboardStats:
+ """Calculate dashboard statistics for an organization"""
+
+ # Count active projects (in_progress or review status)
+ active_projects_stmt = (
+ select(func.count())
+ .select_from(Project)
+ .where(
+ Project.organization_id == organization_id,
+ or_(Project.status == "in_progress", Project.status == "review"),
+ )
+ )
+ active_projects = session.exec(active_projects_stmt).one()
+
+ # Count upcoming deadlines (projects with deadline in next 14 days, not completed)
+ today = datetime.utcnow().date()
+ two_weeks = today + timedelta(days=14)
+ upcoming_deadlines_stmt = (
+ select(func.count())
+ .select_from(Project)
+ .where(
+ Project.organization_id == organization_id,
+ Project.deadline.isnot(None), # type: ignore[union-attr]
+ Project.deadline >= today, # type: ignore[operator]
+ Project.deadline <= two_weeks, # type: ignore[operator]
+ Project.status != "completed",
+ )
+ )
+ upcoming_deadlines = session.exec(upcoming_deadlines_stmt).one()
+
+ # Count team members in organization
+ team_members_stmt = (
+ select(func.count())
+ .select_from(User)
+ .where(User.organization_id == organization_id)
+ )
+ team_members = session.exec(team_members_stmt).one()
+
+ # Count completed projects this month
+ first_day_of_month = today.replace(day=1)
+ completed_this_month_stmt = (
+ select(func.count())
+ .select_from(Project)
+ .where(
+ Project.organization_id == organization_id,
+ Project.status == "completed",
+ Project.updated_at >= first_day_of_month,
+ )
+ )
+ completed_this_month = session.exec(completed_this_month_stmt).one()
+
+ return DashboardStats(
+ active_projects=active_projects,
+ upcoming_deadlines=upcoming_deadlines,
+ team_members=team_members,
+ completed_this_month=completed_this_month,
+ )
diff --git a/backend/app/initial_data.py b/backend/app/initial_data.py
index d806c3d381..4cd9d6ebfc 100644
--- a/backend/app/initial_data.py
+++ b/backend/app/initial_data.py
@@ -1,4 +1,5 @@
import logging
+import sys
from sqlmodel import Session
@@ -9,14 +10,22 @@
def init() -> None:
- with Session(engine) as session:
- init_db(session)
+ try:
+ with Session(engine) as session:
+ init_db(session)
+ except Exception as e:
+ logger.error(f"Error in init_db: {e}", exc_info=True)
+ raise
def main() -> None:
- logger.info("Creating initial data")
- init()
- logger.info("Initial data created")
+ try:
+ logger.info("Creating initial data")
+ init()
+ logger.info("Initial data created")
+ except Exception as e:
+ logger.error(f"Failed to create initial data: {e}", exc_info=True)
+ sys.exit(1)
if __name__ == "__main__":
diff --git a/backend/app/main.py b/backend/app/main.py
index 9a95801e74..b25f93b2a8 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -8,7 +8,9 @@
def custom_generate_unique_id(route: APIRoute) -> str:
- return f"{route.tags[0]}-{route.name}"
+ if route.tags:
+ return f"{route.tags[0]}-{route.name}"
+ return route.name
if settings.SENTRY_DSN and settings.ENVIRONMENT != "local":
@@ -18,6 +20,7 @@ def custom_generate_unique_id(route: APIRoute) -> str:
title=settings.PROJECT_NAME,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
generate_unique_id_function=custom_generate_unique_id,
+ redirect_slashes=False,
)
# Set all CORS enabled origins
diff --git a/backend/app/models.py b/backend/app/models.py
index 2d060ba0b4..c4d99cfc63 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -1,4 +1,7 @@
import uuid
+from datetime import date as DateType
+from datetime import datetime
+from typing import Optional
from pydantic import EmailStr
from sqlmodel import Field, Relationship, SQLModel
@@ -10,23 +13,26 @@ class UserBase(SQLModel):
is_active: bool = True
is_superuser: bool = False
full_name: str | None = Field(default=None, max_length=255)
+ user_type: str = Field(default="team_member", max_length=50)
+ organization_id: uuid.UUID | None = Field(default=None)
# Properties to receive via API on creation
class UserCreate(UserBase):
- password: str = Field(min_length=8, max_length=128)
+ password: str = Field(min_length=8, max_length=40)
class UserRegister(SQLModel):
email: EmailStr = Field(max_length=255)
- password: str = Field(min_length=8, max_length=128)
+ password: str = Field(min_length=8, max_length=40)
full_name: str | None = Field(default=None, max_length=255)
+ user_type: str = Field(default="team_member", max_length=50)
# Properties to receive via API on update, all are optional
class UserUpdate(UserBase):
email: EmailStr | None = Field(default=None, max_length=255) # type: ignore
- password: str | None = Field(default=None, min_length=8, max_length=128)
+ password: str | None = Field(default=None, min_length=8, max_length=40)
class UserUpdateMe(SQLModel):
@@ -35,15 +41,21 @@ class UserUpdateMe(SQLModel):
class UpdatePassword(SQLModel):
- current_password: str = Field(min_length=8, max_length=128)
- new_password: str = Field(min_length=8, max_length=128)
+ current_password: str = Field(min_length=8, max_length=40)
+ new_password: str = Field(min_length=8, max_length=40)
# Database model, database table inferred from class name
class User(UserBase, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
hashed_password: str
- items: list["Item"] = Relationship(back_populates="owner", cascade_delete=True)
+ organization_id: uuid.UUID | None = Field(
+ default=None, foreign_key="organization.id"
+ )
+ organization: Optional["Organization"] = Relationship(back_populates="users")
+ project_access: list["ProjectAccess"] = Relationship(
+ back_populates="user", cascade_delete=True
+ )
# Properties to return via API, id is always required
@@ -56,58 +68,338 @@ class UsersPublic(SQLModel):
count: int
-# Shared properties
-class ItemBase(SQLModel):
- title: str = Field(min_length=1, max_length=255)
- description: str | None = Field(default=None, max_length=255)
+# Generic message
+class Message(SQLModel):
+ message: str
+
+
+# JSON payload containing access token
+class Token(SQLModel):
+ access_token: str
+ token_type: str = "bearer"
+
+
+# Contents of JWT token
+class TokenPayload(SQLModel):
+ sub: str | None = None
+
+
+class NewPassword(SQLModel):
+ token: str
+ new_password: str = Field(min_length=8, max_length=40)
+
+
+# ============================================================================
+# ORGANIZATION MODELS
+# ============================================================================
-# Properties to receive on item creation
-class ItemCreate(ItemBase):
+class OrganizationBase(SQLModel):
+ name: str = Field(min_length=1, max_length=255)
+ description: str | None = Field(default=None, max_length=1000)
+
+
+class OrganizationCreate(OrganizationBase):
pass
-# Properties to receive on item update
-class ItemUpdate(ItemBase):
- title: str | None = Field(default=None, min_length=1, max_length=255) # type: ignore
+class OrganizationUpdate(SQLModel):
+ name: str | None = Field(default=None, min_length=1, max_length=255)
+ description: str | None = Field(default=None, max_length=1000)
-# Database model, database table inferred from class name
-class Item(ItemBase, table=True):
+class Organization(OrganizationBase, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
- owner_id: uuid.UUID = Field(
+ created_at: datetime = Field(default_factory=datetime.utcnow)
+ users: list["User"] = Relationship(back_populates="organization")
+ projects: list["Project"] = Relationship(
+ back_populates="organization", cascade_delete=True
+ )
+
+
+class OrganizationPublic(OrganizationBase):
+ id: uuid.UUID
+ created_at: datetime
+
+
+class OrganizationsPublic(SQLModel):
+ data: list[OrganizationPublic]
+ count: int
+
+
+# ============================================================================
+# PROJECT MODELS
+# ============================================================================
+
+
+class ProjectBase(SQLModel):
+ name: str = Field(min_length=1, max_length=255)
+ client_name: str = Field(min_length=1, max_length=255)
+ client_email: str | None = Field(default=None, max_length=255)
+ description: str | None = Field(default=None, max_length=2000)
+ status: str = Field(
+ default="planning", max_length=50
+ ) # planning, in_progress, review, completed
+ deadline: DateType | None = None
+ start_date: DateType | None = None
+ budget: str | None = Field(default=None, max_length=100)
+ progress: int = Field(default=0, ge=0, le=100)
+
+
+class ProjectCreate(ProjectBase):
+ organization_id: uuid.UUID
+
+
+class ProjectUpdate(SQLModel):
+ name: str | None = Field(default=None, min_length=1, max_length=255)
+ client_name: str | None = Field(default=None, min_length=1, max_length=255)
+ client_email: str | None = Field(default=None, max_length=255)
+ description: str | None = Field(default=None, max_length=2000)
+ status: str | None = Field(default=None, max_length=50)
+ deadline: DateType | None = None
+ start_date: DateType | None = None
+ budget: str | None = Field(default=None, max_length=100)
+ progress: int | None = Field(default=None, ge=0, le=100)
+
+
+class Project(ProjectBase, table=True):
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
+ created_at: datetime = Field(default_factory=datetime.utcnow)
+ updated_at: datetime = Field(default_factory=datetime.utcnow)
+ organization_id: uuid.UUID = Field(
+ foreign_key="organization.id", nullable=False, ondelete="CASCADE"
+ )
+ organization: Optional["Organization"] = Relationship(back_populates="projects")
+ galleries: list["Gallery"] = Relationship(
+ back_populates="project", cascade_delete=True
+ )
+ access_list: list["ProjectAccess"] = Relationship(
+ back_populates="project", cascade_delete=True
+ )
+
+
+class ProjectPublic(ProjectBase):
+ id: uuid.UUID
+ created_at: datetime
+ updated_at: datetime
+ organization_id: uuid.UUID
+
+
+class ProjectsPublic(SQLModel):
+ data: list[ProjectPublic]
+ count: int
+
+
+# ============================================================================
+# GALLERY MODELS
+# ============================================================================
+
+
+class GalleryBase(SQLModel):
+ name: str = Field(min_length=1, max_length=255)
+ date: DateType | None = None
+ photo_count: int = Field(default=0, ge=0)
+ photographer: str | None = Field(default=None, max_length=255)
+ status: str = Field(default="draft", max_length=50) # draft, processing, published
+ cover_image_url: str | None = Field(default=None, max_length=500)
+
+
+class GalleryCreate(GalleryBase):
+ project_id: uuid.UUID
+
+
+class GalleryUpdate(SQLModel):
+ name: str | None = Field(default=None, min_length=1, max_length=255)
+ date: DateType | None = None
+ photo_count: int | None = Field(default=None, ge=0)
+ photographer: str | None = Field(default=None, max_length=255)
+ status: str | None = Field(default=None, max_length=50)
+ cover_image_url: str | None = Field(default=None, max_length=500)
+
+
+class Gallery(GalleryBase, table=True):
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
+ created_at: datetime = Field(default_factory=datetime.utcnow)
+ project_id: uuid.UUID = Field(
+ foreign_key="project.id", nullable=False, ondelete="CASCADE"
+ )
+ project: Optional["Project"] = Relationship(back_populates="galleries")
+
+
+class GalleryPublic(GalleryBase):
+ id: uuid.UUID
+ created_at: datetime
+ project_id: uuid.UUID
+
+
+class GalleriesPublic(SQLModel):
+ data: list[GalleryPublic]
+ count: int
+
+
+# ============================================================================
+# PHOTO MODELS
+# ============================================================================
+
+
+class PhotoBase(SQLModel):
+ filename: str = Field(min_length=1, max_length=255)
+ url: str = Field(min_length=1, max_length=500)
+
+
+class PhotoCreate(SQLModel):
+ gallery_id: uuid.UUID
+ filename: str = Field(min_length=1, max_length=255)
+ url: str = Field(min_length=1, max_length=500)
+ file_size: int = Field(ge=0) # File size in bytes
+
+
+class Photo(PhotoBase, table=True):
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
+ created_at: datetime = Field(default_factory=datetime.utcnow)
+ uploaded_at: datetime = Field(default_factory=datetime.utcnow) # When photo was uploaded
+ file_size: int = Field(default=0, ge=0) # File size in bytes
+ gallery_id: uuid.UUID = Field(
+ foreign_key="gallery.id", nullable=False, ondelete="CASCADE"
+ )
+
+
+class PhotoPublic(PhotoBase):
+ id: uuid.UUID
+ created_at: datetime
+ uploaded_at: datetime
+ file_size: int
+ gallery_id: uuid.UUID
+
+
+class PhotosPublic(SQLModel):
+ data: list[PhotoPublic]
+ count: int
+
+# ============================================================================
+# PROJECT ACCESS (Client Invitations)
+# ============================================================================
+
+
+class ProjectAccessBase(SQLModel):
+ role: str = Field(default="viewer", max_length=50) # viewer, collaborator
+ can_comment: bool = Field(default=True)
+ can_download: bool = Field(default=True)
+
+
+class ProjectAccessCreate(ProjectAccessBase):
+ project_id: uuid.UUID
+ user_id: uuid.UUID
+
+
+class ProjectAccessInviteByEmail(ProjectAccessBase):
+ """Invite a client to a project by email - creates user if needed"""
+
+ email: EmailStr = Field(max_length=255)
+
+
+class ProjectAccessUpdate(SQLModel):
+ role: str | None = Field(default=None, max_length=50)
+ can_comment: bool | None = None
+ can_download: bool | None = None
+
+
+class ProjectAccess(ProjectAccessBase, table=True):
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
+ created_at: datetime = Field(default_factory=datetime.utcnow)
+ project_id: uuid.UUID = Field(
+ foreign_key="project.id", nullable=False, ondelete="CASCADE"
+ )
+ user_id: uuid.UUID = Field(
foreign_key="user.id", nullable=False, ondelete="CASCADE"
)
- owner: User | None = Relationship(back_populates="items")
+ project: Optional["Project"] = Relationship(back_populates="access_list")
+ user: Optional["User"] = Relationship(back_populates="project_access")
-# Properties to return via API, id is always required
-class ItemPublic(ItemBase):
+class ProjectAccessPublic(ProjectAccessBase):
id: uuid.UUID
- owner_id: uuid.UUID
+ created_at: datetime
+ project_id: uuid.UUID
+ user_id: uuid.UUID
+
+class ProjectAccessWithUser(ProjectAccessPublic):
+ user: UserPublic
-class ItemsPublic(SQLModel):
- data: list[ItemPublic]
+
+class ProjectAccessesPublic(SQLModel):
+ data: list[ProjectAccessPublic]
count: int
-# Generic message
-class Message(SQLModel):
- message: str
+# ============================================================================
+# DASHBOARD STATS
+# ============================================================================
-# JSON payload containing access token
-class Token(SQLModel):
- access_token: str
- token_type: str = "bearer"
+class DashboardStats(SQLModel):
+ active_projects: int
+ upcoming_deadlines: int
+ team_members: int
+ completed_this_month: int
-# Contents of JWT token
-class TokenPayload(SQLModel):
- sub: str | None = None
+# ============================================================================
+# Organization Invitation Models
+# ============================================================================
-class NewPassword(SQLModel):
- token: str
- new_password: str = Field(min_length=8, max_length=128)
+class OrganizationInvitationBase(SQLModel):
+ email: EmailStr = Field(max_length=255, index=True)
+
+
+class OrganizationInvitationCreate(OrganizationInvitationBase):
+ pass
+
+
+class OrganizationInvitation(OrganizationInvitationBase, table=True):
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
+ created_at: datetime = Field(default_factory=datetime.utcnow)
+ organization_id: uuid.UUID = Field(
+ foreign_key="organization.id", nullable=False, ondelete="CASCADE"
+ )
+ organization: Optional["Organization"] = Relationship()
+
+
+class OrganizationInvitationPublic(OrganizationInvitationBase):
+ id: uuid.UUID
+ created_at: datetime
+ organization_id: uuid.UUID
+
+
+class OrganizationInvitationsPublic(SQLModel):
+ data: list[OrganizationInvitationPublic]
+ count: int
+
+
+# ============================================================================
+# PROJECT INVITATION MODELS (Pending Client Access)
+# ============================================================================
+
+
+class ProjectInvitationBase(SQLModel):
+ email: EmailStr = Field(max_length=255, index=True)
+ role: str = Field(default="viewer", max_length=50)
+ can_comment: bool = Field(default=True)
+ can_download: bool = Field(default=True)
+
+
+class ProjectInvitation(ProjectInvitationBase, table=True):
+ id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
+ created_at: datetime = Field(default_factory=datetime.utcnow)
+ project_id: uuid.UUID = Field(
+ foreign_key="project.id", nullable=False, ondelete="CASCADE"
+ )
+ project: Optional["Project"] = Relationship()
+
+
+class ProjectInvitationPublic(ProjectInvitationBase):
+ id: uuid.UUID
+ created_at: datetime
+ project_id: uuid.UUID
diff --git a/backend/app/seed_data.py b/backend/app/seed_data.py
new file mode 100644
index 0000000000..8a870fbf43
--- /dev/null
+++ b/backend/app/seed_data.py
@@ -0,0 +1,188 @@
+"""Seed script to add sample projects and galleries to the database"""
+
+import logging
+from datetime import date, timedelta
+
+from sqlmodel import Session, select
+
+from app.core.db import engine
+from app.models import Gallery, Organization, Project
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger(__name__)
+
+
+def seed_data() -> None:
+ with Session(engine) as session:
+ # Get the admin organization (created during init_db)
+ organization = session.exec(
+ select(Organization).where(Organization.name == "Admin Organization")
+ ).first()
+
+ if not organization:
+ logger.error("Admin organization not found! Run initial_data.py first.")
+ return
+
+ logger.info(f"Using organization: {organization.name}")
+
+ # Check if we already have sample data
+ existing_projects = session.exec(
+ select(Project).where(Project.organization_id == organization.id)
+ ).first()
+
+ if existing_projects:
+ logger.info("Sample data already exists. Skipping seed.")
+ return
+
+ # Create sample projects
+ today = date.today()
+
+ project1 = Project(
+ name="Sarah & John Wedding Photography",
+ client_name="Sarah Thompson",
+ client_email="sarah@example.com",
+ description="Full day wedding photography coverage including ceremony, reception, and portraits. Client wants natural, candid shots with some posed family photos.",
+ status="in_progress",
+ deadline=today + timedelta(days=7),
+ start_date=today - timedelta(days=18),
+ budget="$3,500",
+ progress=65,
+ organization_id=organization.id,
+ )
+ session.add(project1)
+
+ project2 = Project(
+ name="Product Shoot - TechCorp",
+ client_name="TechCorp Inc.",
+ client_email="marketing@techcorp.com",
+ description="Product photography for new smartphone lineup. Clean white background shots and lifestyle photography.",
+ status="review",
+ deadline=today + timedelta(days=4),
+ start_date=today - timedelta(days=7),
+ budget="$2,000",
+ progress=90,
+ organization_id=organization.id,
+ )
+ session.add(project2)
+
+ project3 = Project(
+ name="Brand Photography - StartupX",
+ client_name="StartupX",
+ client_email="team@startupx.com",
+ description="Corporate headshots and office culture photography for startup's website and marketing materials.",
+ status="planning",
+ deadline=today + timedelta(days=12),
+ start_date=today,
+ budget="$1,800",
+ progress=15,
+ organization_id=organization.id,
+ )
+ session.add(project3)
+
+ project4 = Project(
+ name="Corporate Headshots - Law Firm",
+ client_name="Smith & Associates",
+ client_email="office@smithlaw.com",
+ description="Professional headshots for 25 attorneys and staff members.",
+ status="planning",
+ deadline=today + timedelta(days=10),
+ start_date=today + timedelta(days=2),
+ budget="$1,250",
+ progress=10,
+ organization_id=organization.id,
+ )
+ session.add(project4)
+
+ project5 = Project(
+ name="Restaurant Menu Photography",
+ client_name="Bella Italia",
+ client_email="chef@bellaitalia.com",
+ description="Food photography for new seasonal menu. 30 dishes to be photographed.",
+ status="completed",
+ deadline=today - timedelta(days=8),
+ start_date=today - timedelta(days=30),
+ budget="$1,500",
+ progress=100,
+ organization_id=organization.id,
+ )
+ session.add(project5)
+
+ # Commit projects first so we have their IDs
+ session.commit()
+ session.refresh(project1)
+ session.refresh(project2)
+ session.refresh(project3)
+ session.refresh(project4)
+ session.refresh(project5)
+
+ logger.info("Created 5 sample projects")
+
+ # Create one gallery per project
+ gallery1 = Gallery(
+ name=f"{project1.name} - Gallery",
+ date=project1.start_date,
+ photo_count=0,
+ photographer=None,
+ status="draft",
+ cover_image_url=None,
+ project_id=project1.id,
+ )
+ session.add(gallery1)
+
+ gallery2 = Gallery(
+ name=f"{project2.name} - Gallery",
+ date=project2.start_date,
+ photo_count=0,
+ photographer=None,
+ status="draft",
+ cover_image_url=None,
+ project_id=project2.id,
+ )
+ session.add(gallery2)
+
+ gallery3 = Gallery(
+ name=f"{project3.name} - Gallery",
+ date=project3.start_date,
+ photo_count=0,
+ photographer=None,
+ status="draft",
+ cover_image_url=None,
+ project_id=project3.id,
+ )
+ session.add(gallery3)
+
+ gallery4 = Gallery(
+ name=f"{project4.name} - Gallery",
+ date=project4.start_date,
+ photo_count=0,
+ photographer=None,
+ status="draft",
+ cover_image_url=None,
+ project_id=project4.id,
+ )
+ session.add(gallery4)
+
+ gallery5 = Gallery(
+ name=f"{project5.name} - Gallery",
+ date=project5.start_date,
+ photo_count=0,
+ photographer=None,
+ status="draft",
+ cover_image_url=None,
+ project_id=project5.id,
+ )
+ session.add(gallery5)
+
+ session.commit()
+ logger.info("Created 5 galleries (one per project)")
+ logger.info("Sample data seeding complete!")
+
+
+def main() -> None:
+ logger.info("Starting sample data seeding...")
+ seed_data()
+ logger.info("Done!")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/scripts/prestart.sh b/backend/scripts/prestart.sh
index 1b395d513f..27bbbe5faa 100644
--- a/backend/scripts/prestart.sh
+++ b/backend/scripts/prestart.sh
@@ -3,11 +3,27 @@
set -e
set -x
+echo "Starting prestart script..."
+
# Let the DB start
-python app/backend_pre_start.py
+echo "Waiting for database to be ready..."
+python app/backend_pre_start.py || {
+ echo "ERROR: Failed to connect to database"
+ exit 1
+}
# Run migrations
-alembic upgrade head
+echo "Running database migrations..."
+alembic upgrade head || {
+ echo "ERROR: Database migrations failed"
+ exit 1
+}
# Create initial data in DB
-python app/initial_data.py
+echo "Creating initial data..."
+python app/initial_data.py || {
+ echo "ERROR: Failed to create initial data"
+ exit 1
+}
+
+echo "Prestart script completed successfully!"
diff --git a/backend/tests/api/routes/test_items.py b/backend/tests/api/routes/test_items.py
deleted file mode 100644
index db950b4535..0000000000
--- a/backend/tests/api/routes/test_items.py
+++ /dev/null
@@ -1,164 +0,0 @@
-import uuid
-
-from fastapi.testclient import TestClient
-from sqlmodel import Session
-
-from app.core.config import settings
-from tests.utils.item import create_random_item
-
-
-def test_create_item(
- client: TestClient, superuser_token_headers: dict[str, str]
-) -> None:
- data = {"title": "Foo", "description": "Fighters"}
- response = client.post(
- f"{settings.API_V1_STR}/items/",
- headers=superuser_token_headers,
- json=data,
- )
- assert response.status_code == 200
- content = response.json()
- assert content["title"] == data["title"]
- assert content["description"] == data["description"]
- assert "id" in content
- assert "owner_id" in content
-
-
-def test_read_item(
- client: TestClient, superuser_token_headers: dict[str, str], db: Session
-) -> None:
- item = create_random_item(db)
- response = client.get(
- f"{settings.API_V1_STR}/items/{item.id}",
- headers=superuser_token_headers,
- )
- assert response.status_code == 200
- content = response.json()
- assert content["title"] == item.title
- assert content["description"] == item.description
- assert content["id"] == str(item.id)
- assert content["owner_id"] == str(item.owner_id)
-
-
-def test_read_item_not_found(
- client: TestClient, superuser_token_headers: dict[str, str]
-) -> None:
- response = client.get(
- f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
- headers=superuser_token_headers,
- )
- assert response.status_code == 404
- content = response.json()
- assert content["detail"] == "Item not found"
-
-
-def test_read_item_not_enough_permissions(
- client: TestClient, normal_user_token_headers: dict[str, str], db: Session
-) -> None:
- item = create_random_item(db)
- response = client.get(
- f"{settings.API_V1_STR}/items/{item.id}",
- headers=normal_user_token_headers,
- )
- assert response.status_code == 400
- content = response.json()
- assert content["detail"] == "Not enough permissions"
-
-
-def test_read_items(
- client: TestClient, superuser_token_headers: dict[str, str], db: Session
-) -> None:
- create_random_item(db)
- create_random_item(db)
- response = client.get(
- f"{settings.API_V1_STR}/items/",
- headers=superuser_token_headers,
- )
- assert response.status_code == 200
- content = response.json()
- assert len(content["data"]) >= 2
-
-
-def test_update_item(
- client: TestClient, superuser_token_headers: dict[str, str], db: Session
-) -> None:
- item = create_random_item(db)
- data = {"title": "Updated title", "description": "Updated description"}
- response = client.put(
- f"{settings.API_V1_STR}/items/{item.id}",
- headers=superuser_token_headers,
- json=data,
- )
- assert response.status_code == 200
- content = response.json()
- assert content["title"] == data["title"]
- assert content["description"] == data["description"]
- assert content["id"] == str(item.id)
- assert content["owner_id"] == str(item.owner_id)
-
-
-def test_update_item_not_found(
- client: TestClient, superuser_token_headers: dict[str, str]
-) -> None:
- data = {"title": "Updated title", "description": "Updated description"}
- response = client.put(
- f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
- headers=superuser_token_headers,
- json=data,
- )
- assert response.status_code == 404
- content = response.json()
- assert content["detail"] == "Item not found"
-
-
-def test_update_item_not_enough_permissions(
- client: TestClient, normal_user_token_headers: dict[str, str], db: Session
-) -> None:
- item = create_random_item(db)
- data = {"title": "Updated title", "description": "Updated description"}
- response = client.put(
- f"{settings.API_V1_STR}/items/{item.id}",
- headers=normal_user_token_headers,
- json=data,
- )
- assert response.status_code == 400
- content = response.json()
- assert content["detail"] == "Not enough permissions"
-
-
-def test_delete_item(
- client: TestClient, superuser_token_headers: dict[str, str], db: Session
-) -> None:
- item = create_random_item(db)
- response = client.delete(
- f"{settings.API_V1_STR}/items/{item.id}",
- headers=superuser_token_headers,
- )
- assert response.status_code == 200
- content = response.json()
- assert content["message"] == "Item deleted successfully"
-
-
-def test_delete_item_not_found(
- client: TestClient, superuser_token_headers: dict[str, str]
-) -> None:
- response = client.delete(
- f"{settings.API_V1_STR}/items/{uuid.uuid4()}",
- headers=superuser_token_headers,
- )
- assert response.status_code == 404
- content = response.json()
- assert content["detail"] == "Item not found"
-
-
-def test_delete_item_not_enough_permissions(
- client: TestClient, normal_user_token_headers: dict[str, str], db: Session
-) -> None:
- item = create_random_item(db)
- response = client.delete(
- f"{settings.API_V1_STR}/items/{item.id}",
- headers=normal_user_token_headers,
- )
- assert response.status_code == 400
- content = response.json()
- assert content["detail"] == "Not enough permissions"
diff --git a/backend/tests/api/routes/test_login.py b/backend/tests/api/routes/test_login.py
index ee166913bd..e8f092761c 100644
--- a/backend/tests/api/routes/test_login.py
+++ b/backend/tests/api/routes/test_login.py
@@ -51,6 +51,7 @@ def test_recovery_password(
with (
patch("app.core.config.settings.SMTP_HOST", "smtp.example.com"),
patch("app.core.config.settings.SMTP_USER", "admin@example.com"),
+ patch("app.core.config.settings.EMAILS_FROM_EMAIL", "noreply@example.com"),
):
email = "test@example.com"
r = client.post(
@@ -116,3 +117,40 @@ def test_reset_password_invalid_token(
assert "detail" in response
assert r.status_code == 400
assert response["detail"] == "Invalid token"
+
+
+# Arthur Nguyen, Assignment 6: added test for SQL injection protection
+def test_login_sql_injection_protection(client: TestClient) -> None:
+ """
+ Test that login endpoint is protected against SQL injection attacks.
+ Verifies that malicious SQL commands in username/password don't compromise security.
+ """
+ # Common SQL injection attack patterns
+ sql_injection_attempts = [
+ "admin' OR '1'='1",
+ "admin'--",
+ "admin' OR '1'='1'--",
+ "'; DROP TABLE users--",
+ "admin' OR 1=1#",
+ "' UNION SELECT NULL--",
+ ]
+
+ for malicious_input in sql_injection_attempts:
+ login_data = {
+ "username": malicious_input,
+ "password": "anypassword",
+ }
+ r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
+
+ # Should return authentication error, not SQL error or success
+ assert r.status_code == 400
+ response_data = r.json()
+ assert "access_token" not in response_data
+
+ # Also test in password field
+ login_data = {
+ "username": settings.FIRST_SUPERUSER,
+ "password": malicious_input,
+ }
+ r = client.post(f"{settings.API_V1_STR}/login/access-token", data=login_data)
+ assert r.status_code == 400
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 8ddab7b321..6e87adb2c2 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -7,7 +7,7 @@
from app.core.config import settings
from app.core.db import engine, init_db
from app.main import app
-from app.models import Item, User
+from app.models import Organization, User
from tests.utils.user import authentication_token_from_email
from tests.utils.utils import get_superuser_token_headers
@@ -17,10 +17,11 @@ def db() -> Generator[Session, None, None]:
with Session(engine) as session:
init_db(session)
yield session
- statement = delete(Item)
- session.execute(statement)
+ # Clean up in proper order due to foreign key constraints
statement = delete(User)
session.execute(statement)
+ statement = delete(Organization)
+ session.execute(statement)
session.commit()
diff --git a/backend/tests/crud/test_gallery.py b/backend/tests/crud/test_gallery.py
new file mode 100644
index 0000000000..22a23cebf1
--- /dev/null
+++ b/backend/tests/crud/test_gallery.py
@@ -0,0 +1,163 @@
+"""Unit tests for Gallery CRUD operations"""
+
+from datetime import date
+
+from sqlmodel import Session
+
+from app import crud
+from app.models import GalleryCreate, GalleryUpdate, OrganizationCreate, ProjectCreate
+from tests.utils.utils import random_lower_string
+
+
+def test_create_gallery(db: Session) -> None:
+ """Test creating a new gallery"""
+ # Create organization and project first
+ org_in = OrganizationCreate(name=random_lower_string())
+ organization = crud.create_organization(session=db, organization_in=org_in)
+
+ project_in = ProjectCreate(
+ name="Test Project", client_name="Test Client", organization_id=organization.id
+ )
+ project = crud.create_project(session=db, project_in=project_in)
+
+ # Create gallery
+ gallery_name = f"Test Gallery {random_lower_string()}"
+ gallery_in = GalleryCreate(
+ name=gallery_name,
+ date=date.today(),
+ photo_count=50,
+ photographer="Test Photographer",
+ status="published",
+ cover_image_url="https://example.com/image.jpg",
+ project_id=project.id,
+ )
+
+ gallery = crud.create_gallery(session=db, gallery_in=gallery_in)
+
+ assert gallery.name == gallery_name
+ assert gallery.photo_count == 50
+ assert gallery.photographer == "Test Photographer"
+ assert gallery.status == "published"
+ assert gallery.project_id == project.id
+ assert gallery.id is not None
+
+
+def test_get_gallery(db: Session) -> None:
+ """Test retrieving a gallery by ID"""
+ # Setup: create org, project, and gallery
+ org = crud.create_organization(
+ session=db, organization_in=OrganizationCreate(name=random_lower_string())
+ )
+ project = crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Test Project", client_name="Client", organization_id=org.id
+ ),
+ )
+ gallery_in = GalleryCreate(
+ name="Test Gallery", photo_count=100, status="draft", project_id=project.id
+ )
+ created_gallery = crud.create_gallery(session=db, gallery_in=gallery_in)
+
+ # Test retrieval
+ retrieved_gallery = crud.get_gallery(session=db, gallery_id=created_gallery.id)
+
+ assert retrieved_gallery is not None
+ assert retrieved_gallery.id == created_gallery.id
+ assert retrieved_gallery.name == "Test Gallery"
+ assert retrieved_gallery.photo_count == 100
+
+
+def test_update_gallery(db: Session) -> None:
+ """Test updating a gallery"""
+ # Setup
+ org = crud.create_organization(
+ session=db, organization_in=OrganizationCreate(name=random_lower_string())
+ )
+ project = crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Test Project", client_name="Client", organization_id=org.id
+ ),
+ )
+ gallery = crud.create_gallery(
+ session=db,
+ gallery_in=GalleryCreate(
+ name="Original Name", photo_count=50, status="draft", project_id=project.id
+ ),
+ )
+
+ # Update gallery
+ gallery_update = GalleryUpdate(
+ name="Updated Name", photo_count=100, status="published"
+ )
+
+ updated_gallery = crud.update_gallery(
+ session=db, db_gallery=gallery, gallery_in=gallery_update
+ )
+
+ assert updated_gallery.name == "Updated Name"
+ assert updated_gallery.photo_count == 100
+ assert updated_gallery.status == "published"
+
+
+def test_get_galleries_by_project(db: Session) -> None:
+ """Test retrieving all galleries for a project"""
+ # Setup
+ org = crud.create_organization(
+ session=db, organization_in=OrganizationCreate(name=random_lower_string())
+ )
+ project = crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Test Project", client_name="Client", organization_id=org.id
+ ),
+ )
+
+ # Create multiple galleries
+ for i in range(3):
+ crud.create_gallery(
+ session=db,
+ gallery_in=GalleryCreate(
+ name=f"Gallery {i}", photo_count=i * 10, project_id=project.id
+ ),
+ )
+
+ # Retrieve galleries
+ galleries = crud.get_galleries_by_project(session=db, project_id=project.id)
+
+ assert len(galleries) == 3
+ gallery_names = [g.name for g in galleries]
+ assert "Gallery 0" in gallery_names
+ assert "Gallery 1" in gallery_names
+ assert "Gallery 2" in gallery_names
+
+
+def test_delete_gallery(db: Session) -> None:
+ """Test deleting a gallery"""
+ # Setup
+ org = crud.create_organization(
+ session=db, organization_in=OrganizationCreate(name=random_lower_string())
+ )
+ project = crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Test Project", client_name="Client", organization_id=org.id
+ ),
+ )
+ gallery = crud.create_gallery(
+ session=db,
+ gallery_in=GalleryCreate(
+ name="Gallery to Delete", photo_count=50, project_id=project.id
+ ),
+ )
+ gallery_id = gallery.id
+
+ # Verify gallery exists
+ assert crud.get_gallery(session=db, gallery_id=gallery_id) is not None
+
+ # Delete gallery
+ crud.delete_gallery(session=db, gallery_id=gallery_id)
+
+ # Verify gallery is deleted
+ assert crud.get_gallery(session=db, gallery_id=gallery_id) is None
diff --git a/backend/tests/crud/test_project.py b/backend/tests/crud/test_project.py
new file mode 100644
index 0000000000..c84b9149f8
--- /dev/null
+++ b/backend/tests/crud/test_project.py
@@ -0,0 +1,202 @@
+"""Unit tests for Project CRUD operations"""
+
+from datetime import date, timedelta
+
+from sqlmodel import Session
+
+from app import crud
+from app.models import OrganizationCreate, ProjectCreate, ProjectUpdate
+from tests.utils.utils import random_lower_string
+
+
+def test_create_project(db: Session) -> None:
+ """Test creating a new project"""
+ # First create an organization
+ org_in = OrganizationCreate(
+ name=random_lower_string(), description="Test organization"
+ )
+ organization = crud.create_organization(session=db, organization_in=org_in)
+
+ # Create a project
+ project_name = f"Test Project {random_lower_string()}"
+ client_name = f"Client {random_lower_string()}"
+ deadline = date.today() + timedelta(days=30)
+
+ project_in = ProjectCreate(
+ name=project_name,
+ client_name=client_name,
+ description="Test project description",
+ status="planning",
+ deadline=deadline,
+ budget="$5,000",
+ progress=0,
+ organization_id=organization.id,
+ )
+
+ project = crud.create_project(session=db, project_in=project_in)
+
+ assert project.name == project_name
+ assert project.client_name == client_name
+ assert project.status == "planning"
+ assert project.deadline == deadline
+ assert project.budget == "$5,000"
+ assert project.progress == 0
+ assert project.organization_id == organization.id
+ assert project.id is not None
+
+
+def test_get_project(db: Session) -> None:
+ """Test retrieving a project by ID"""
+ # Create organization and project
+ org_in = OrganizationCreate(name=random_lower_string())
+ organization = crud.create_organization(session=db, organization_in=org_in)
+
+ project_in = ProjectCreate(
+ name="Test Project",
+ client_name="Test Client",
+ status="in_progress",
+ progress=50,
+ organization_id=organization.id,
+ )
+ created_project = crud.create_project(session=db, project_in=project_in)
+
+ # Retrieve the project
+ retrieved_project = crud.get_project(session=db, project_id=created_project.id)
+
+ assert retrieved_project is not None
+ assert retrieved_project.id == created_project.id
+ assert retrieved_project.name == "Test Project"
+ assert retrieved_project.progress == 50
+
+
+def test_update_project(db: Session) -> None:
+ """Test updating a project"""
+ # Create organization and project
+ org_in = OrganizationCreate(name=random_lower_string())
+ organization = crud.create_organization(session=db, organization_in=org_in)
+
+ project_in = ProjectCreate(
+ name="Original Name",
+ client_name="Test Client",
+ status="planning",
+ progress=0,
+ organization_id=organization.id,
+ )
+ project = crud.create_project(session=db, project_in=project_in)
+
+ # Update the project
+ project_update = ProjectUpdate(
+ name="Updated Name", status="in_progress", progress=75
+ )
+
+ updated_project = crud.update_project(
+ session=db, db_project=project, project_in=project_update
+ )
+
+ assert updated_project.name == "Updated Name"
+ assert updated_project.status == "in_progress"
+ assert updated_project.progress == 75
+ assert updated_project.client_name == "Test Client" # Unchanged field
+
+
+def test_get_projects_by_organization(db: Session) -> None:
+ """Test retrieving all projects for an organization"""
+ # Create organization
+ org_in = OrganizationCreate(name=random_lower_string())
+ organization = crud.create_organization(session=db, organization_in=org_in)
+
+ # Create multiple projects
+ _project1 = crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Project 1", client_name="Client 1", organization_id=organization.id
+ ),
+ )
+
+ _project2 = crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Project 2", client_name="Client 2", organization_id=organization.id
+ ),
+ )
+
+ # Retrieve projects
+ projects = crud.get_projects_by_organization(
+ session=db, organization_id=organization.id
+ )
+
+ assert len(projects) == 2
+ project_names = [p.name for p in projects]
+ assert "Project 1" in project_names
+ assert "Project 2" in project_names
+
+
+def test_count_projects_by_organization(db: Session) -> None:
+ """Test counting projects for an organization"""
+ # Create organization
+ org_in = OrganizationCreate(name=random_lower_string())
+ organization = crud.create_organization(session=db, organization_in=org_in)
+
+ # Initially should have 0 projects
+ count = crud.count_projects_by_organization(
+ session=db, organization_id=organization.id
+ )
+ assert count == 0
+
+ # Create 3 projects
+ for i in range(3):
+ crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name=f"Project {i}",
+ client_name=f"Client {i}",
+ organization_id=organization.id,
+ ),
+ )
+
+ # Should now have 3 projects
+ count = crud.count_projects_by_organization(
+ session=db, organization_id=organization.id
+ )
+ assert count == 3
+
+
+def test_delete_project(db: Session) -> None:
+ """Test deleting a project"""
+ # Create organization and project
+ org_in = OrganizationCreate(name=random_lower_string())
+ organization = crud.create_organization(session=db, organization_in=org_in)
+
+ project_in = ProjectCreate(
+ name="Project to Delete",
+ client_name="Test Client",
+ organization_id=organization.id,
+ )
+ project = crud.create_project(session=db, project_in=project_in)
+ project_id = project.id
+
+ # Verify project exists
+ assert crud.get_project(session=db, project_id=project_id) is not None
+
+ # Delete project
+ crud.delete_project(session=db, project_id=project_id)
+
+ # Verify project is deleted
+ assert crud.get_project(session=db, project_id=project_id) is None
+
+
+def test_project_progress_validation(db: Session) -> None:
+ """Test that project progress is validated (0-100)"""
+ org_in = OrganizationCreate(name=random_lower_string())
+ organization = crud.create_organization(session=db, organization_in=org_in)
+
+ # Valid progress values
+ for progress_val in [0, 50, 100]:
+ project_in = ProjectCreate(
+ name=f"Project {progress_val}",
+ client_name="Test Client",
+ progress=progress_val,
+ organization_id=organization.id,
+ )
+ project = crud.create_project(session=db, project_in=project_in)
+ assert project.progress == progress_val
diff --git a/backend/tests/crud/test_stats.py b/backend/tests/crud/test_stats.py
new file mode 100644
index 0000000000..b149869dbc
--- /dev/null
+++ b/backend/tests/crud/test_stats.py
@@ -0,0 +1,156 @@
+"""Unit tests for Dashboard Statistics"""
+
+from datetime import date, timedelta
+
+from sqlmodel import Session
+
+from app import crud
+from app.models import OrganizationCreate, ProjectCreate
+from tests.utils.utils import random_lower_string
+
+
+def test_dashboard_stats_empty_organization(db: Session) -> None:
+ """Test dashboard stats for an organization with no projects"""
+ org_in = OrganizationCreate(name=random_lower_string())
+ organization = crud.create_organization(session=db, organization_in=org_in)
+
+ stats = crud.get_dashboard_stats(session=db, organization_id=organization.id)
+
+ assert stats.active_projects == 0
+ assert stats.upcoming_deadlines == 0
+ assert stats.team_members == 0
+ assert stats.completed_this_month == 0
+
+
+def test_dashboard_stats_active_projects(db: Session) -> None:
+ """Test counting active projects (in_progress and review status)"""
+ org = crud.create_organization(
+ session=db, organization_in=OrganizationCreate(name=random_lower_string())
+ )
+
+ # Create projects with different statuses
+ crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Project 1",
+ client_name="Client 1",
+ status="in_progress",
+ organization_id=org.id,
+ ),
+ )
+ crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Project 2",
+ client_name="Client 2",
+ status="review",
+ organization_id=org.id,
+ ),
+ )
+ crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Project 3",
+ client_name="Client 3",
+ status="planning", # Not active
+ organization_id=org.id,
+ ),
+ )
+ crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Project 4",
+ client_name="Client 4",
+ status="completed", # Not active
+ organization_id=org.id,
+ ),
+ )
+
+ stats = crud.get_dashboard_stats(session=db, organization_id=org.id)
+
+ # Should count only in_progress and review
+ assert stats.active_projects == 2
+
+
+def test_dashboard_stats_upcoming_deadlines(db: Session) -> None:
+ """Test counting upcoming deadlines (within next 14 days)"""
+ org = crud.create_organization(
+ session=db, organization_in=OrganizationCreate(name=random_lower_string())
+ )
+
+ today = date.today()
+
+ # Project with deadline in 5 days - should count
+ crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Project Soon",
+ client_name="Client",
+ status="in_progress",
+ deadline=today + timedelta(days=5),
+ organization_id=org.id,
+ ),
+ )
+
+ # Project with deadline in 30 days - should NOT count (too far)
+ crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Project Later",
+ client_name="Client",
+ status="in_progress",
+ deadline=today + timedelta(days=30),
+ organization_id=org.id,
+ ),
+ )
+
+ # Completed project with deadline in 7 days - should NOT count (completed)
+ crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Project Done",
+ client_name="Client",
+ status="completed",
+ deadline=today + timedelta(days=7),
+ organization_id=org.id,
+ ),
+ )
+
+ stats = crud.get_dashboard_stats(session=db, organization_id=org.id)
+
+ # Should only count the first project
+ assert stats.upcoming_deadlines == 1
+
+
+def test_dashboard_stats_completed_this_month(db: Session) -> None:
+ """Test counting projects completed this month"""
+ org = crud.create_organization(
+ session=db, organization_in=OrganizationCreate(name=random_lower_string())
+ )
+
+ # Create completed projects
+ _project1 = crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="Completed Project",
+ client_name="Client",
+ status="completed",
+ organization_id=org.id,
+ ),
+ )
+
+ # Also create a non-completed project
+ crud.create_project(
+ session=db,
+ project_in=ProjectCreate(
+ name="In Progress Project",
+ client_name="Client",
+ status="in_progress",
+ organization_id=org.id,
+ ),
+ )
+
+ stats = crud.get_dashboard_stats(session=db, organization_id=org.id)
+
+ # Should count only completed projects
+ assert stats.completed_this_month >= 1 # At least the one we created
diff --git a/backend/tests/utils/item.py b/backend/tests/utils/item.py
deleted file mode 100644
index ee51b351a6..0000000000
--- a/backend/tests/utils/item.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from sqlmodel import Session
-
-from app import crud
-from app.models import Item, ItemCreate
-from tests.utils.user import create_random_user
-from tests.utils.utils import random_lower_string
-
-
-def create_random_item(db: Session) -> Item:
- user = create_random_user(db)
- owner_id = user.id
- assert owner_id is not None
- title = random_lower_string()
- description = random_lower_string()
- item_in = ItemCreate(title=title, description=description)
- return crud.create_item(session=db, item_in=item_in, owner_id=owner_id)
diff --git a/deploy-ip.sh b/deploy-ip.sh
new file mode 100644
index 0000000000..a30ac40696
--- /dev/null
+++ b/deploy-ip.sh
@@ -0,0 +1,168 @@
+#!/bin/bash
+
+# Deployment script for FastAPI project on EC2 with IP address
+# Usage: ./deploy-ip.sh YOUR_EC2_IP
+
+if [ -z "$1" ]; then
+ echo "Usage: ./deploy-ip.sh YOUR_EC2_IP"
+ echo "Example: ./deploy-ip.sh 54.123.45.67"
+ exit 1
+fi
+
+EC2_IP=$1
+
+echo "Deploying to EC2 IP: $EC2_IP"
+
+# Create environment file
+cat > .env << EOF
+# Production Environment Variables for IP-based deployment
+ENVIRONMENT=production
+DOMAIN=$EC2_IP
+PROJECT_NAME=Mosaic Project
+STACK_NAME=mosaic-project-production
+BACKEND_CORS_ORIGINS=http://$EC2_IP:5173,http://$EC2_IP:80,http://$EC2_IP
+FRONTEND_HOST=http://$EC2_IP:5173
+SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))")
+FIRST_SUPERUSER=admin@example.com
+FIRST_SUPERUSER_PASSWORD=$(python3 -c "import secrets; print(secrets.token_urlsafe(16))")
+POSTGRES_SERVER=db
+POSTGRES_PORT=5432
+POSTGRES_USER=postgres
+POSTGRES_PASSWORD=$(python3 -c "import secrets; print(secrets.token_urlsafe(16))")
+POSTGRES_DB=app
+SMTP_HOST=
+SMTP_USER=
+SMTP_PASSWORD=
+EMAILS_FROM_EMAIL=
+DOCKER_IMAGE_BACKEND=mosaic-backend
+DOCKER_IMAGE_FRONTEND=mosaic-frontend
+TAG=latest
+EOF
+
+echo "Environment file created with secure random keys"
+
+# Create simplified docker-compose for IP deployment
+cat > docker-compose.production.yml << EOF
+services:
+ db:
+ image: postgres:17
+ restart: always
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER} -d \${POSTGRES_DB}"]
+ interval: 10s
+ retries: 5
+ start_period: 30s
+ timeout: 10s
+ volumes:
+ - app-db-data:/var/lib/postgresql/data/pgdata
+ environment:
+ - PGDATA=/var/lib/postgresql/data/pgdata
+ - POSTGRES_PASSWORD=\${POSTGRES_PASSWORD}
+ - POSTGRES_USER=\${POSTGRES_USER}
+ - POSTGRES_DB=\${POSTGRES_DB}
+ ports:
+ - "5432:5432"
+
+ adminer:
+ image: adminer
+ restart: always
+ depends_on:
+ - db
+ environment:
+ - ADMINER_DESIGN=pepa-linha-dark
+ ports:
+ - "8080:8080"
+
+ prestart:
+ image: \${DOCKER_IMAGE_BACKEND}:\${TAG}
+ build:
+ context: ./backend
+ depends_on:
+ db:
+ condition: service_healthy
+ restart: true
+ command: bash scripts/prestart.sh
+ environment:
+ - PROJECT_NAME=\${PROJECT_NAME}
+ - DOMAIN=\${DOMAIN}
+ - FRONTEND_HOST=\${FRONTEND_HOST}
+ - ENVIRONMENT=\${ENVIRONMENT}
+ - BACKEND_CORS_ORIGINS=\${BACKEND_CORS_ORIGINS}
+ - SECRET_KEY=\${SECRET_KEY}
+ - FIRST_SUPERUSER=\${FIRST_SUPERUSER}
+ - FIRST_SUPERUSER_PASSWORD=\${FIRST_SUPERUSER_PASSWORD}
+ - SMTP_HOST=\${SMTP_HOST}
+ - SMTP_USER=\${SMTP_USER}
+ - SMTP_PASSWORD=\${SMTP_PASSWORD}
+ - EMAILS_FROM_EMAIL=\${EMAILS_FROM_EMAIL}
+ - POSTGRES_SERVER=db
+ - POSTGRES_PORT=\${POSTGRES_PORT}
+ - POSTGRES_DB=\${POSTGRES_DB}
+ - POSTGRES_USER=\${POSTGRES_USER}
+ - POSTGRES_PASSWORD=\${POSTGRES_PASSWORD}
+
+ backend:
+ image: \${DOCKER_IMAGE_BACKEND}:\${TAG}
+ restart: always
+ depends_on:
+ db:
+ condition: service_healthy
+ restart: true
+ prestart:
+ condition: service_completed_successfully
+ build:
+ context: ./backend
+ environment:
+ - PROJECT_NAME=\${PROJECT_NAME}
+ - DOMAIN=\${DOMAIN}
+ - FRONTEND_HOST=\${FRONTEND_HOST}
+ - ENVIRONMENT=\${ENVIRONMENT}
+ - BACKEND_CORS_ORIGINS=\${BACKEND_CORS_ORIGINS}
+ - SECRET_KEY=\${SECRET_KEY}
+ - FIRST_SUPERUSER=\${FIRST_SUPERUSER}
+ - FIRST_SUPERUSER_PASSWORD=\${FIRST_SUPERUSER_PASSWORD}
+ - SMTP_HOST=\${SMTP_HOST}
+ - SMTP_USER=\${SMTP_USER}
+ - SMTP_PASSWORD=\${SMTP_PASSWORD}
+ - EMAILS_FROM_EMAIL=\${EMAILS_FROM_EMAIL}
+ - POSTGRES_SERVER=db
+ - POSTGRES_PORT=\${POSTGRES_PORT}
+ - POSTGRES_DB=\${POSTGRES_DB}
+ - POSTGRES_USER=\${POSTGRES_USER}
+ - POSTGRES_PASSWORD=\${POSTGRES_PASSWORD}
+ healthcheck:
+ test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ ports:
+ - "8000:8000"
+
+ frontend:
+ image: \${DOCKER_IMAGE_FRONTEND}:\${TAG}
+ restart: always
+ build:
+ context: ./frontend
+ args:
+ - VITE_API_URL=http://$EC2_IP:8000
+ - NODE_ENV=production
+ ports:
+ - "80:80"
+
+volumes:
+ app-db-data:
+EOF
+
+echo "Production docker-compose file created"
+
+echo "Deployment files created successfully!"
+echo ""
+echo "Next steps:"
+echo "1. Copy your project files to the EC2 instance"
+echo "2. Run: docker compose -f docker-compose.production.yml up -d"
+echo ""
+echo "Your application will be available at:"
+echo "- Frontend: http://$EC2_IP"
+echo "- Backend API: http://$EC2_IP:8000"
+echo "- API Docs: http://$EC2_IP:8000/docs"
+echo "- Adminer: http://$EC2_IP:8080"
diff --git a/docker-compose.override.yml b/docker-compose.override.yml
index 0751abe901..8d9fbd7468 100644
--- a/docker-compose.override.yml
+++ b/docker-compose.override.yml
@@ -121,6 +121,9 @@ services:
# For the reports when run locally
- PLAYWRIGHT_HTML_HOST=0.0.0.0
- CI=${CI}
+ # Test credentials
+ - FIRST_SUPERUSER=${FIRST_SUPERUSER}
+ - FIRST_SUPERUSER_PASSWORD=${FIRST_SUPERUSER_PASSWORD}
volumes:
- ./frontend/blob-report:/app/blob-report
- ./frontend/test-results:/app/test-results
diff --git a/docker-compose.yml b/docker-compose.yml
index b1aa17ed43..d237c583c6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,3 +1,6 @@
+x-backend-build: &backend-build
+ context: ./backend
+
services:
db:
@@ -44,8 +47,7 @@ services:
prestart:
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
- build:
- context: ./backend
+ build: *backend-build
networks:
- traefik-public
- default
@@ -74,9 +76,11 @@ services:
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
- SENTRY_DSN=${SENTRY_DSN}
+ - PROJECT_NAME=${PROJECT_NAME?Variable not set}
backend:
image: '${DOCKER_IMAGE_BACKEND?Variable not set}:${TAG-latest}'
+ build: *backend-build
restart: always
networks:
- traefik-public
@@ -89,6 +93,8 @@ services:
condition: service_completed_successfully
env_file:
- .env
+ volumes:
+ - ./backend/app_data:/app/app_data
environment:
- DOMAIN=${DOMAIN}
- FRONTEND_HOST=${FRONTEND_HOST?Variable not set}
@@ -107,15 +113,14 @@ services:
- POSTGRES_USER=${POSTGRES_USER?Variable not set}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD?Variable not set}
- SENTRY_DSN=${SENTRY_DSN}
-
+ - PROJECT_NAME=${PROJECT_NAME?Variable not set}
+ command: fastapi run --workers 4 app/main.py
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/api/v1/utils/health-check/"]
interval: 10s
timeout: 5s
retries: 5
- build:
- context: ./backend
labels:
- traefik.enable=true
- traefik.docker.network=traefik-public
diff --git a/frontend/.env b/frontend/.env
deleted file mode 100644
index 27fcbfe8c8..0000000000
--- a/frontend/.env
+++ /dev/null
@@ -1,2 +0,0 @@
-VITE_API_URL=http://localhost:8000
-MAILCATCHER_HOST=http://localhost:1080
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index ee30d000f3..06d430588d 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -9,7 +9,8 @@ RUN npm install
COPY ./ /app/
-ARG VITE_API_URL=${VITE_API_URL}
+ARG VITE_API_URL
+ENV VITE_API_URL=$VITE_API_URL
RUN npm run build
diff --git a/frontend/index.html b/frontend/index.html
index 57621a268b..2f52280354 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,8 +4,8 @@
-