diff --git a/.a0/todo.md b/.a0/todo.md index 5cebcd0..fbecfac 100644 --- a/.a0/todo.md +++ b/.a0/todo.md @@ -1,7 +1,6 @@ # Todo ## Open -- [ ] T024: Project function group and function editor UI - [ ] T025: Create database models for ProjectEquipment and ProjectCrew - [ ] T026: Implement project equipment group and items API - [ ] T027: Project equipment management UI @@ -42,3 +41,4 @@ - T021: Create database models for Project hierarchy - T022: Implement Project CRUD API - T023: Project list and detail UI +- T024: Project function group and function editor UI (function_type, start_date, end_date) diff --git a/backend/alembic/versions/b183c967088f_add_project_equipment_and_crew.py b/backend/alembic/versions/b183c967088f_add_project_equipment_and_crew.py new file mode 100644 index 0000000..79a072b --- /dev/null +++ b/backend/alembic/versions/b183c967088f_add_project_equipment_and_crew.py @@ -0,0 +1,65 @@ +"""add_project_equipment_and_crew + +Revision ID: b183c967088f +Revises: cfe638817921 +Create Date: 2026-05-31 21:32:46.068246 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'b183c967088f' +down_revision: Union[str, None] = 'cfe638817921' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('project_equipment_groups', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False), + sa.Column('sort_order', sa.Integer(), nullable=False), + sa.Column('start_date', sa.DateTime(timezone=True), nullable=True), + sa.Column('end_date', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('project_equipment', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('project_equipment_group_id', sa.String(length=36), nullable=False), + sa.Column('equipment_id', sa.String(length=36), nullable=True), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('quantity', sa.Float(), nullable=False), + sa.Column('price', sa.Float(), nullable=False), + sa.Column('discount', sa.Float(), nullable=False), + sa.Column('sort_order', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['equipment_id'], ['equipment.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['project_equipment_group_id'], ['project_equipment_groups.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('project_crew', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('project_function_id', sa.String(length=36), nullable=False), + sa.Column('crew_id', sa.String(length=36), nullable=False), + sa.Column('start_date', sa.DateTime(timezone=True), nullable=True), + sa.Column('end_date', sa.DateTime(timezone=True), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['crew_id'], ['crew.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['project_function_id'], ['project_functions.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('project_crew') + op.drop_table('project_equipment') + op.drop_table('project_equipment_groups') + # ### end Alembic commands ### diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 5c08cb3..557c20e 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -10,7 +10,15 @@ from app.models.stock_location import StockLocation from app.models.equipment_group import EquipmentGroup from app.models.crew import Crew, CrewAvailability from app.models.vehicle import Vehicle, VehicleAssignment -from app.models.project import Project, SubProject, ProjectFunctionGroup, ProjectFunction +from app.models.project import ( + Project, + SubProject, + ProjectFunctionGroup, + ProjectFunction, + ProjectEquipmentGroup, + ProjectEquipment, + ProjectCrew, +) __all__ = [ "Account", @@ -29,4 +37,7 @@ __all__ = [ "SubProject", "ProjectFunctionGroup", "ProjectFunction", + "ProjectEquipmentGroup", + "ProjectEquipment", + "ProjectCrew", ] diff --git a/backend/app/models/project.py b/backend/app/models/project.py index af21e44..a7c967b 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -60,6 +60,9 @@ class Project(Base): function_groups: Mapped[list["ProjectFunctionGroup"]] = relationship( "ProjectFunctionGroup", back_populates="project", cascade="all, delete-orphan" ) + equipment_groups: Mapped[list["ProjectEquipmentGroup"]] = relationship( + "ProjectEquipmentGroup", back_populates="project", cascade="all, delete-orphan" + ) def __repr__(self) -> str: return f"" @@ -147,6 +150,99 @@ class ProjectFunction(Base): function_group: Mapped["ProjectFunctionGroup"] = relationship( "ProjectFunctionGroup", back_populates="functions" ) + crew_assignments: Mapped[list["ProjectCrew"]] = relationship( + "ProjectCrew", back_populates="project_function", cascade="all, delete-orphan" + ) def __repr__(self) -> str: return f"" + + +class ProjectEquipmentGroup(Base): + """Groups project equipment under a project.""" + + __tablename__ = "project_equipment_groups" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + project_id: Mapped[str] = mapped_column( + String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False + ) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + start_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + end_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + # Relationships + project: Mapped["Project"] = relationship("Project", back_populates="equipment_groups") + items: Mapped[list["ProjectEquipment"]] = relationship( + "ProjectEquipment", back_populates="group", cascade="all, delete-orphan" + ) + + def __repr__(self) -> str: + return f"" + + +class ProjectEquipment(Base): + """Single equipment item within a project equipment group.""" + + __tablename__ = "project_equipment" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + project_equipment_group_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("project_equipment_groups.id", ondelete="CASCADE"), + nullable=False, + ) + equipment_id: Mapped[str | None] = mapped_column( + String(36), ForeignKey("equipment.id", ondelete="SET NULL"), nullable=True + ) + name: Mapped[str] = mapped_column(String(255), nullable=False) + quantity: Mapped[float] = mapped_column(Float, nullable=False, default=1.0) + price: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + discount: Mapped[float] = mapped_column(Float, nullable=False, default=0.0) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + # Relationships + group: Mapped["ProjectEquipmentGroup"] = relationship( + "ProjectEquipmentGroup", back_populates="items" + ) + equipment: Mapped[Optional["Equipment"]] = relationship( + "Equipment", foreign_keys=[equipment_id] + ) + + def __repr__(self) -> str: + return f"" + + +class ProjectCrew(Base): + """Crew assignment to a project function.""" + + __tablename__ = "project_crew" + + id: Mapped[str] = mapped_column( + String(36), primary_key=True, default=lambda: str(uuid.uuid4()) + ) + project_function_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("project_functions.id", ondelete="CASCADE"), + nullable=False, + ) + crew_id: Mapped[str] = mapped_column( + String(36), ForeignKey("crew.id", ondelete="CASCADE"), nullable=False + ) + start_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + end_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + # Relationships + project_function: Mapped["ProjectFunction"] = relationship( + "ProjectFunction", back_populates="crew_assignments" + ) + crew_member: Mapped["Crew"] = relationship("Crew") + + def __repr__(self) -> str: + return f" {self.project_function_id}>" diff --git a/backend/nohup.out b/backend/nohup.out new file mode 100644 index 0000000..d53f6e4 --- /dev/null +++ b/backend/nohup.out @@ -0,0 +1,51 @@ +INFO: Started server process [405788] +INFO: Waiting for application startup. +2026-05-31 21:25:58,679 INFO sqlalchemy.engine.Engine BEGIN (implicit) +2026-05-31 21:25:58,679 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("accounts") +2026-05-31 21:25:58,679 INFO sqlalchemy.engine.Engine [raw sql] () +2026-05-31 21:25:58,681 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("users") +2026-05-31 21:25:58,681 INFO sqlalchemy.engine.Engine [raw sql] () +2026-05-31 21:25:58,681 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("roles") +2026-05-31 21:25:58,681 INFO sqlalchemy.engine.Engine [raw sql] () +2026-05-31 21:25:58,682 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("contact_tags") +2026-05-31 21:25:58,682 INFO sqlalchemy.engine.Engine [raw sql] () +2026-05-31 21:25:58,682 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("contacts") +2026-05-31 21:25:58,682 INFO sqlalchemy.engine.Engine [raw sql] () +2026-05-31 21:25:58,683 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("tags") +2026-05-31 21:25:58,683 INFO sqlalchemy.engine.Engine [raw sql] () +2026-05-31 21:25:58,684 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("equipment") +2026-05-31 21:25:58,684 INFO sqlalchemy.engine.Engine [raw sql] () +2026-05-31 21:25:58,684 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("stock_locations") +2026-05-31 21:25:58,684 INFO sqlalchemy.engine.Engine [raw sql] () +2026-05-31 21:25:58,685 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("equipment_group_items") +2026-05-31 21:25:58,685 INFO sqlalchemy.engine.Engine [raw sql] () +2026-05-31 21:25:58,685 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("equipment_groups") +2026-05-31 21:25:58,685 INFO sqlalchemy.engine.Engine [raw sql] () +2026-05-31 21:25:58,686 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("crew") +2026-05-31 21:25:58,686 INFO sqlalchemy.engine.Engine [raw sql] () +2026-05-31 21:25:58,687 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("crew_availabilities") +2026-05-31 21:25:58,687 INFO sqlalchemy.engine.Engine [raw sql] () +2026-05-31 21:25:58,688 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("vehicles") +2026-05-31 21:25:58,688 INFO sqlalchemy.engine.Engine [raw sql] () +2026-05-31 21:25:58,688 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("vehicle_assignments") +2026-05-31 21:25:58,689 INFO sqlalchemy.engine.Engine [raw sql] () +2026-05-31 21:25:58,689 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("projects") +2026-05-31 21:25:58,689 INFO sqlalchemy.engine.Engine [raw sql] () +2026-05-31 21:25:58,689 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("sub_projects") +2026-05-31 21:25:58,690 INFO sqlalchemy.engine.Engine [raw sql] () +2026-05-31 21:25:58,690 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("project_function_groups") +2026-05-31 21:25:58,690 INFO sqlalchemy.engine.Engine [raw sql] () +2026-05-31 21:25:58,690 INFO sqlalchemy.engine.Engine PRAGMA main.table_info("project_functions") +2026-05-31 21:25:58,691 INFO sqlalchemy.engine.Engine [raw sql] () +2026-05-31 21:25:58,691 INFO sqlalchemy.engine.Engine COMMIT +INFO: Application startup complete. +INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit) +2026-05-31 21:26:00,791 INFO sqlalchemy.engine.Engine BEGIN (implicit) +2026-05-31 21:26:00,792 INFO sqlalchemy.engine.Engine SELECT 1 +2026-05-31 21:26:00,792 INFO sqlalchemy.engine.Engine [generated in 0.00026s] () +2026-05-31 21:26:00,794 INFO sqlalchemy.engine.Engine COMMIT +INFO: 127.0.0.1:51750 - "GET /api/v1/health HTTP/1.1" 200 OK +INFO: Shutting down +INFO: Waiting for application shutdown. +INFO: Application shutdown complete. +INFO: Finished server process [405788]