Skip to content

Commit 740137a

Browse files
committed
Merge branch 'master' into iva-testing
2 parents 2d6c1c6 + 062e669 commit 740137a

26 files changed

+597
-1171
lines changed

.DS_Store

8 KB
Binary file not shown.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""add project invitation table
2+
3+
Revision ID: 2025111201
4+
Revises: 2025110302
5+
Create Date: 2025-11-12
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
import sqlmodel
11+
from sqlalchemy.dialects import postgresql
12+
13+
# revision identifiers, used by Alembic.
14+
revision = '2025111201'
15+
down_revision = '2025110302'
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
op.create_table(
22+
'projectinvitation',
23+
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
24+
sa.Column('role', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False),
25+
sa.Column('can_comment', sa.Boolean(), nullable=False),
26+
sa.Column('can_download', sa.Boolean(), nullable=False),
27+
sa.Column('id', sa.Uuid(), nullable=False),
28+
sa.Column('created_at', sa.DateTime(), nullable=False),
29+
sa.Column('project_id', sa.Uuid(), nullable=False),
30+
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ondelete='CASCADE'),
31+
sa.PrimaryKeyConstraint('id')
32+
)
33+
op.create_index(op.f('ix_projectinvitation_email'), 'projectinvitation', ['email'], unique=False)
34+
35+
36+
def downgrade():
37+
op.drop_index(op.f('ix_projectinvitation_email'), table_name='projectinvitation')
38+
op.drop_table('projectinvitation')
39+

