diff --git a/alembic/versions/49b9ed64d5a7_add_project_public_table.py b/alembic/versions/49b9ed64d5a7_add_project_public_table.py new file mode 100644 index 0000000..7ecd3dc --- /dev/null +++ b/alembic/versions/49b9ed64d5a7_add_project_public_table.py @@ -0,0 +1,43 @@ +"""add project_public table + +Revision ID: 49b9ed64d5a7 +Revises: 70bf9033bd58 +Create Date: 2024-12-23 14:12:25.662809 + +""" +from alembic import op +import sqlalchemy as sa +import geoalchemy2 +import sqlmodel + +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '49b9ed64d5a7' +down_revision = '70bf9033bd58' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('project_public', + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('to_char(CURRENT_TIMESTAMP AT TIME ZONE \'UTC\', \'YYYY-MM-DD"T"HH24:MI:SSOF\')::timestamptz'), nullable=False), + sa.Column('id', postgresql.UUID(as_uuid=True), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('password', sa.Text(), nullable=True), + sa.Column('config', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('project_id', postgresql.UUID(as_uuid=True), nullable=True), + sa.ForeignKeyConstraint(['project_id'], ['customer.project.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + schema='customer' + ) + op.create_index(op.f('ix_customer_project_public_id'), 'project_public', ['id'], unique=False, schema='customer') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_customer_project_public_id'), table_name='project_public', schema='customer') + op.drop_table('project_public', schema='customer') + # ### end Alembic commands ### diff --git a/alembic/versions/70bf9033bd58_add_builder_config_column.py b/alembic/versions/70bf9033bd58_add_builder_config_column.py new file mode 100644 index 0000000..904911e --- /dev/null +++ b/alembic/versions/70bf9033bd58_add_builder_config_column.py @@ -0,0 +1,31 @@ +"""add builder_config column + +Revision ID: 70bf9033bd58 +Revises: f3660efddfc9 +Create Date: 2024-12-20 09:38:53.227648 + +""" +from alembic import op +import sqlalchemy as sa +import geoalchemy2 +import sqlmodel + +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '70bf9033bd58' +down_revision = 'f3660efddfc9' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('project', sa.Column('builder_config', postgresql.JSONB(astext_type=sa.Text()), nullable=True), schema='customer') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('project', 'builder_config', schema='customer') + # ### end Alembic commands ### diff --git a/src/crud/crud_project.py b/src/crud/crud_project.py index dc3dc91..7d39889 100644 --- a/src/crud/crud_project.py +++ b/src/crud/crud_project.py @@ -1,3 +1,4 @@ +import json from uuid import UUID from fastapi_pagination import Page @@ -7,9 +8,9 @@ from src.core.config import settings from src.core.content import ( + build_shared_with_object, create_query_shared_content, update_content_by_id, - build_shared_with_object, ) from src.crud.base import CRUDBase from src.crud.crud_layer_project import layer_project as crud_layer_project @@ -23,12 +24,16 @@ Team, ) from src.db.models._link_model import UserProjectLink +from src.db.models.project import ProjectPublic from src.schemas.common import OrderEnum from src.schemas.project import ( InitialViewState, IProjectBaseUpdate, IProjectCreate, IProjectRead, + ProjectPublicConfig, + ProjectPublicProjectConfig, + ProjectPublicRead, ) @@ -160,5 +165,85 @@ async def update_base( return IProjectRead(**project.dict()) + async def get_public_project( + self, *, async_session: AsyncSession, project_id: str + ) -> ProjectPublicRead | None: + project_public = select(ProjectPublic).where( + ProjectPublic.project_id == project_id + ) + result = await async_session.execute(project_public) + project = result.scalars().first() + if not project: + return None + project_public_read = ProjectPublicRead(**project.dict()) + return project_public_read + + async def publish_project( + self, *, async_session: AsyncSession, project_id: str + ) -> ProjectPublic: + project = select(Project).where(Project.id == project_id) + project = await async_session.execute(project) + project: Project | None = project.scalars().first() + if not project: + raise Exception("Project not found") + project_public = select(ProjectPublic).where( + ProjectPublic.project_id == project_id + ) + project_public = await async_session.execute(project_public) + project_public: ProjectPublic | None = project_public.scalars().first() + user_project = select(UserProjectLink).where( + and_( + UserProjectLink.project_id == project_id, + Project.user_id == UserProjectLink.user_id, + ) + ) + user_project = await async_session.execute(user_project) + user_project: UserProjectLink = user_project.scalars().first() + if project_public: + await async_session.delete(project_public) + + project_layers = await crud_layer_project.get_layers( + async_session=async_session, project_id=project_id + ) + + new_project_public_project_config = ProjectPublicProjectConfig( + id=project.id, + name=project.name, + description=project.description, + tags=project.tags, + thumbnail_url=project.thumbnail_url, + initial_view_state=user_project.initial_view_state, + layer_order=project.layer_order, + max_extent=project.max_extent, + folder_id=project.folder_id, + builder_config=project.builder_config, + ) + new_project_public_config = ProjectPublicConfig( + layers=project_layers, + project=new_project_public_project_config, + ) + new_project_public = ProjectPublic( + project_id=project_id, config=json.loads(new_project_public_config.json()) + ) + + async_session.add(new_project_public) + await async_session.commit() + return new_project_public + + async def unpublish_project( + self, *, async_session: AsyncSession, project_id: str + ) -> None: + public_project = select(ProjectPublic).where( + ProjectPublic.project_id == project_id + ) + public_project = await async_session.execute(public_project) + public_project = public_project.scalars().first() + if public_project: + await async_session.delete(public_project) + await async_session.commit() + else: + raise Exception("Project not found") + return None + project = CRUDProject(Project) diff --git a/src/db/models/project.py b/src/db/models/project.py index 8b4b3ab..0169fd7 100644 --- a/src/db/models/project.py +++ b/src/db/models/project.py @@ -3,6 +3,7 @@ from pydantic import HttpUrl from sqlalchemy import Text +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import UUID as UUID_PG from sqlalchemy.sql import text from sqlmodel import ( @@ -86,6 +87,13 @@ class Project(ContentBaseAttributes, DateTimeBase, table=True): ), description="Max extent of the project", ) + builder_config: dict | None = Field( + sa_column=Column( + JSONB, + nullable=True, + ), + description="Builder config", + ) # Relationships user_projects: List["UserProjectLink"] = Relationship( back_populates="project", @@ -108,5 +116,44 @@ class Project(ContentBaseAttributes, DateTimeBase, table=True): sa_relationship_kwargs={"cascade": "all, delete-orphan"}, ) + project_public: "ProjectPublic" = Relationship( + back_populates="project", + sa_relationship_kwargs={"cascade": "all, delete-orphan", "uselist": False}, + ) + + +class ProjectPublic(DateTimeBase, table=True, extend_existing=True): + """ + A table representing a public project. A public project is a project that is accessible to the public. + + Attributes: + password (str): The password required to access the project. + config (dict): The configuration of the project. This is a JSON object which includes project settings, layers etc. + project_id (UUID): The unique identifier for the project. + """ + + __tablename__ = "project_public" + __table_args__ = {"schema": settings.CUSTOMER_SCHEMA} + id: UUID | None = Field( + sa_column=Column( + UUID_PG(as_uuid=True), + server_default=text("uuid_generate_v4()"), + nullable=False, + index=True, + primary_key=True, + ) + ) + password: str | None = Field(sa_column=Column(Text, nullable=True), max_length=255) + config: dict = Field(sa_column=Column(JSONB, nullable=False)) + project_id: UUID = Field( + nullable=False, + sa_column=Column( + UUID_PG(as_uuid=True), + ForeignKey(f"{settings.CUSTOMER_SCHEMA}.project.id", ondelete="CASCADE"), + ), + ) + + project: Project = Relationship(back_populates="project_public") + UniqueConstraint(Project.__table__.c.folder_id, Project.__table__.c.name) diff --git a/src/endpoints/v2/project.py b/src/endpoints/v2/project.py index 58c21ab..cd2c1f1 100644 --- a/src/endpoints/v2/project.py +++ b/src/endpoints/v2/project.py @@ -779,3 +779,64 @@ async def delete_scenario_features( ) return None + + +############################################## +### Project public endpoints +############################################## + + +@router.get( + "/{project_id}/public", + summary="Get public project", +) +async def get_public_project( + project_id: str, + async_session: AsyncSession = Depends(get_db), +): + """ + Get shared project + """ + result = await crud_project.get_public_project( + async_session=async_session, project_id=project_id + ) + + return result + + +@router.post( + "/{project_id}/publish", + summary="Publish a project", + # dependencies=[Depends(auth_z)], +) +async def publish_project( + project_id: str, + async_session: AsyncSession = Depends(get_db), +): + """ + Publish a project + """ + result = await crud_project.publish_project( + async_session=async_session, project_id=project_id + ) + + return result + + +@router.delete( + "/{project_id}/unpublish", + summary="Unpublish a project", + # dependencies=[Depends(auth_z)], +) +async def unpublish_project( + project_id: str, + async_session: AsyncSession = Depends(get_db), +): + """ + Unpublish a project + """ + result = await crud_project.unpublish_project( + async_session=async_session, project_id=project_id + ) + + return result diff --git a/src/schemas/project.py b/src/schemas/project.py index 50f3158..db667c7 100644 --- a/src/schemas/project.py +++ b/src/schemas/project.py @@ -1,5 +1,6 @@ +from datetime import datetime from enum import Enum -from typing import Optional +from typing import List, Optional from uuid import UUID from pydantic import BaseModel, Field, HttpUrl, validator @@ -237,6 +238,43 @@ class IRasterProjectUpdate(BaseModel): "table": ITableProjectUpdate, } + +class ProjectPublicProjectConfig(BaseModel): + id: UUID = Field(..., description="Project ID") + name: str = Field(..., description="Project name") + description: str | None = Field(..., description="Project description") + tags: List[str] | None = Field(..., description="Project tags") + thumbnail_url: HttpUrl | None = Field(None, description="Project thumbnail URL") + initial_view_state: InitialViewState = Field( + ..., description="Initial view state of the project" + ) + layer_order: list[int] | None = Field(None, description="Layer order in project") + max_extent: list[float] | None = Field( + None, description="Max extent of the project" + ) + folder_id: UUID = Field(..., description="Folder ID") + builder_config: dict | None = Field(None, description="Builder config") + + +class ProjectPublicConfig(BaseModel): + layers: List[ + IFeatureStandardProjectRead + | IFeatureToolProjectRead + | ITableProjectRead + | IRasterProjectRead + ] = Field(..., description="Layers of the project") + project: ProjectPublicProjectConfig = Field( + ..., description="Project configuration" + ) + + +class ProjectPublicRead(BaseModel): + created_at: datetime = Field(..., description="Created at") + updated_at: datetime = Field(..., description="Updated at") + project_id: UUID + config: ProjectPublicConfig + + # TODO: Refactor request_examples = { "get": {