Skip to content

Commit 9033795

Browse files
committed
Added ability to create orgs, authorize team members, invite clients to projects.
1 parent 0bf48fd commit 9033795

31 files changed

+3229
-132
lines changed
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Add project_access table for client invitations
2+
3+
Revision ID: 2025110301
4+
Revises: 2025110201
5+
Create Date: 2025-11-03 00:00:00.000000
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
import sqlmodel.sql.sqltypes
11+
from sqlalchemy.dialects import postgresql
12+
13+
# revision identifiers, used by Alembic.
14+
revision = '2025110301'
15+
down_revision = '2025110201'
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
# Create project_access table
22+
op.create_table(
23+
'projectaccess',
24+
sa.Column('id', sa.UUID(), nullable=False),
25+
sa.Column('role', sqlmodel.sql.sqltypes.AutoString(length=50), nullable=False, server_default='viewer'),
26+
sa.Column('can_comment', sa.Boolean(), nullable=False, server_default='true'),
27+
sa.Column('can_download', sa.Boolean(), nullable=False, server_default='true'),
28+
sa.Column('created_at', sa.DateTime(), nullable=False),
29+
sa.Column('project_id', sa.UUID(), nullable=False),
30+
sa.Column('user_id', sa.UUID(), nullable=False),
31+
sa.ForeignKeyConstraint(['project_id'], ['project.id'], ondelete='CASCADE'),
32+
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
33+
sa.PrimaryKeyConstraint('id')
34+
)
35+
36+
# Create index for faster lookups
37+
op.create_index('ix_projectaccess_project_id', 'projectaccess', ['project_id'])
38+
op.create_index('ix_projectaccess_user_id', 'projectaccess', ['user_id'])
39+
40+
# Create unique constraint to prevent duplicate access entries
41+
op.create_unique_constraint('uq_projectaccess_project_user', 'projectaccess', ['project_id', 'user_id'])
42+
43+
44+
def downgrade():
45+
# Drop indexes
46+
op.drop_index('ix_projectaccess_user_id', 'projectaccess')
47+
op.drop_index('ix_projectaccess_project_id', 'projectaccess')
48+
49+
# Drop table
50+
op.drop_table('projectaccess')
51+
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""add organization invitation table
2+
3+
Revision ID: 2025110302
4+
Revises: 2025110301
5+
Create Date: 2025-11-03
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 = '2025110302'
15+
down_revision = '2025110301'
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
op.create_table(
22+
'organizationinvitation',
23+
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
24+
sa.Column('id', sa.Uuid(), nullable=False),
25+
sa.Column('created_at', sa.DateTime(), nullable=False),
26+
sa.Column('organization_id', sa.Uuid(), nullable=False),
27+
sa.ForeignKeyConstraint(['organization_id'], ['organization.id'], ondelete='CASCADE'),
28+
sa.PrimaryKeyConstraint('id')
29+
)
30+
op.create_index(op.f('ix_organizationinvitation_email'), 'organizationinvitation', ['email'], unique=False)
31+
32+
33+
def downgrade():
34+
op.drop_index(op.f('ix_organizationinvitation_email'), table_name='organizationinvitation')
35+
op.drop_table('organizationinvitation')
36+

backend/app/api/main.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
from fastapi import APIRouter
22

3-
from app.api.routes import galleries, items, login, private, projects, users, utils
3+
from app.api.routes import galleries, invitations, items, login, organizations, private, project_access, projects, users, utils
44
from app.core.config import settings
55

66
api_router = APIRouter()
77
api_router.include_router(login.router)
88
api_router.include_router(users.router)
99
api_router.include_router(utils.router)
1010
api_router.include_router(items.router)
11+
api_router.include_router(organizations.router, prefix="/organizations", tags=["organizations"])
1112
api_router.include_router(projects.router, prefix="/projects", tags=["projects"])
13+
api_router.include_router(project_access.router, prefix="/projects", tags=["project-access"])
14+
api_router.include_router(invitations.router, prefix="/invitations", tags=["invitations"])
1215
api_router.include_router(galleries.router, prefix="/galleries", tags=["galleries"])
1316

1417

backend/app/api/routes/galleries.py

Lines changed: 97 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -26,33 +26,71 @@ def read_galleries(
2626
) -> Any:
2727
"""
2828
Retrieve galleries. If project_id is provided, get galleries for that project.
29-
Otherwise, get all galleries for the user's organization.
29+
Otherwise, get all galleries based on user type:
30+
- Team members: all galleries from their organization
31+
- Clients: galleries from projects they have access to
3032
"""
31-
if not current_user.organization_id:
32-
raise HTTPException(
33-
status_code=400, detail="User is not part of an organization"
34-
)
33+
user_type = getattr(current_user, "user_type", None)
3534