backend/app/api/main.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from app.api.routes import (
44
galleries,
55
invitations,
6-
items,
76
login,
87
organizations,
98
private,
@@ -18,14 +17,18 @@
1817
api_router.include_router(login.router)
1918
api_router.include_router(users.router)
2019
api_router.include_router(utils.router)
21-
api_router.include_router(items.router)
20+
21+
2222
api_router.include_router(
2323
organizations.router, prefix="/organizations", tags=["organizations"]
2424
)
25-
api_router.include_router(projects.router, prefix="/projects", tags=["projects"])
25+
2626
api_router.include_router(
2727
project_access.router, prefix="/projects", tags=["project-access"]
2828
)
29+
30+
api_router.include_router(projects.router, prefix="/projects", tags=["projects"])
31+
2932
api_router.include_router(
3033
invitations.router, prefix="/invitations", tags=["invitations"]
3134
)

backend/app/api/routes/items.py

Lines changed: 0 additions & 109 deletions
This file was deleted.

backend/app/api/routes/project_access.py

Lines changed: 119 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,109 @@
88
from app.models import (
99
Message,
1010
ProjectAccessCreate,
11-
ProjectAccessesPublic,
11+
ProjectAccessInviteByEmail,
1212
ProjectAccessPublic,
1313
ProjectAccessUpdate,
14+
ProjectAccessWithUser,
1415
User,
1516
)
1617

1718
router = APIRouter()
1819

1920

21+
# AN - New endpoint to get all projects the current user has access to
22+
@router.get("/my-projects")
23+
def read_my_projects(
24+
session: SessionDep,
25+
current_user: CurrentUser,
26+
) -> Any:
27+
"""
28+
Get all projects the current user has access to.
29+
For clients: returns projects they've been invited to.
30+
For team members: returns all projects in their organization.
31+
"""
32+
if getattr(current_user, "user_type", None) == "client":
33+
# Use the existing function - perfect!
34+
projects = crud.get_user_accessible_projects(
35+
session=session, user_id=current_user.id, skip=0, limit=1000
36+
)
37+
return {"data": projects, "count": len(projects)}
38+
elif getattr(current_user, "user_type", None) == "team_member":
39+
if not current_user.organization_id:
40+
return {"data": [], "count": 0}
41+
projects = crud.get_projects_by_organization(
42+
session=session,
43+
organization_id=current_user.organization_id,
44+
skip=0,
45+
limit=1000
46+
)
47+
return {"data": projects, "count": len(projects)}
48+
else:
49+
return {"data": [], "count": 0}
50+
51+
@router.post("/{project_id}/access/invite-by-email")
52+
def invite_client_by_email(
53+
*,
54+
session: SessionDep,
55+
current_user: CurrentUser,
56+
project_id: uuid.UUID,
57+
invite_data: ProjectAccessInviteByEmail,
58+
) -> Any:
59+
"""
60+
Invite a client to a project by email.
61+
If user exists: grants immediate access
62+
If user doesn't exist: creates a pending invitation
63+
Only team members can invite clients.
64+
"""
65+
# Check if current user is a team member
66+
if getattr(current_user, "user_type", None) != "team_member":
67+
raise HTTPException(
68+
status_code=403,
69+
detail="Only team members can invite clients to projects",
70+
)
71+
72+
# Check if project exists and user has access to it
73+
project = crud.get_project(session=session, project_id=project_id)
74+
if not project:
75+
raise HTTPException(status_code=404, detail="Project not found")
76+
77+
# Check if current user's organization owns the project
78+
if (
79+
not current_user.organization_id
80+
or current_user.organization_id != project.organization_id
81+
):
82+
raise HTTPException(
83+
status_code=403,
84+
detail="You don't have permission to manage this project",
85+
)
86+
87+
# Invite client by email
88+
try:
89+
access, is_pending = crud.invite_client_by_email(
90+
session=session,
91+
project_id=project_id,
92+
email=invite_data.email,
93+
role=invite_data.role,
94+
can_comment=invite_data.can_comment,
95+
can_download=invite_data.can_download,
96+
)
97+
98+
if is_pending:
99+
return {
100+
"message": "Invitation sent. Client will get access when they sign up with this email.",
101+
"is_pending": True,
102+
"email": invite_data.email,
103+
}
104+
else:
105+
return {
106+
"message": "Client invited successfully",
107+
"is_pending": False,
108+
"access": ProjectAccessPublic.model_validate(access),
109+
}
110+
except ValueError as e:
111+
raise HTTPException(status_code=400, detail=str(e))
112+
113+
20114
@router.post("/{project_id}/access", response_model=ProjectAccessPublic)
21115
def grant_project_access(
22116
*,
@@ -31,6 +125,7 @@ def grant_project_access(
31125
"""
32126
Grant a user access to a project (invite a client).
33127
Only team members can invite clients.
128+
DEPRECATED: Use /invite-by-email endpoint instead.
34129
"""
35130
# Check if current user is a team member
36131
if getattr(current_user, "user_type", None) != "team_member":
@@ -71,7 +166,7 @@ def grant_project_access(
71166
return access
72167

73168

74-
@router.get("/{project_id}/access", response_model=ProjectAccessesPublic)
169+
@router.get("/{project_id}/access", response_model=list[ProjectAccessWithUser])
75170
def read_project_access_list(
76171
*,
77172
session: SessionDep,
@@ -99,7 +194,26 @@ def read_project_access_list(
99194
raise HTTPException(status_code=403, detail="Access denied")
100195

101196
access_list = crud.get_project_access_list(session=session, project_id=project_id)
102-
return ProjectAccessesPublic(data=access_list, count=len(access_list))
197+
# Convert to ProjectAccessWithUser
198+
result = []
199+
for access in access_list:
200+
user = session.get(User, access.user_id)
201+
if user:
202+
from app.models import UserPublic
203+
204+
result.append(
205+
ProjectAccessWithUser(
206+
id=access.id,
207+
created_at=access.created_at,
208+
project_id=access.project_id,
209+
user_id=access.user_id,
210+
role=access.role,
211+
can_comment=access.can_comment,
212+
can_download=access.can_download,
213+
user=UserPublic.model_validate(user),
214+
)
215+
)
216+
return result
103217

104218

105219
@router.delete("/{project_id}/access/{user_id}", response_model=Message)
@@ -176,3 +290,5 @@ def update_project_access_permissions(
176290
session=session, db_access=db_access, access_in=access_in
177291
)
178292
return access
293+
294+

backend/app/api/routes/users.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from typing import Any
33

44
from fastapi import APIRouter, Depends, HTTPException
5-
from sqlmodel import col, delete, func, select
5+
from sqlmodel import func, select
66

77
from app import crud
88
from app.api.deps import (
@@ -13,7 +13,6 @@
1313
from app.core.config import settings
1414
from app.core.security import get_password_hash, verify_password
1515
from app.models import (
16-
Item,
1716
Message,
1817
UpdatePassword,
1918
User,
@@ -197,6 +196,15 @@ def register_user(session: SessionDep, user_in: UserRegister) -> Any:
197196
session.commit()
198197

199198
user = crud.create_user(session=session, user_create=user_create)
199+
200+
# Process any pending project invitations for clients
201+
if user.user_type == "client":
202+
crud.process_pending_project_invitations(
203+
session=session,
204+
user_id=user.id,
205+
email=user.email,
206+
)
207+
200208
return user
201209

202210

@@ -394,8 +402,6 @@ def delete_user(
394402
raise HTTPException(
395403
status_code=403, detail="Super users are not allowed to delete themselves"
396404
)
397-
statement = delete(Item).where(col(Item.owner_id) == user_id)
398-
session.exec(statement) # type: ignore
399405
session.delete(user)
400406
session.commit()
401407
return Message(message="User deleted successfully")

0 commit comments

Comments
 (0)