Skip to content

Commit 9f255f6

Browse files
committed
Updated galleries with more features
Changed a few other files like docker-compose due to having issues in building backend twice.
1 parent e287736 commit 9f255f6

File tree

12 files changed

+428
-45
lines changed

12 files changed

+428
-45
lines changed

backend/app/alembic/versions/2025111201_add_project_invitation_table.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212

1313
# revision identifiers, used by Alembic.
1414
revision = '2025111201'
15-
down_revision = '2025110302'
15+
# changed from '2025110302' to '2025111101'
16+
down_revision = '2025111101'
1617
branch_labels = None
1718
depends_on = None
1819

backend/app/api/routes/projects.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import os
12
import uuid
3+
from pathlib import Path
24
from typing import Any
35

46
from fastapi import APIRouter, HTTPException
7+
from sqlmodel import select
58

69
from app import crud
710
from app.api.deps import CurrentUser, SessionDep
@@ -18,6 +21,15 @@
1821
router = APIRouter()
1922

2023

24+
def _gallery_storage_root() -> Path:
25+
"""Return the root directory where gallery photos are stored.
26+
27+
Mirrors the logic in app.api.routes.galleries.STORAGE_ROOT without importing it
28+
directly here to avoid any circular import issues at app startup.
29+
"""
30+
return Path(os.getenv("GALLERY_STORAGE_ROOT", "app_data/galleries")).resolve()
31+
32+
2133
@router.get("/", response_model=ProjectsPublic)
2234
def read_projects(
2335
session: SessionDep, current_user: CurrentUser, skip: int = 0, limit: int = 100
@@ -190,5 +202,36 @@ def delete_project(
190202
if project.organization_id != current_user.organization_id:
191203
raise HTTPException(status_code=403, detail="Not enough permissions")
192204

205+
# Before deleting the project from DB, remove gallery folders & photos from storage.
206+
from app.models import Gallery
207+
208+
storage_root = _gallery_storage_root()
209+
210+
# Find all galleries for this project
211+
statement = select(Gallery).where(Gallery.project_id == id)
212+
galleries = session.exec(statement).all()
213+
214+
for gallery in galleries:
215+
gallery_dir = storage_root / str(gallery.id)
216+
if gallery_dir.exists() and gallery_dir.is_dir():
217+
# Best-effort recursive delete of all files and subdirs
218+
for root, dirs, files in os.walk(gallery_dir, topdown=False):
219+
for name in files:
220+
try:
221+
(Path(root) / name).unlink()
222+
except OSError:
223+
pass
224+
for name in dirs:
225+
try:
226+
(Path(root) / name).rmdir()
227+
except OSError:
228+
pass
229+
try:
230+
gallery_dir.rmdir()
231+
except OSError:
232+
# If something remains locked, ignore; DB rows will still be removed
233+
pass
234+
235+
# Delete the project. DB-level cascading will remove galleries & photos.
193236
crud.delete_project(session=session, project_id=id)
194237
return Message(message="Project deleted successfully")

backend/app/core/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def parse_cors(v: Any) -> list[str] | str:
2626
class Settings(BaseSettings):
2727
model_config = SettingsConfigDict(
2828
# Use top level .env file (one level above ./backend/)
29-
env_file="../.env",
29+
#env_file="../.env",
3030
env_ignore_empty=True,
3131
extra="ignore",
3232
)

backend/app/core/db.py

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1+
import logging
2+
13
from sqlmodel import Session, create_engine, select
24

35
from app import crud
46
from app.core.config import settings
57
from app.models import OrganizationCreate, User, UserCreate
68

9+
logger = logging.getLogger(__name__)
10+
711
engine = create_engine(str(settings.SQLALCHEMY_DATABASE_URI))
812

913

@@ -21,25 +25,34 @@ def init_db(session: Session) -> None:
2125
# This works because the models are already imported and registered from app.models
2226
# SQLModel.metadata.create_all(engine)
2327

24-
# Check if superuser exists
25-
user = session.exec(
26-
select(User).where(User.email == settings.FIRST_SUPERUSER)
27-
).first()
28-
29-
if not user:
30-
# Create the superuser's organization
31-
organization_in = OrganizationCreate(
32-
name="Admin Organization", description="Organization for admin user"
33-
)
34-
organization = crud.create_organization(
35-
session=session, organization_in=organization_in
36-
)
37-
38-
# Create superuser and assign to their organization
39-
user_in = UserCreate(
40-
email=settings.FIRST_SUPERUSER,
41-
password=settings.FIRST_SUPERUSER_PASSWORD,
42-
is_superuser=True,
43-
organization_id=organization.id,
44-
)
45-
user = crud.create_user(session=session, user_create=user_in)
28+
try:
29+
# Check if superuser exists
30+
user = session.exec(
31+
select(User).where(User.email == settings.FIRST_SUPERUSER)
32+
).first()
33+
34+
if not user:
35+
logger.info(f"Creating superuser: {settings.FIRST_SUPERUSER}")
36+
# Create the superuser's organization
37+
organization_in = OrganizationCreate(
38+
name="Admin Organization", description="Organization for admin user"
39+
)
40+
organization = crud.create_organization(
41+
session=session, organization_in=organization_in
42+
)
43+
logger.info(f"Created organization: {organization.name} (id: {organization.id})")
44+
45+
# Create superuser and assign to their organization
46+
user_in = UserCreate(
47+
email=settings.FIRST_SUPERUSER,
48+
password=settings.FIRST_SUPERUSER_PASSWORD,
49+
is_superuser=True,
50+
organization_id=organization.id,
51+
)
52+
user = crud.create_user(session=session, user_create=user_in)
53+
logger.info(f"Created superuser: {user.email} (id: {user.id})")
54+
else:
55+
logger.info(f"Superuser already exists: {user.email}")
56+
except Exception as e:
57+
logger.error(f"Error creating superuser: {e}", exc_info=True)
58+
raise

backend/app/crud.py

Lines changed: 192 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
GalleryUpdate,
1313
Photo,
1414
PhotoCreate,
15-
Item,
16-
ItemCreate,
15+
#Item,
16+
#ItemCreate,
1717
Organization,
1818
OrganizationCreate,
1919
OrganizationUpdate,
@@ -22,6 +22,7 @@
2222
ProjectAccessCreate,
2323
ProjectAccessUpdate,
2424
ProjectCreate,
25+
ProjectInvitation,
2526
ProjectUpdate,
2627
User,
2728
UserCreate,
@@ -68,7 +69,7 @@ def authenticate(*, session: Session, email: str, password: str) -> User | None:
6869
return db_user
6970

7071

71-
def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item:
72+
#def create_item(*, session: Session, item_in: ItemCreate, owner_id: uuid.UUID) -> Item:
7273
db_item = Item.model_validate(item_in, update={"owner_id": owner_id})
7374
session.add(db_item)
7475
session.commit()
@@ -252,6 +253,87 @@ def delete_gallery(*, session: Session, gallery_id: uuid.UUID) -> None:
252253
session.commit()
253254

254255

256+
def create_photo(*, session: Session, photo_in: PhotoCreate) -> Photo:
257+
"""Create a new photo record for a gallery and keep gallery.photo_count in sync."""
258+
db_obj = Photo.model_validate(photo_in)
259+
session.add(db_obj)
260+
session.commit()
261+
session.refresh(db_obj)
262+
263+
# Update gallery photo_count
264+
gallery = session.get(Gallery, photo_in.gallery_id)
265+
if gallery is not None:
266+
# Recalculate from DB to avoid drift
267+
gallery.photo_count = count_photos_in_gallery(
268+
session=session, gallery_id=gallery.id
269+
)
270+
session.add(gallery)
271+
session.commit()
272+
session.refresh(gallery)
273+
274+
return db_obj
275+
276+
277+
def delete_photos(
278+
*, session: Session, gallery_id: uuid.UUID, photo_ids: list[uuid.UUID]
279+
) -> int:
280+
"""Delete photos by ID for a gallery and update gallery.photo_count.
281+
282+
Returns the number of Photo rows deleted.
283+
"""
284+
if not photo_ids:
285+
return 0
286+
287+
# Only delete photos that belong to this gallery
288+
from app.models import Photo # local import to avoid circulars in some tools
289+
290+
statement = select(Photo).where(
291+
Photo.gallery_id == gallery_id, Photo.id.in_(photo_ids) # type: ignore[arg-type]
292+
)
293+
photos = list(session.exec(statement).all())
294+
deleted_count = 0
295+
296+
for photo in photos:
297+
session.delete(photo)
298+
deleted_count += 1
299+
300+
# Update gallery photo_count after deletions
301+
gallery = session.get(Gallery, gallery_id)
302+
if gallery is not None:
303+
gallery.photo_count = max(
304+
0, count_photos_in_gallery(session=session, gallery_id=gallery.id)
305+
)
306+
session.add(gallery)
307+
308+
session.commit()
309+
310+
return deleted_count
311+
312+
313+
def get_photos_by_gallery(
314+
*, session: Session, gallery_id: uuid.UUID, skip: int = 0, limit: int = 100
315+
) -> list[Photo]:
316+
"""Get photos belonging to a specific gallery."""
317+
statement = (
318+
select(Photo)
319+
.where(Photo.gallery_id == gallery_id)
320+
.offset(skip)
321+
.limit(limit)
322+
.order_by(desc(Photo.created_at))
323+
)
324+
return list(session.exec(statement).all())
325+
326+
327+
def count_photos_in_gallery(*, session: Session, gallery_id: uuid.UUID) -> int:
328+
"""Count how many photos are in a gallery."""
329+
statement = (
330+
select(func.count())
331+
.select_from(Photo)
332+
.where(Photo.gallery_id == gallery_id)
333+
)
334+
return session.exec(statement).one()
335+
336+
255337
# ============================================================================
256338
# PROJECT ACCESS CRUD
257339
# ============================================================================
@@ -369,6 +451,113 @@ def user_has_project_access(
369451
return count > 0
370452

371453

454+
def invite_client_by_email(
455+
*,
456+
session: Session,
457+
project_id: uuid.UUID,
458+
email: str,
459+
role: str = "viewer",
460+
can_comment: bool = True,
461+
can_download: bool = True,
462+
) -> tuple[ProjectAccess | None, bool]:
463+
"""
464+
Invite a client to a project by email.
465+
If user exists: grants immediate access and returns (access, False)
466+
If user doesn't exist: creates a pending ProjectInvitation and returns (None, True)
467+
"""
468+
# Check if user exists
469+
user = get_user_by_email(session=session, email=email)
470+
471+
if user:
472+
# User exists - grant immediate access
473+
# Check if access already exists
474+
existing_access = get_project_access(
475+
session=session, project_id=project_id, user_id=user.id
476+
)
477+
if existing_access:
478+
# Update existing access
479+
existing_access.role = role
480+
existing_access.can_comment = can_comment
481+
existing_access.can_download = can_download
482+
session.add(existing_access)
483+
session.commit()
484+
session.refresh(existing_access)
485+
return existing_access, False
486+
487+
# Create new access
488+
access_in = ProjectAccessCreate(
489+
project_id=project_id,
490+
user_id=user.id,
491+
role=role,
492+
can_comment=can_comment,
493+
can_download=can_download,
494+
)
495+
access = create_project_access(session=session, access_in=access_in)
496+
return access, False
497+
else:
498+
# User doesn't exist - create pending invitation
499+
# Check if invitation already exists
500+
existing_invitation = session.exec(
501+
select(ProjectInvitation).where(
502+
ProjectInvitation.project_id == project_id,
503+
ProjectInvitation.email == email,
504+
)
505+
).first()
506+
507+
if existing_invitation:
508+
# Update existing invitation
509+
existing_invitation.role = role
510+
existing_invitation.can_comment = can_comment
511+
existing_invitation.can_download = can_download
512+
session.add(existing_invitation)
513+
session.commit()
514+
session.refresh(existing_invitation)
515+
return None, True
516+
517+
# Create new invitation
518+
invitation = ProjectInvitation(
519+
project_id=project_id,
520+
email=email,
521+
role=role,
522+
can_comment=can_comment,
523+
can_download=can_download,
524+
)
525+
session.add(invitation)
526+
session.commit()
527+
session.refresh(invitation)
528+
return None, True
529+
530+
531+
def process_pending_project_invitations(
532+
*, session: Session, user_id: uuid.UUID, email: str
533+
) -> None:
534+
"""
535+
Process pending project invitations for a newly created client user.
536+
Finds all ProjectInvitation records for the email, creates ProjectAccess for each,
537+
and deletes the invitations.
538+
"""
539+
# Find all pending invitations for this email
540+
statement = select(ProjectInvitation).where(ProjectInvitation.email == email)
541+
invitations = session.exec(statement).all()
542+
543+
for invitation in invitations:
544+
# Create project access
545+
access_in = ProjectAccessCreate(
546+
project_id=invitation.project_id,
547+
user_id=user_id,
548+
role=invitation.role,
549+
can_comment=invitation.can_comment,
550+
can_download=invitation.can_download,
551+
)
552+
create_project_access(session=session, access_in=access_in)
553+
554+
# Delete the invitation
555+
session.delete(invitation)
556+
557+
# Commit all changes
558+
session.commit()
559+
560+
372561
# ============================================================================
373562
# DASHBOARD STATS
374563
# ============================================================================

0 commit comments

Comments
 (0)