3635
if project_id:
37-
# Verify project belongs to user's organization
36+
# Verify user has access to this project
3837
project = crud.get_project(session=session, project_id=project_id)
39-
if not project or project.organization_id != current_user.organization_id:
40-
raise HTTPException(status_code=403, detail="Not enough permissions")
38+
if not project:
39+
raise HTTPException(status_code=404, detail="Project not found")
40+
41+
# Check access based on user type
42+
if user_type == "client":
43+
# Client must have explicit access
44+
if not crud.user_has_project_access(
45+
session=session, project_id=project_id, user_id=current_user.id
46+
):
47+
raise HTTPException(status_code=403, detail="Not enough permissions")
48+
else:
49+
# Team member must be in same organization
50+
if not current_user.organization_id or project.organization_id != current_user.organization_id:
51+
raise HTTPException(status_code=403, detail="Not enough permissions")
4152

4253
galleries = crud.get_galleries_by_project(
4354
session=session, project_id=project_id, skip=skip, limit=limit
4455
)
4556
count = len(galleries) # Simple count for project galleries
4657
else:
47-
galleries = crud.get_galleries_by_organization(
48-
session=session,
49-
organization_id=current_user.organization_id,
50-
skip=skip,
51-
limit=limit,
52-
)
53-
count = crud.count_galleries_by_organization(
54-
session=session, organization_id=current_user.organization_id
55-
)
58+
# No specific project - list all accessible galleries
59+
if user_type == "client":
60+
# Get galleries from all projects the client has access to
61+
accessible_projects = crud.get_user_accessible_projects(
62+
session=session, user_id=current_user.id, skip=0, limit=1000
63+
)
64+
project_ids = [p.id for p in accessible_projects]
65+
66+
# Get galleries for all accessible projects
67+
galleries = []
68+
for pid in project_ids[skip:skip+limit]:
69+
project_galleries = crud.get_galleries_by_project(
70+
session=session, project_id=pid, skip=0, limit=100
71+
)
72+
galleries.extend(project_galleries)
73+
74+
count = sum(
75+
len(crud.get_galleries_by_project(session=session, project_id=pid, skip=0, limit=1000))
76+
for pid in project_ids
77+
)
78+
else:
79+
# Team member - get all galleries from organization
80+
if not current_user.organization_id:
81+
raise HTTPException(
82+
status_code=400, detail="User is not part of an organization"
83+
)
84+
85+
galleries = crud.get_galleries_by_organization(
86+
session=session,
87+
organization_id=current_user.organization_id,
88+
skip=skip,
89+
limit=limit,
90+
)
91+
count = crud.count_galleries_by_organization(
92+
session=session, organization_id=current_user.organization_id
93+
)
5694

5795
return GalleriesPublic(data=galleries, count=count)
5896

@@ -62,8 +100,16 @@ def create_gallery(
62100
*, session: SessionDep, current_user: CurrentUser, gallery_in: GalleryCreate
63101
) -> Any:
64102
"""
65-
Create new gallery.
103+
Create new gallery. Only team members can create galleries.
66104
"""
105+
user_type = getattr(current_user, "user_type", None)
106+
107+
# Only team members can create galleries
108+
if user_type != "team_member":
109+
raise HTTPException(
110+
status_code=403, detail="Only team members can create galleries"
111+
)
112+
67113
if not current_user.organization_id:
68114
raise HTTPException(
69115
status_code=400, detail="User is not part of an organization"
@@ -87,10 +133,22 @@ def read_gallery(session: SessionDep, current_user: CurrentUser, id: uuid.UUID)
87133
if not gallery:
88134
raise HTTPException(status_code=404, detail="Gallery not found")
89135

90-
# Check if gallery's project belongs to user's organization
136+
# Check access based on user type
137+
user_type = getattr(current_user, "user_type", None)
91138
project = crud.get_project(session=session, project_id=gallery.project_id)
92-
if not project or project.organization_id != current_user.organization_id:
93-
raise HTTPException(status_code=403, detail="Not enough permissions")
139+
if not project:
140+
raise HTTPException(status_code=404, detail="Project not found")
141+
142+
if user_type == "client":
143+
# Client must have access to the project
144+
if not crud.user_has_project_access(
145+
session=session, project_id=project.id, user_id=current_user.id
146+
):
147+
raise HTTPException(status_code=403, detail="Not enough permissions")
148+
else:
149+
# Team member must be in same organization
150+
if not current_user.organization_id or project.organization_id != current_user.organization_id:
151+
raise HTTPException(status_code=403, detail="Not enough permissions")
94152

95153
return gallery
96154

@@ -104,8 +162,16 @@ def update_gallery(
104162
gallery_in: GalleryUpdate,
105163
) -> Any:
106164
"""
107-
Update a gallery.
165+
Update a gallery. Only team members can update galleries.
108166
"""
167+
user_type = getattr(current_user, "user_type", None)
168+
169+
# Only team members can update galleries
170+
if user_type != "team_member":
171+
raise HTTPException(
172+
status_code=403, detail="Only team members can update galleries"
173+
)
174+
109175
gallery = crud.get_gallery(session=session, gallery_id=id)
110176
if not gallery:
111177
raise HTTPException(status_code=404, detail="Gallery not found")
@@ -126,8 +192,16 @@ def delete_gallery(
126192
session: SessionDep, current_user: CurrentUser, id: uuid.UUID
127193
) -> Message:
128194
"""
129-
Delete a gallery.
195+
Delete a gallery. Only team members can delete galleries.
130196
"""
197+
user_type = getattr(current_user, "user_type", None)
198+
199+
# Only team members can delete galleries
200+
if user_type != "team_member":
201+
raise HTTPException(
202+
status_code=403, detail="Only team members can delete galleries"
203+
)
204+
131205
gallery = crud.get_gallery(session=session, gallery_id=id)
132206
if not gallery:
133207
raise HTTPException(status_code=404, detail="Gallery not found")
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import uuid
2+
from typing import Any
3+
4+
from fastapi import APIRouter, HTTPException
5+
from sqlmodel import func, select
6+
7+
from app.api.deps import CurrentUser, SessionDep
8+
from app.models import (
9+
OrganizationInvitation,
10+
OrganizationInvitationCreate,
11+
OrganizationInvitationPublic,
12+
OrganizationInvitationsPublic,
13+
)
14+
15+
router = APIRouter()
16+
17+
18+
@router.post("/", response_model=OrganizationInvitationPublic)
19+
def create_invitation(
20+
*,
21+
session: SessionDep,
22+
current_user: CurrentUser,
23+
invitation_in: OrganizationInvitationCreate,
24+
) -> Any:
25+
"""
26+
Create an organization invitation.
27+
Team members can invite people to their organization.
28+
"""
29+
if not current_user.organization_id:
30+
raise HTTPException(
31+
status_code=400,
32+
detail="You must be part of an organization to invite others",
33+
)
34+
35+
# Check if invitation already exists
36+
statement = select(OrganizationInvitation).where(
37+
OrganizationInvitation.email == invitation_in.email,
38+
OrganizationInvitation.organization_id == current_user.organization_id,
39+
)
40+
existing = session.exec(statement).first()
41+
if existing:
42+
raise HTTPException(
43+
status_code=400,
44+
detail="An invitation has already been sent to this email",
45+
)
46+
47+
invitation = OrganizationInvitation(
48+
email=invitation_in.email,
49+
organization_id=current_user.organization_id,
50+
)
51+
session.add(invitation)
52+
session.commit()
53+
session.refresh(invitation)
54+
return invitation
55+
56+
57+
@router.get("/", response_model=OrganizationInvitationsPublic)
58+
def read_invitations(
59+
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
60+
) -> Any:
61+
"""
62+
Retrieve invitations for the current user's organization.
63+
"""
64+
if not current_user.organization_id:
65+
raise HTTPException(
66+
status_code=400,
67+
detail="You must be part of an organization",
68+
)
69+
70+
count_statement = (
71+
select(func.count())
72+
.select_from(OrganizationInvitation)
73+
.where(OrganizationInvitation.organization_id == current_user.organization_id)
74+
)
75+
count = session.exec(count_statement).one()
76+
77+
statement = (
78+
select(OrganizationInvitation)
79+
.where(OrganizationInvitation.organization_id == current_user.organization_id)
80+
.offset(skip)
81+
.limit(limit)
82+
)
83+
invitations = session.exec(statement).all()
84+
85+
return OrganizationInvitationsPublic(data=invitations, count=count)
86+
87+
88+
@router.delete("/{invitation_id}")
89+
def delete_invitation(
90+
session: SessionDep, current_user: CurrentUser, invitation_id: uuid.UUID
91+
) -> Any:
92+
"""
93+
Delete an invitation.
94+
"""
95+
invitation = session.get(OrganizationInvitation, invitation_id)
96+
if not invitation:
97+
raise HTTPException(status_code=404, detail="Invitation not found")
98+
99+
if invitation.organization_id != current_user.organization_id:
100+
raise HTTPException(status_code=403, detail="Not enough permissions")
101+
102+
session.delete(invitation)
103+
session.commit()
104+
return {"message": "Invitation deleted"}
105+

0 commit comments

Comments
 (0)