diff --git a/.env.example b/.env.example index 276819d..4a1d6b3 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ DATABASE_URL=postgresql+psycopg2://evsy:evsy@db:5432/evsy FRONTEND_URL=http://localhost:3000 # Frontend -VITE_ENV=demo # dev | prod | demo -__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=demo.evsy.dev +VITE_ENV=dev # dev | prod | demo VITE_API_URL=http://localhost:8000/api/v1 -VITE_LOG_LEVEL=error \ No newline at end of file +VITE_LOG_LEVEL=error +__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=demo.evsy.dev \ No newline at end of file diff --git a/backend/app/database/seeds/__init__.py b/backend/app/api/v1/__init__.py similarity index 100% rename from backend/app/database/seeds/__init__.py rename to backend/app/api/v1/__init__.py diff --git a/backend/app/api/v1/routes/admin.py b/backend/app/api/v1/routes/admin.py new file mode 100644 index 0000000..a9b908a --- /dev/null +++ b/backend/app/api/v1/routes/admin.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from app.modules.admin.io.router import router as io_router +from app.modules.admin.reset.router import router as reset_router +from app.modules.admin.seed.router import router as seed_router + +router = APIRouter(prefix="/admin") + +router.include_router(io_router) +router.include_router(seed_router) +router.include_router(reset_router) diff --git a/backend/app/api/v1/routes/events.py b/backend/app/api/v1/routes/events.py index 57e40a3..3362aaa 100644 --- a/backend/app/api/v1/routes/events.py +++ b/backend/app/api/v1/routes/events.py @@ -1,15 +1,18 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from app import crud, schemas -from app.database.database import get_db +from app.core.database import get_db +from app.modules.events import crud as event_crud +from app.modules.events.schemas import EventCreate, EventOut +from app.modules.fields.crud import get_fields_by_ids +from app.modules.tags.crud import get_or_create_tags router = APIRouter(prefix="/events", tags=["events"]) @router.post( "/", - response_model=schemas.EventOut, + response_model=EventOut, status_code=status.HTTP_201_CREATED, summary="Create a new event", description=( @@ -22,20 +25,20 @@ 400: {"description": "One or more fields do not exist"}, }, ) -def create_event(event: schemas.EventCreate, db: Session = Depends(get_db)): - db_fields = crud.get_fields_by_ids(db, event.fields) +def create_event_route(event: EventCreate, db: Session = Depends(get_db)): + db_fields = get_fields_by_ids(db, event.fields) if len(db_fields) != len(event.fields): raise HTTPException(status_code=400, detail="One or more fields do not exist.") - _ = crud.get_or_create_tags(db, event.tags) - db_event = crud.create_event(db=db, event=event) + get_or_create_tags(db, event.tags) + db_event = event_crud.create_event(db=db, event=event) return db_event @router.get( "/{event_id}", - response_model=schemas.EventOut, + response_model=EventOut, summary="Get event by ID", description="Return a single event by its ID. Includes tags and fields.", responses={ @@ -43,8 +46,8 @@ def create_event(event: schemas.EventCreate, db: Session = Depends(get_db)): 404: {"description": "Event not found"}, }, ) -def get_event(event_id: int, db: Session = Depends(get_db)): - db_event = crud.get_event(db=db, event_id=event_id) +def get_event_route(event_id: int, db: Session = Depends(get_db)): + db_event = event_crud.get_event(db=db, event_id=event_id) if db_event is None: raise HTTPException(status_code=404, detail="Event not found") return db_event @@ -52,20 +55,19 @@ def get_event(event_id: int, db: Session = Depends(get_db)): @router.get( "/", - response_model=list[schemas.EventOut], + response_model=list[EventOut], response_model_by_alias=False, summary="List all events", description="Return a paginated list of all events with their tags and fields.", responses={200: {"description": "List of events returned"}}, ) -def get_events(db: Session = Depends(get_db)): - events = crud.get_events(db=db) - return events +def list_events_route(db: Session = Depends(get_db)): + return event_crud.get_events(db=db) @router.put( "/{event_id}", - response_model=schemas.EventOut, + response_model=EventOut, summary="Update an existing event", description=( "Update an existing analytics event. " @@ -78,16 +80,16 @@ def get_events(db: Session = Depends(get_db)): 404: {"description": "Event not found"}, }, ) -def update_event( - event_id: int, event: schemas.EventCreate, db: Session = Depends(get_db) +def update_event_route( + event_id: int, event: EventCreate, db: Session = Depends(get_db) ): - db_fields = crud.get_fields_by_ids(db, event.fields) + db_fields = get_fields_by_ids(db, event.fields) if len(db_fields) != len(event.fields): raise HTTPException(status_code=400, detail="One or more fields do not exist.") - _ = crud.get_or_create_tags(db, event.tags) - db_event = crud.update_event(db=db, event_id=event_id, event=event) + get_or_create_tags(db, event.tags) + db_event = event_crud.update_event(db=db, event_id=event_id, event=event) if db_event is None: raise HTTPException(status_code=404, detail="Event not found") return db_event @@ -95,7 +97,7 @@ def update_event( @router.delete( "/{event_id}", - response_model=schemas.EventOut, + response_model=EventOut, summary="Delete an event", description="Delete an analytics event by ID.", responses={ @@ -103,8 +105,8 @@ def update_event( 404: {"description": "Event not found"}, }, ) -def delete_event(event_id: int, db: Session = Depends(get_db)): - db_event = crud.delete_event(db=db, event_id=event_id) +def delete_event_route(event_id: int, db: Session = Depends(get_db)): + db_event = event_crud.delete_event(db=db, event_id=event_id) if db_event is None: raise HTTPException(status_code=404, detail="Event not found") return db_event diff --git a/backend/app/api/v1/routes/fields.py b/backend/app/api/v1/routes/fields.py index 16c1fc7..a2ddb3d 100644 --- a/backend/app/api/v1/routes/fields.py +++ b/backend/app/api/v1/routes/fields.py @@ -1,81 +1,68 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session -from app import crud, schemas -from app.database.database import get_db +from app.core.database import get_db +from app.modules.fields import crud as field_crud +from app.modules.fields.schemas import FieldCreate, FieldOut, FieldOutWithEventCount router = APIRouter(prefix="/fields", tags=["fields"]) @router.post( "/", - response_model=schemas.FieldOut, + response_model=FieldOut, status_code=status.HTTP_201_CREATED, summary="Create a field", description="Create a new field that can be associated with events.", - responses={ - 201: {"description": "Field created successfully"}, - }, ) -def create_field(field: schemas.FieldCreate, db: Session = Depends(get_db)): - db_field = crud.create_field(db=db, field=field) - return db_field +def create_field_route(field: FieldCreate, db: Session = Depends(get_db)): + return field_crud.create_field(db=db, field=field) @router.get( "/", - response_model=list[schemas.FieldOut], + response_model=list[FieldOut], summary="List all fields", description="Return a paginated list of all fields that can be assigned to events.", - responses={ - 200: {"description": "List of fields returned"}, - }, ) -def get_fields(db: Session = Depends(get_db)): - fields = crud.get_fields(db=db) - return fields +def list_fields_route(db: Session = Depends(get_db)): + return field_crud.get_fields(db=db) @router.get( "/{field_id}", - response_model=schemas.FieldOut | schemas.FieldOutWithEventCount, + response_model=FieldOut | FieldOutWithEventCount, summary="Get field by ID", description="Return a single field by its ID.", - responses={ - 200: {"description": "Field found"}, - 404: {"description": "Field not found"}, - }, + responses={404: {"description": "Field not found"}}, ) -def get_field( +def get_field_route( field_id: int, with_event_count: bool = Query(False), db: Session = Depends(get_db), ): - db_field = crud.get_field(db=db, field_id=field_id) + db_field = field_crud.get_field(db=db, field_id=field_id) if db_field is None: raise HTTPException(status_code=404, detail="Field not found") if with_event_count: - count = crud.get_field_event_count(db=db, field_id=field_id) - return schemas.FieldOutWithEventCount(**db_field.__dict__, event_count=count) + count = field_crud.get_field_event_count(db=db, field_id=field_id) + return FieldOutWithEventCount(**db_field.__dict__, event_count=count) return db_field @router.put( "/{field_id}", - response_model=schemas.FieldOut, + response_model=FieldOut, summary="Update a field", description="Update the name, description, or type of a field.", - responses={ - 200: {"description": "Field updated"}, - 404: {"description": "Field not found"}, - }, + responses={404: {"description": "Field not found"}}, ) -def update_field( - field_id: int, field: schemas.FieldCreate, db: Session = Depends(get_db) +def update_field_route( + field_id: int, field: FieldCreate, db: Session = Depends(get_db) ): - db_field = crud.update_field(db=db, field_id=field_id, field=field) + db_field = field_crud.update_field(db=db, field_id=field_id, field=field) if db_field is None: raise HTTPException(status_code=404, detail="Field not found") return db_field @@ -83,16 +70,13 @@ def update_field( @router.delete( "/{field_id}", - response_model=schemas.FieldOut, + response_model=FieldOut, summary="Delete a field", description="Delete a field by its ID. This will remove the field from all related events.", - responses={ - 200: {"description": "Field deleted"}, - 404: {"description": "Field not found"}, - }, + responses={404: {"description": "Field not found"}}, ) -def delete_field(field_id: int, db: Session = Depends(get_db)): - db_field = crud.delete_field(db=db, field_id=field_id) +def delete_field_route(field_id: int, db: Session = Depends(get_db)): + db_field = field_crud.delete_field(db=db, field_id=field_id) if db_field is None: raise HTTPException(status_code=404, detail="Field not found") return db_field diff --git a/backend/app/api/v1/routes/generic.py b/backend/app/api/v1/routes/generic.py index 9e58c1b..85fa156 100644 --- a/backend/app/api/v1/routes/generic.py +++ b/backend/app/api/v1/routes/generic.py @@ -1,7 +1,8 @@ from fastapi import APIRouter, Depends, Response from app.api.deps import get_settings -from app.schemas import FieldType, LinkType +from app.modules.events.schemas import LinkType +from app.modules.fields.schemas import FieldType from app.settings import Settings router = APIRouter() diff --git a/backend/app/api/v1/routes/tags.py b/backend/app/api/v1/routes/tags.py index 5791c17..4b531b3 100644 --- a/backend/app/api/v1/routes/tags.py +++ b/backend/app/api/v1/routes/tags.py @@ -1,53 +1,43 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from app import crud, schemas -from app.database.database import get_db +from app.core.database import get_db +from app.modules.tags import crud as tag_crud +from app.modules.tags.schemas import TagCreate, TagOut router = APIRouter(prefix="/tags", tags=["tags"]) @router.post( "/", - response_model=schemas.TagOut, + response_model=TagOut, status_code=status.HTTP_201_CREATED, summary="Create a tag", description="Create a tag manually. Typically, tags are created automatically when creating or updating an event.", - responses={ - 201: {"description": "Tag created successfully"}, - }, ) -def create_tag(tag: schemas.TagCreate, db: Session = Depends(get_db)): - db_tag = crud.create_tag(db=db, tag=tag) - return db_tag +def create_tag_route(tag: TagCreate, db: Session = Depends(get_db)): + return tag_crud.create_tag(db=db, tag=tag) @router.get( "/", - response_model=list[schemas.TagOut], + response_model=list[TagOut], summary="List all tags", description="Return a paginated list of all tags available in the system.", - responses={ - 200: {"description": "List of tags returned"}, - }, ) -def get_tags(db: Session = Depends(get_db)): - tags = crud.get_tags(db=db) - return tags +def list_tags_route(db: Session = Depends(get_db)): + return tag_crud.get_tags(db=db) @router.get( "/{tag_id}", - response_model=schemas.TagOut, + response_model=TagOut, summary="Get tag by ID", description="Return a single tag by its unique identifier.", - responses={ - 200: {"description": "Tag found"}, - 404: {"description": "Tag not found"}, - }, + responses={404: {"description": "Tag not found"}}, ) -def get_event(tag_id: str, db: Session = Depends(get_db)): - db_tag = crud.get_tag(db=db, tag_id=tag_id) +def get_tag_route(tag_id: str, db: Session = Depends(get_db)): + db_tag = tag_crud.get_tag(db=db, tag_id=tag_id) if db_tag is None: raise HTTPException(status_code=404, detail="Tag not found") return db_tag @@ -55,16 +45,13 @@ def get_event(tag_id: str, db: Session = Depends(get_db)): @router.put( "/{tag_id}", - response_model=schemas.TagOut, + response_model=TagOut, summary="Update a tag", description="Update the description of an existing tag.", - responses={ - 200: {"description": "Tag updated"}, - 404: {"description": "Tag not found"}, - }, + responses={404: {"description": "Tag not found"}}, ) -def update_tag(tag_id: str, tag: schemas.TagCreate, db: Session = Depends(get_db)): - db_tag = crud.update_tag(db=db, tag_id=tag_id, tag=tag) +def update_tag_route(tag_id: str, tag: TagCreate, db: Session = Depends(get_db)): + db_tag = tag_crud.update_tag(db=db, tag_id=tag_id, tag=tag) if db_tag is None: raise HTTPException(status_code=404, detail="Tag not found") return db_tag @@ -72,16 +59,13 @@ def update_tag(tag_id: str, tag: schemas.TagCreate, db: Session = Depends(get_db @router.delete( "/{tag_id}", - response_model=schemas.TagOut, + response_model=TagOut, summary="Delete a tag", description="Delete a tag by its ID. This will remove the tag from all related events.", - responses={ - 200: {"description": "Tag deleted"}, - 404: {"description": "Tag not found"}, - }, + responses={404: {"description": "Tag not found"}}, ) -def delete_tag(tag_id: str, db: Session = Depends(get_db)): - db_tag = crud.delete_tag(db=db, tag_id=tag_id) +def delete_tag_route(tag_id: str, db: Session = Depends(get_db)): + db_tag = tag_crud.delete_tag(db=db, tag_id=tag_id) if db_tag is None: raise HTTPException(status_code=404, detail="Tag not found") return db_tag diff --git a/backend/app/database/database.py b/backend/app/core/database.py similarity index 100% rename from backend/app/database/database.py rename to backend/app/core/database.py diff --git a/backend/app/core/models.py b/backend/app/core/models.py new file mode 100644 index 0000000..79d2238 --- /dev/null +++ b/backend/app/core/models.py @@ -0,0 +1,19 @@ +from datetime import datetime + +from sqlalchemy import ( + DateTime, + func, +) +from sqlalchemy.orm import Mapped, mapped_column + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/crud.py b/backend/app/crud.py deleted file mode 100644 index 89fa3be..0000000 --- a/backend/app/crud.py +++ /dev/null @@ -1,188 +0,0 @@ -from fastapi import HTTPException, Response -from sqlalchemy import func -from sqlalchemy.orm import Session, joinedload - -from . import models, schemas - - -def create_event(db: Session, event: schemas.EventCreate): - db_event = models.Event(name=event.name, description=event.description) - db.add(db_event) - db.commit() - db.refresh(db_event) - - for tag_id in event.tags: - db.add(models.EventTag(event_id=db_event.id, tag_id=tag_id)) - - for field_id in event.fields: - db.add(models.EventField(event_id=db_event.id, field_id=field_id)) - - db.commit() - db.refresh(db_event) - return db_event - - -def get_event(db: Session, event_id: int): - return db.query(models.Event).filter(models.Event.id == event_id).first() - - -def get_events(db: Session): - return ( - db.query(models.Event) - .options( - joinedload(models.Event.tags), - joinedload(models.Event.fields), - ) - .order_by(models.Event.id) - .all() - ) - - -def update_event(db: Session, event_id: int, event: schemas.EventCreate): - db_event = db.query(models.Event).filter(models.Event.id == event_id).first() - if db_event is None: - return None - - db_event.name = event.name - db_event.description = event.description - - if event.tags is not None: - db.query(models.EventTag).filter( - models.EventTag.event_id == db_event.id - ).delete() - if event.tags: - for tag_id in event.tags: - db.add(models.EventTag(event_id=db_event.id, tag_id=tag_id)) - - if event.fields is not None: - db.query(models.EventField).filter( - models.EventField.event_id == db_event.id - ).delete() - if event.fields: - for field_id in event.fields: - db.add(models.EventField(event_id=db_event.id, field_id=field_id)) - - db.commit() - db.refresh(db_event) - return db_event - - -def delete_event(db: Session, event_id: int): - db_event = db.query(models.Event).filter(models.Event.id == event_id).first() - - if not db_event: - raise HTTPException(status_code=404, detail="Event not found") - - db.delete(db_event) - db.commit() - return Response(status_code=204) - - -def create_tag(db: Session, tag: schemas.TagCreate): - db_tag = models.Tag(id=tag.id, description=tag.description) - db.add(db_tag) - db.commit() - db.refresh(db_tag) - return db_tag - - -def get_tags(db: Session): - return ( - db.query(models.Tag).order_by(models.Tag.created_at.desc(), models.Tag.id).all() - ) - - -def get_tag(db: Session, tag_id: str): - return db.query(models.Tag).filter(models.Tag.id == tag_id).first() - - -def update_tag(db: Session, tag_id: str, tag: schemas.TagCreate): - db_tag = db.query(models.Tag).filter(models.Tag.id == tag_id).first() - if db_tag: - db_tag.description = tag.description - db.commit() - db.refresh(db_tag) - return db_tag - return None - - -def delete_tag(db: Session, tag_id: str): - db_tag = db.query(models.Tag).filter(models.Tag.id == tag_id).first() - if db_tag: - db.query(models.EventTag).filter(models.EventTag.tag_id == tag_id).delete() - db.delete(db_tag) - db.commit() - return db_tag - return None - - -def get_tags_by_ids(db: Session, tag_ids: list[str]): - return db.query(models.Tag).filter(models.Tag.id.in_(tag_ids)).all() - - -def get_or_create_tags(db: Session, tag_ids: list[str]) -> list[models.Tag]: - existing_tags = db.query(models.Tag).filter(models.Tag.id.in_(tag_ids)).all() - existing_ids = {tag.id for tag in existing_tags} - - missing_ids = set(tag_ids) - existing_ids - new_tags = [models.Tag(id=tag_id) for tag_id in missing_ids] - - if new_tags: - db.add_all(new_tags) - db.flush() - - return existing_tags + new_tags - - -def create_field(db: Session, field: schemas.FieldCreate): - db_field = models.Field( - name=field.name, description=field.description, field_type=field.field_type - ) - db.add(db_field) - db.commit() - db.refresh(db_field) - return db_field - - -def get_fields(db: Session): - return db.query(models.Field).order_by(models.Field.id).all() - - -def get_field(db: Session, field_id: int): - return db.query(models.Field).filter(models.Field.id == field_id).first() - - -def update_field(db: Session, field_id: int, field: schemas.FieldCreate): - db_field = db.query(models.Field).filter(models.Field.id == field_id).first() - if db_field: - db_field.name = field.name - db_field.description = field.description - db_field.field_type = field.field_type - db.commit() - db.refresh(db_field) - return db_field - return None - - -def delete_field(db: Session, field_id: int): - db_field = db.query(models.Field).filter(models.Field.id == field_id).first() - if db_field: - db.query(models.EventField).filter( - models.EventField.field_id == field_id - ).delete() - db.delete(db_field) - db.commit() - return db_field - return None - - -def get_field_event_count(db: Session, field_id: int): - return ( - db.query(func.count(models.EventField.event_id)) - .filter(models.EventField.field_id == field_id) - .scalar() - ) - - -def get_fields_by_ids(db: Session, field_ids: list[int]): - return db.query(models.Field).filter(models.Field.id.in_(field_ids)).all() diff --git a/backend/app/database/seeds/__main__.py b/backend/app/database/seeds/__main__.py deleted file mode 100644 index 386038b..0000000 --- a/backend/app/database/seeds/__main__.py +++ /dev/null @@ -1,21 +0,0 @@ -from app.database.database import get_db -from app.database.seeds.seed_events import seed_events -from app.database.seeds.seed_fields import seed_fields -from app.database.seeds.seed_tags import seed_tags - - -def run_all_seeds(): - db_gen = get_db() - db = next(db_gen) - - try: - seed_tags(db) - seed_fields(db) - seed_events(db) - print("๐ŸŒฑ All seeds completed.") - finally: - db_gen.close() - - -if __name__ == "__main__": - run_all_seeds() diff --git a/backend/app/database/seeds/reset_db.py b/backend/app/database/seeds/reset_db.py deleted file mode 100644 index a4b5cc3..0000000 --- a/backend/app/database/seeds/reset_db.py +++ /dev/null @@ -1,23 +0,0 @@ -from sqlalchemy import MetaData - -from app import models -from app.database.database import engine - - -def reset_database(): - print("๐Ÿ“‚ DB URL:", engine.url) - - print("๐Ÿงจ Dropping all tables...") - - meta = MetaData() - meta.reflect(bind=engine) - meta.drop_all(bind=engine) - - print("๐Ÿ›  Recreating all tables...") - models.Base.metadata.create_all(bind=engine) - - print("โœ… Database reset complete.") - - -if __name__ == "__main__": - reset_database() diff --git a/backend/app/factory.py b/backend/app/factory.py index 402d608..1fc94fd 100644 --- a/backend/app/factory.py +++ b/backend/app/factory.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api.v1.routes import events, fields, generic, tags +from app.api.v1.routes import admin, events, fields, generic, tags from app.settings import Settings @@ -58,6 +58,7 @@ def create_app(settings: Settings) -> FastAPI: app.include_router(tags.router, prefix="/v1", tags=["tags"]) app.include_router(fields.router, prefix="/v1", tags=["fields"]) app.include_router(generic.router, prefix="/v1", tags=["generic"]) + app.include_router(admin.router, prefix="/v1", tags=["admin"]) @app.get("/") def read_root(): diff --git a/backend/app/main.py b/backend/app/main.py index 74624ae..7cbea77 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from pydantic import ValidationError -from app.database.database import init_db +from app.core.database import init_db from app.factory import create_app from app.settings import Settings diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py deleted file mode 100644 index 284f15c..0000000 --- a/backend/app/models/__init__.py +++ /dev/null @@ -1,114 +0,0 @@ -from datetime import datetime - -from sqlalchemy import ( - JSON, - Column, - DateTime, - Enum, - ForeignKey, - Integer, - String, - func, -) -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.database.database import Base -from app.schemas import FieldType - - -class TimestampMixin: - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), nullable=False - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - server_default=func.now(), - onupdate=func.now(), - nullable=False, - ) - - -class Event(Base, TimestampMixin): - __tablename__ = "events" - - id = Column(Integer, primary_key=True, index=True) - name = Column(String, index=True) - description = Column(String, nullable=True) - links = Column(JSON, nullable=True) - - tag_links = relationship( - "EventTag", back_populates="event", cascade="all, delete-orphan" - ) - tags = relationship( - "Tag", - secondary="event_tags", - back_populates="events", - viewonly=True, - order_by="Tag.id.asc()", - ) - - field_links = relationship( - "EventField", back_populates="event", cascade="all, delete-orphan" - ) - fields = relationship( - "Field", - secondary="event_fields", - back_populates="events", - viewonly=True, - order_by="Field.id.asc()", - ) - - -class Tag(Base, TimestampMixin): - __tablename__ = "tags" - - id = Column(String, primary_key=True, index=True) - description = Column(String, nullable=True) - - events = relationship( - "Event", secondary="event_tags", back_populates="tags", viewonly=True - ) - tag_links = relationship( - "EventTag", back_populates="tag", cascade="all, delete-orphan" - ) - - -class EventTag(Base, TimestampMixin): - __tablename__ = "event_tags" - - event_id = Column(Integer, ForeignKey("events.id"), primary_key=True) - tag_id = Column(String, ForeignKey("tags.id"), primary_key=True) - - event = relationship("Event", back_populates="tag_links") - tag = relationship("Tag", back_populates="tag_links") - - -class Field(Base, TimestampMixin): - __tablename__ = "fields" - - id = Column(Integer, primary_key=True, index=True) - name = Column(String, unique=True, index=True) - description = Column(String, nullable=True) - field_type = Column(Enum(FieldType), nullable=False) - example = Column(JSON, nullable=True) - - events = relationship( - "Event", secondary="event_fields", back_populates="fields", viewonly=True - ) - field_links = relationship( - "EventField", back_populates="field", cascade="all, delete-orphan" - ) - - -class EventField(Base, TimestampMixin): - __tablename__ = "event_fields" - - event_id = Column( - Integer, ForeignKey("events.id", ondelete="CASCADE"), primary_key=True - ) - field_id = Column( - Integer, ForeignKey("fields.id", ondelete="CASCADE"), primary_key=True - ) - - event = relationship("Event", back_populates="field_links") - field = relationship("Field", back_populates="field_links") diff --git a/backend/app/modules/admin/io/router.py b/backend/app/modules/admin/io/router.py new file mode 100644 index 0000000..7f25560 --- /dev/null +++ b/backend/app/modules/admin/io/router.py @@ -0,0 +1,61 @@ +from fastapi import APIRouter, Depends, Query, Request, status +from sqlalchemy.orm import Session + +from app.core.database import get_db + +from .schemas import ExportBundle +from .service import ExportTarget, ImportSource, export_to, import_from + +router = APIRouter(prefix="/io", tags=["io"]) + + +@router.get( + "/export", + response_model=ExportBundle, + status_code=status.HTTP_200_OK, + summary="Export all data", + description=( + "Export all tags, fields, and events as a single bundle. " + "Supports JSON (default), with future support for CSV and Markdown." + ), + responses={ + 200: {"description": "Export completed successfully"}, + 400: {"description": "Invalid export format"}, + 501: {"description": "Export format not implemented"}, + }, +) +def export_data( + db: Session = Depends(get_db), + target: ExportTarget = Query( + ExportTarget.json, description="Export format (default: json)" + ), +): + return export_to(target, db) + + +@router.post( + "/import", + status_code=status.HTTP_201_CREATED, + summary="Import data from JSON (or future formats)", + description=( + "Import tags, fields, and events. " + "Only works on an empty database. Supports `source=json` (default). " + "`csv` and `sheets` are reserved for future use." + ), + responses={ + 201: {"description": "Import completed successfully"}, + 400: {"description": "Invalid input or unsupported source"}, + 405: {"description": "Import not allowed on non-empty database"}, + 501: {"description": "Import method not implemented"}, + }, +) +async def import_data( + request: Request, + db: Session = Depends(get_db), + source: ImportSource = Query( + ImportSource.json, description="Import source format (default: json)" + ), +): + data = await request.json() + import_from(source, data, db) + return {"status": "ok"} diff --git a/backend/app/modules/admin/io/schemas.py b/backend/app/modules/admin/io/schemas.py new file mode 100644 index 0000000..e406234 --- /dev/null +++ b/backend/app/modules/admin/io/schemas.py @@ -0,0 +1,35 @@ +from typing import Any, List, Optional + +from pydantic import BaseModel + +from app.modules.fields.schemas import FieldType + + +class ExportTag(BaseModel): + id: str + description: Optional[str] + + +class ExportField(BaseModel): + name: str + description: Optional[str] + field_type: FieldType + example: Optional[Any] + + +class ExportEvent(BaseModel): + name: str + description: Optional[str] + links: Optional[list[dict]] + tags: List[str] + fields: List[str] + + +class ExportBundle(BaseModel): + tags: List[ExportTag] + fields: List[ExportField] + events: List[ExportEvent] + + +class ImportBundle(ExportBundle): + pass diff --git a/backend/app/modules/admin/io/service.py b/backend/app/modules/admin/io/service.py new file mode 100644 index 0000000..f2dcb98 --- /dev/null +++ b/backend/app/modules/admin/io/service.py @@ -0,0 +1,128 @@ +from enum import Enum + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.modules.events.models import Event +from app.modules.fields.models import Field +from app.modules.tags.crud import get_or_create_tags +from app.modules.tags.models import Tag +from app.shared.models import EventField, EventTag +from app.shared.service import assert_db_empty + +from .schemas import ExportBundle, ExportEvent, ExportField, ExportTag, ImportBundle + + +class ImportSource(str, Enum): + json = "json" + csv = "csv" + + +class ExportTarget(str, Enum): + json = "json" + csv = "csv" + markdown = "markdown" + + +def export_bundle(db: Session) -> ExportBundle: + tags = db.query(Tag).all() + fields = db.query(Field).all() + events = db.query(Event).all() + + return ExportBundle( + tags=[ExportTag(id=tag.id, description=tag.description) for tag in tags], + fields=[ + ExportField( + name=field.name, + description=field.description, + field_type=field.field_type, + example=field.example, + ) + for field in fields + ], + events=[ + ExportEvent( + name=event.name, + description=event.description, + links=event.links, + tags=[tag.id for tag in event.tags], + fields=[field.name for field in event.fields], + ) + for event in events + ], + ) + + +def export_to(target: ExportTarget, db: Session) -> ExportBundle | str: + if target == ExportTarget.json: + return export_bundle(db) + + elif target == ExportTarget.csv: + raise HTTPException(status_code=501, detail="CSV export is not implemented yet") + + elif target == ExportTarget.markdown: + raise HTTPException( + status_code=501, detail="Markdown export is not implemented yet" + ) + + else: + raise HTTPException(status_code=400, detail=f"Unknown export source: {target}") + + +def import_bundle(bundle: ImportBundle, db: Session): + assert_db_empty(db) + + # Create fields and build name โ†’ model map + field_map = {} + for field_data in bundle.fields: + field = Field( + name=field_data.name, + description=field_data.description, + field_type=field_data.field_type, + example=field_data.example, + ) + db.add(field) + db.flush() # Ensures ID is available before linking + field_map[field.name] = field + + all_tag_ids = {tag.id for tag in bundle.tags} + for event in bundle.events: + all_tag_ids.update(event.tags) + + _ = get_or_create_tags(db, list(all_tag_ids)) + db.flush() + + for event_data in bundle.events: + event = Event( + name=event_data.name, + description=event_data.description, + links=event_data.links or [], + ) + db.add(event) + db.flush() + + for tag_id in event_data.tags: + db.add(EventTag(event_id=event.id, tag_id=tag_id)) + + for field_name in event_data.fields: + if field_name not in field_map: + raise HTTPException( + status_code=400, detail=f"Unknown field name: {field_name}" + ) + db.add(EventField(event_id=event.id, field_id=field_map[field_name].id)) + + db.commit() + + +def import_from(source: ImportSource, data: any, db: Session): + if source == ImportSource.json: + if not isinstance(data, dict): + raise HTTPException(status_code=400, detail="Expected JSON object") + bundle = ImportBundle(**data) + import_bundle(bundle, db) + + elif source == ImportSource.csv: + raise HTTPException(status_code=501, detail="CSV import is not implemented yet") + + else: + raise HTTPException(status_code=400, detail=f"Unknown import source: {source}") diff --git a/backend/app/modules/admin/reset/router.py b/backend/app/modules/admin/reset/router.py new file mode 100644 index 0000000..8f8e654 --- /dev/null +++ b/backend/app/modules/admin/reset/router.py @@ -0,0 +1,33 @@ +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.orm import Session + +from app.core.database import get_db + +from .service import count_entities, reset_database + +router = APIRouter(prefix="/reset", tags=["reset"]) + + +@router.post( + "/", + status_code=status.HTTP_200_OK, + summary="Reset all data", + description=( + "Delete all tags, fields, and events. Use with caution. " + "Set `dry_run=true` to simulate the reset without deleting anything." + ), + responses={ + 200: {"description": "Reset performed or dry-run preview returned"}, + }, +) +def reset_all_data( + dry_run: bool = Query( + True, description="If true, return number of records that would be deleted." + ), + db: Session = Depends(get_db), +): + if dry_run: + return {"would_delete": count_entities(db)} + + reset_database(db) + return {"status": "ok"} diff --git a/backend/app/modules/admin/reset/service.py b/backend/app/modules/admin/reset/service.py new file mode 100644 index 0000000..5ed46c7 --- /dev/null +++ b/backend/app/modules/admin/reset/service.py @@ -0,0 +1,20 @@ +from sqlalchemy.orm import Session + +from app.modules.events.models import Event +from app.modules.fields.models import Field +from app.modules.tags.models import Tag + + +def reset_database(db: Session): + for model in [Event, Field, Tag]: + for obj in db.query(model).all(): + db.delete(obj) + db.commit() + + +def count_entities(db: Session) -> dict: + return { + "events": db.query(Event).count(), + "fields": db.query(Field).count(), + "tags": db.query(Tag).count(), + } diff --git a/backend/app/modules/admin/seed/router.py b/backend/app/modules/admin/seed/router.py new file mode 100644 index 0000000..5666c08 --- /dev/null +++ b/backend/app/modules/admin/seed/router.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter, Depends, status +from sqlalchemy.orm import Session + +from app.core.database import get_db + +from .service import seed_all + +router = APIRouter(prefix="/seed", tags=["seed"]) + + +@router.post( + "/", + status_code=status.HTTP_201_CREATED, + summary="Seed the database with example data", + description="Run module-specific seeding functions to populate the database with test/demo data.", + responses={ + 201: {"description": "Seeding completed successfully"}, + 405: {"description": "Seeding not allowed on non-empty database"}, + }, +) +def seed_data(db: Session = Depends(get_db)): + seed_all(db) + return {"status": "ok"} diff --git a/backend/app/modules/admin/seed/service.py b/backend/app/modules/admin/seed/service.py new file mode 100644 index 0000000..d7b8b59 --- /dev/null +++ b/backend/app/modules/admin/seed/service.py @@ -0,0 +1,13 @@ +from sqlalchemy.orm import Session + +from app.modules.events.seed.seeder import seed as seed_events +from app.modules.fields.seed.seeder import seed as seed_fields +from app.modules.tags.seed.seeder import seed as seed_tags +from app.shared.service import assert_db_empty + + +def seed_all(db: Session): + assert_db_empty(db) + seed_tags(db) + seed_fields(db) + seed_events(db) diff --git a/backend/app/modules/events/crud.py b/backend/app/modules/events/crud.py new file mode 100644 index 0000000..c453ec2 --- /dev/null +++ b/backend/app/modules/events/crud.py @@ -0,0 +1,77 @@ +from fastapi import HTTPException, Response +from sqlalchemy.orm import Session, joinedload + +from . import models, schemas + + +def create_event(db: Session, event: schemas.EventCreate): + db_event = models.Event(name=event.name, description=event.description) + db.add(db_event) + db.commit() + db.refresh(db_event) + + for tag_id in event.tags: + db.add(models.EventTag(event_id=db_event.id, tag_id=tag_id)) + + for field_id in event.fields: + db.add(models.EventField(event_id=db_event.id, field_id=field_id)) + + db.commit() + db.refresh(db_event) + return db_event + + +def get_event(db: Session, event_id: int): + return db.query(models.Event).filter(models.Event.id == event_id).first() + + +def get_events(db: Session): + return ( + db.query(models.Event) + .options( + joinedload(models.Event.tags), + joinedload(models.Event.fields), + ) + .order_by(models.Event.id) + .all() + ) + + +def update_event(db: Session, event_id: int, event: schemas.EventCreate): + db_event = db.query(models.Event).filter(models.Event.id == event_id).first() + if db_event is None: + return None + + db_event.name = event.name + db_event.description = event.description + + if event.tags is not None: + db.query(models.EventTag).filter( + models.EventTag.event_id == db_event.id + ).delete() + if event.tags: + for tag_id in event.tags: + db.add(models.EventTag(event_id=db_event.id, tag_id=tag_id)) + + if event.fields is not None: + db.query(models.EventField).filter( + models.EventField.event_id == db_event.id + ).delete() + if event.fields: + for field_id in event.fields: + db.add(models.EventField(event_id=db_event.id, field_id=field_id)) + + db.commit() + db.refresh(db_event) + return db_event + + +def delete_event(db: Session, event_id: int): + db_event = db.query(models.Event).filter(models.Event.id == event_id).first() + + if not db_event: + raise HTTPException(status_code=404, detail="Event not found") + + db.delete(db_event) + db.commit() + return Response(status_code=204) diff --git a/backend/app/modules/events/models.py b/backend/app/modules/events/models.py new file mode 100644 index 0000000..f0b6219 --- /dev/null +++ b/backend/app/modules/events/models.py @@ -0,0 +1,39 @@ +from sqlalchemy import JSON, Column, Integer, String +from sqlalchemy.orm import relationship + +from app.core.database import Base +from app.core.models import TimestampMixin + +# Required for SQLAlchemy relationship back_populates resolution +from app.shared.models import EventField, EventTag # noqa: F401 + + +class Event(Base, TimestampMixin): + __tablename__ = "events" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, index=True) + description = Column(String, nullable=True) + links = Column(JSON, nullable=True) + + tag_links = relationship( + "EventTag", back_populates="event", cascade="all, delete-orphan" + ) + tags = relationship( + "Tag", + secondary="event_tags", + back_populates="events", + viewonly=True, + order_by="Tag.id.asc()", + ) + + field_links = relationship( + "EventField", back_populates="event", cascade="all, delete-orphan" + ) + fields = relationship( + "Field", + secondary="event_fields", + back_populates="events", + viewonly=True, + order_by="Field.id.asc()", + ) diff --git a/backend/app/schemas/__init__.py b/backend/app/modules/events/schemas.py similarity index 52% rename from backend/app/schemas/__init__.py rename to backend/app/modules/events/schemas.py index 484eecd..fbbf884 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/modules/events/schemas.py @@ -1,17 +1,11 @@ from datetime import datetime from enum import Enum -from typing import Any, List, Optional +from typing import List, Optional from pydantic import BaseModel, ConfigDict - -class FieldType(str, Enum): - string = "string" - number = "number" - integer = "integer" - boolean = "boolean" - array = "array" - object = "object" +from app.modules.fields.schemas import FieldOut +from app.modules.tags.schemas import TagOut class LinkType(str, Enum): @@ -31,45 +25,6 @@ class EventLink(BaseModel): label: Optional[str] = None -class TagBase(BaseModel): - id: str - description: str | None = None - - -class TagCreate(TagBase): - pass - - -class TagOut(TagBase): - created_at: datetime - updated_at: datetime - - model_config = ConfigDict(from_attributes=True) - - -class FieldBase(BaseModel): - name: str - description: Optional[str] = None - field_type: FieldType - example: Optional[Any] = None - - -class FieldCreate(FieldBase): - pass - - -class FieldOut(FieldBase): - id: int - created_at: datetime - updated_at: datetime - - model_config = ConfigDict(from_attributes=True) - - -class FieldOutWithEventCount(FieldOut): - event_count: int - - class EventBase(BaseModel): name: str description: Optional[str] = None diff --git a/backend/app/modules/events/seed/__init__.py b/backend/app/modules/events/seed/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/database/seeds/seed_events.py b/backend/app/modules/events/seed/seeder.py similarity index 87% rename from backend/app/database/seeds/seed_events.py rename to backend/app/modules/events/seed/seeder.py index c5a5477..1f6381f 100644 --- a/backend/app/database/seeds/seed_events.py +++ b/backend/app/modules/events/seed/seeder.py @@ -3,8 +3,10 @@ from faker import Faker from sqlalchemy.orm import Session -from app import models -from app.schemas import LinkType +from app.modules.events.models import Event, EventField, EventTag +from app.modules.events.schemas import LinkType +from app.modules.fields.models import Field +from app.modules.tags.models import Tag faker = Faker() @@ -122,9 +124,9 @@ def generate_event_link(): } -def seed_events(db: Session, count: int = 10): - tags = db.query(models.Tag).all() - fields = db.query(models.Field).all() +def seed(db: Session, count: int = 10): + tags = db.query(Tag).all() + fields = db.query(Field).all() if not tags or not fields: print("โš ๏ธ No tags or fields available. Please seed them first.") @@ -140,7 +142,7 @@ def seed_events(db: Session, count: int = 10): links = [generate_event_link() for _ in range(random.randint(0, 4))] - event = models.Event( + event = Event( name=name, description=generate_event_description(), links=links, @@ -151,11 +153,11 @@ def seed_events(db: Session, count: int = 10): # Attach 0โ€“2 random tags for tag in random.sample(tags, k=random.randint(0, min(2, len(tags)))): - db.add(models.EventTag(event_id=event.id, tag_id=tag.id)) + db.add(EventTag(event_id=event.id, tag_id=tag.id)) # Attach 1โ€“6 random fields for field in random.sample(fields, k=random.randint(1, min(6, len(fields)))): - db.add(models.EventField(event_id=event.id, field_id=field.id)) + db.add(EventField(event_id=event.id, field_id=field.id)) db.commit() diff --git a/backend/app/modules/events/service.py b/backend/app/modules/events/service.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/fields/crud.py b/backend/app/modules/fields/crud.py new file mode 100644 index 0000000..3d9ba7b --- /dev/null +++ b/backend/app/modules/fields/crud.py @@ -0,0 +1,58 @@ +from sqlalchemy import func +from sqlalchemy.orm import Session + +from . import models, schemas + + +def create_field(db: Session, field: schemas.FieldCreate): + db_field = models.Field( + name=field.name, description=field.description, field_type=field.field_type + ) + db.add(db_field) + db.commit() + db.refresh(db_field) + return db_field + + +def get_fields(db: Session): + return db.query(models.Field).order_by(models.Field.id).all() + + +def get_field(db: Session, field_id: int): + return db.query(models.Field).filter(models.Field.id == field_id).first() + + +def update_field(db: Session, field_id: int, field: schemas.FieldCreate): + db_field = db.query(models.Field).filter(models.Field.id == field_id).first() + if db_field: + db_field.name = field.name + db_field.description = field.description + db_field.field_type = field.field_type + db.commit() + db.refresh(db_field) + return db_field + return None + + +def delete_field(db: Session, field_id: int): + db_field = db.query(models.Field).filter(models.Field.id == field_id).first() + if db_field: + db.query(models.EventField).filter( + models.EventField.field_id == field_id + ).delete() + db.delete(db_field) + db.commit() + return db_field + return None + + +def get_field_event_count(db: Session, field_id: int): + return ( + db.query(func.count(models.EventField.event_id)) + .filter(models.EventField.field_id == field_id) + .scalar() + ) + + +def get_fields_by_ids(db: Session, field_ids: list[int]): + return db.query(models.Field).filter(models.Field.id.in_(field_ids)).all() diff --git a/backend/app/modules/fields/models.py b/backend/app/modules/fields/models.py new file mode 100644 index 0000000..eff0e38 --- /dev/null +++ b/backend/app/modules/fields/models.py @@ -0,0 +1,23 @@ +from sqlalchemy import JSON, Column, Enum, Integer, String +from sqlalchemy.orm import relationship + +from app.core.database import Base +from app.core.models import TimestampMixin +from app.modules.fields.schemas import FieldType + + +class Field(Base, TimestampMixin): + __tablename__ = "fields" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, unique=True, index=True) + description = Column(String, nullable=True) + field_type = Column(Enum(FieldType), nullable=False) + example = Column(JSON, nullable=True) + + events = relationship( + "Event", secondary="event_fields", back_populates="fields", viewonly=True + ) + field_links = relationship( + "EventField", back_populates="field", cascade="all, delete-orphan" + ) diff --git a/backend/app/modules/fields/schemas.py b/backend/app/modules/fields/schemas.py new file mode 100644 index 0000000..17d29d7 --- /dev/null +++ b/backend/app/modules/fields/schemas.py @@ -0,0 +1,37 @@ +from datetime import datetime +from enum import Enum +from typing import Any, Optional + +from pydantic import BaseModel, ConfigDict + + +class FieldType(str, Enum): + string = "string" + number = "number" + integer = "integer" + boolean = "boolean" + array = "array" + object = "object" + + +class FieldBase(BaseModel): + name: str + description: Optional[str] = None + field_type: FieldType + example: Optional[Any] = None + + +class FieldCreate(FieldBase): + pass + + +class FieldOut(FieldBase): + id: int + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class FieldOutWithEventCount(FieldOut): + event_count: int diff --git a/backend/app/modules/fields/seed/__init__.py b/backend/app/modules/fields/seed/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/database/seeds/seed_fields.py b/backend/app/modules/fields/seed/seeder.py similarity index 85% rename from backend/app/database/seeds/seed_fields.py rename to backend/app/modules/fields/seed/seeder.py index 47399c4..c6ff38c 100644 --- a/backend/app/database/seeds/seed_fields.py +++ b/backend/app/modules/fields/seed/seeder.py @@ -3,7 +3,7 @@ from faker import Faker from sqlalchemy.orm import Session -from app import models +from app.modules.fields.models import Field, FieldType fake = Faker() @@ -81,18 +81,18 @@ def generate_field_description(): ) -def generate_field_example(field_type: models.FieldType): - if field_type == models.FieldType.string: +def generate_field_example(field_type: FieldType): + if field_type == FieldType.string: return fake.word() - elif field_type == models.FieldType.integer: + elif field_type == FieldType.integer: return random.randint(0, 1000000) - elif field_type == models.FieldType.number: + elif field_type == FieldType.number: return random.uniform(0, 1000000) - elif field_type == models.FieldType.boolean: + elif field_type == FieldType.boolean: return random.choice([True, False]) - elif field_type == models.FieldType.array: + elif field_type == FieldType.array: return [fake.word() for _ in range(random.randint(1, 10))] - elif field_type == models.FieldType.object: + elif field_type == FieldType.object: return { fake.word(part_of_speech="noun"): fake.word(part_of_speech="adjective") for _ in range(random.randint(1, 5)) @@ -101,17 +101,17 @@ def generate_field_example(field_type: models.FieldType): return None -def seed_fields(db: Session, count: int = 20): +def seed(db: Session, count: int = 20): existing_names = set() for _ in range(count): name = generate_field_slug(existing_names) existing_names.add(name) - field_type = random.choice(list(models.FieldType)) + field_type = random.choice(list(FieldType)) example = generate_field_example(field_type) - db_field = models.Field( + db_field = Field( name=name, description=generate_field_description(), field_type=field_type, diff --git a/backend/app/modules/fields/service.py b/backend/app/modules/fields/service.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/modules/tags/crud.py b/backend/app/modules/tags/crud.py new file mode 100644 index 0000000..44f2c12 --- /dev/null +++ b/backend/app/modules/tags/crud.py @@ -0,0 +1,59 @@ +from sqlalchemy.orm import Session + +from . import models, schemas + + +def create_tag(db: Session, tag: schemas.TagCreate): + db_tag = models.Tag(id=tag.id, description=tag.description) + db.add(db_tag) + db.commit() + db.refresh(db_tag) + return db_tag + + +def get_tags(db: Session): + return ( + db.query(models.Tag).order_by(models.Tag.created_at.desc(), models.Tag.id).all() + ) + + +def get_tag(db: Session, tag_id: str): + return db.query(models.Tag).filter(models.Tag.id == tag_id).first() + + +def update_tag(db: Session, tag_id: str, tag: schemas.TagCreate): + db_tag = db.query(models.Tag).filter(models.Tag.id == tag_id).first() + if db_tag: + db_tag.description = tag.description + db.commit() + db.refresh(db_tag) + return db_tag + return None + + +def delete_tag(db: Session, tag_id: str): + db_tag = db.query(models.Tag).filter(models.Tag.id == tag_id).first() + if db_tag: + db.query(models.EventTag).filter(models.EventTag.tag_id == tag_id).delete() + db.delete(db_tag) + db.commit() + return db_tag + return None + + +def get_tags_by_ids(db: Session, tag_ids: list[str]): + return db.query(models.Tag).filter(models.Tag.id.in_(tag_ids)).all() + + +def get_or_create_tags(db: Session, tag_ids: list[str]) -> list[models.Tag]: + existing_tags = db.query(models.Tag).filter(models.Tag.id.in_(tag_ids)).all() + existing_ids = {tag.id for tag in existing_tags} + + missing_ids = set(tag_ids) - existing_ids + new_tags = [models.Tag(id=tag_id) for tag_id in missing_ids] + + if new_tags: + db.add_all(new_tags) + db.flush() + + return existing_tags + new_tags diff --git a/backend/app/modules/tags/models.py b/backend/app/modules/tags/models.py new file mode 100644 index 0000000..22051dc --- /dev/null +++ b/backend/app/modules/tags/models.py @@ -0,0 +1,19 @@ +from sqlalchemy import Column, String +from sqlalchemy.orm import relationship + +from app.core.database import Base +from app.core.models import TimestampMixin + + +class Tag(Base, TimestampMixin): + __tablename__ = "tags" + + id = Column(String, primary_key=True, index=True) + description = Column(String, nullable=True) + + events = relationship( + "Event", secondary="event_tags", back_populates="tags", viewonly=True + ) + tag_links = relationship( + "EventTag", back_populates="tag", cascade="all, delete-orphan" + ) diff --git a/backend/app/modules/tags/schemas.py b/backend/app/modules/tags/schemas.py new file mode 100644 index 0000000..2893a0e --- /dev/null +++ b/backend/app/modules/tags/schemas.py @@ -0,0 +1,19 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class TagBase(BaseModel): + id: str + description: str | None = None + + +class TagCreate(TagBase): + pass + + +class TagOut(TagBase): + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/modules/tags/seed/__init__.py b/backend/app/modules/tags/seed/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/database/seeds/seed_tags.py b/backend/app/modules/tags/seed/seeder.py similarity index 95% rename from backend/app/database/seeds/seed_tags.py rename to backend/app/modules/tags/seed/seeder.py index 0ffb99a..ff5b801 100644 --- a/backend/app/database/seeds/seed_tags.py +++ b/backend/app/modules/tags/seed/seeder.py @@ -3,7 +3,7 @@ from faker import Faker from sqlalchemy.orm import Session -from app import models +from app.modules.tags.models import Tag fake = Faker() @@ -90,14 +90,14 @@ def generate_tag_description(): ) -def seed_tags(db: Session, count: int = 10): +def seed(db: Session, count: int = 10): existing_names = set() for _ in range(count): name = generate_tag_name(existing_names) existing_names.add(name) - db_tag = models.Tag( + db_tag = Tag( id=name, description=generate_tag_description(), ) diff --git a/backend/app/modules/tags/service.py b/backend/app/modules/tags/service.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/shared/models.py b/backend/app/shared/models.py new file mode 100644 index 0000000..ab557bf --- /dev/null +++ b/backend/app/shared/models.py @@ -0,0 +1,34 @@ +from sqlalchemy import ( + Column, + ForeignKey, + Integer, + String, +) +from sqlalchemy.orm import relationship + +from app.core.database import Base +from app.core.models import TimestampMixin + + +class EventTag(Base, TimestampMixin): + __tablename__ = "event_tags" + + event_id = Column(Integer, ForeignKey("events.id"), primary_key=True) + tag_id = Column(String, ForeignKey("tags.id"), primary_key=True) + + event = relationship("Event", back_populates="tag_links") + tag = relationship("Tag", back_populates="tag_links") + + +class EventField(Base, TimestampMixin): + __tablename__ = "event_fields" + + event_id = Column( + Integer, ForeignKey("events.id", ondelete="CASCADE"), primary_key=True + ) + field_id = Column( + Integer, ForeignKey("fields.id", ondelete="CASCADE"), primary_key=True + ) + + event = relationship("Event", back_populates="field_links") + field = relationship("Field", back_populates="field_links") diff --git a/backend/app/shared/service.py b/backend/app/shared/service.py new file mode 100644 index 0000000..b373a25 --- /dev/null +++ b/backend/app/shared/service.py @@ -0,0 +1,13 @@ +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.modules.events.models import Event +from app.modules.fields.models import Field +from app.modules.tags.models import Tag + + +def assert_db_empty(db: Session): + if db.query(Event).first() or db.query(Field).first() or db.query(Tag).first(): + raise HTTPException( + status_code=405, detail="Action is only allowed on empty database" + ) diff --git a/backend/migrations/env.py b/backend/migrations/env.py index 76430e4..3869de9 100644 --- a/backend/migrations/env.py +++ b/backend/migrations/env.py @@ -5,7 +5,7 @@ from sqlalchemy import pool from alembic import context -from app import models +from app.core.database import Base from app.settings import Settings config = context.config @@ -14,7 +14,7 @@ fileConfig(config.config_file_name) settings = Settings() -target_metadata = models.Base.metadata +target_metadata = Base.metadata def run_migrations_offline(): """Run migrations in 'offline' mode.""" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index bb44904..5d4fb31 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -16,7 +16,8 @@ dependencies = [ "psycopg2-binary (>=2.9.10,<3.0.0)", "python-dotenv (>=1.1.0,<2.0.0)", "pydantic-settings (>=2.9.1,<3.0.0)", - "alembic (>=1.15.2,<2.0.0)" + "alembic (>=1.15.2,<2.0.0)", + "faker (>=37.3.0,<38.0.0)" ] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 4ce5569..75bdc46 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -2,7 +2,7 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session -from app.database.database import Base, get_db, init_db +from app.core.database import Base, get_db, init_db from app.factory import create_app from app.settings import Settings diff --git a/backend/tests/test_events.py b/backend/tests/test_events.py index f06b760..8290902 100644 --- a/backend/tests/test_events.py +++ b/backend/tests/test_events.py @@ -1,6 +1,6 @@ import pytest -from app.schemas import EventCreate +from app.modules.events.schemas import EventCreate @pytest.fixture diff --git a/backend/tests/test_fields.py b/backend/tests/test_fields.py index 66fbd82..ffcc0af 100644 --- a/backend/tests/test_fields.py +++ b/backend/tests/test_fields.py @@ -1,6 +1,6 @@ import pytest -from app.schemas import FieldCreate +from app.modules.fields.schemas import FieldCreate @pytest.fixture diff --git a/backend/tests/test_tags.py b/backend/tests/test_tags.py index 758c7d7..147b2c9 100644 --- a/backend/tests/test_tags.py +++ b/backend/tests/test_tags.py @@ -1,6 +1,6 @@ import pytest -from app.schemas import TagCreate +from app.modules.tags.schemas import TagCreate @pytest.fixture diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index 25a4800..9dd6a4a 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -20,6 +20,9 @@ services: build: context: ./frontend dockerfile: Dockerfile.dev + args: + VITE_API_URL: ${VITE_API_URL} + VITE_ENV: ${VITE_ENV} volumes: - ./frontend:/app - /app/node_modules diff --git a/frontend/src/index.css b/frontend/src/index.css index dc6a56d..9c13443 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,4 +1,4 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100..900&family=Pacifico&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,100..900;1,100..900&family=Pacifico&display=swap'); @import 'tailwindcss'; @import 'tw-animate-css'; @@ -158,7 +158,6 @@ } /* Card transition */ -/* index.css */ .fade-enter-active, .fade-leave-active { transition: @@ -178,6 +177,22 @@ transform: translateY(0); } +/* Text transition */ +.fade-slide-enter-active, +.fade-slide-leave-active { + transition: all 0.2s ease; +} + +.fade-slide-enter-from { + opacity: 0; + transform: translateY(-2px); +} + +.fade-slide-leave-to { + opacity: 0; + transform: translateY(2px); +} + /* Scrolling */ .hide-scrollbar { scrollbar-width: none; /* Firefox */ @@ -235,10 +250,6 @@ @apply bg-background text-foreground flex min-h-screen justify-center; } - /* main { - @apply text-center - } */ - #app { @apply mx-auto w-full max-w-screen-md flex-1 px-4; } diff --git a/frontend/src/modules/events/components/EventForm.vue b/frontend/src/modules/events/components/EventForm.vue index fea5d39..c118ca1 100644 --- a/frontend/src/modules/events/components/EventForm.vue +++ b/frontend/src/modules/events/components/EventForm.vue @@ -28,13 +28,12 @@ import { ComboboxItem, ComboboxList, } from '@/shared/ui/combobox' -import { ComboboxInput } from 'reka-ui' +import { ComboboxInput, useFilter } from 'reka-ui' import type { Event } from '@/modules/events/types' import type { Field } from '@/modules/fields/types' import type { Tag } from '@/modules/tags/types' import { computed, ref, watchEffect } from 'vue' import { eventSchema, type EventFormValues } from '@/modules/events/validation/eventSchema' -import { useFilter } from 'reka-ui' import Skeleton from '@/shared/ui/skeleton/Skeleton.vue' import LinkedFieldsSelector from '@/modules/fields/components/LinkedFieldsSelector.vue' diff --git a/frontend/src/modules/events/components/EventsDataTable.vue b/frontend/src/modules/events/components/EventsDataTable.vue index c29aa83..d02f1d4 100644 --- a/frontend/src/modules/events/components/EventsDataTable.vue +++ b/frontend/src/modules/events/components/EventsDataTable.vue @@ -39,7 +39,9 @@ const { table } = useDataTable({ diff --git a/frontend/src/modules/events/pages/EventsPage.vue b/frontend/src/modules/events/pages/EventsPage.vue index 180363e..7070462 100644 --- a/frontend/src/modules/events/pages/EventsPage.vue +++ b/frontend/src/modules/events/pages/EventsPage.vue @@ -113,7 +113,7 @@ onMounted(() => { :onClose="() => (showDeleteModal = false)" :onConfirm="handleDelete" :isDeleting="isDeleting" - description="Once deleted, this event will be unlinked from any events it's part of." + description="Once deleted, this event will be removed permanently." /> diff --git a/frontend/src/modules/fields/components/FieldsDataTable.vue b/frontend/src/modules/fields/components/FieldsDataTable.vue index 0136ce1..c42ab61 100644 --- a/frontend/src/modules/fields/components/FieldsDataTable.vue +++ b/frontend/src/modules/fields/components/FieldsDataTable.vue @@ -40,6 +40,7 @@ const fieldTypes = Object.values(FieldType) diff --git a/frontend/src/modules/fields/components/LinkedFieldsSelector.vue b/frontend/src/modules/fields/components/LinkedFieldsSelector.vue index 8e7c487..0b8fb00 100644 --- a/frontend/src/modules/fields/components/LinkedFieldsSelector.vue +++ b/frontend/src/modules/fields/components/LinkedFieldsSelector.vue @@ -21,7 +21,7 @@ const emit = defineEmits<{ -
+
api.post('/admin/seed') + +export const resetDatabase = (dryRun = false) => + api + .post('/admin/reset', null, { + params: { dry_run: dryRun }, + }) + .then(r => r.data) + +export const importData = (payload: ImportBundle, source = 'json') => + api.post('/admin/io/import', payload, { params: { source } }) + +export const exportData = (target = 'json') => + api.get('/admin/io/export', { params: { target } }) diff --git a/frontend/src/modules/switchboard/components/ExportPanel.vue b/frontend/src/modules/switchboard/components/ExportPanel.vue new file mode 100644 index 0000000..a06e4ef --- /dev/null +++ b/frontend/src/modules/switchboard/components/ExportPanel.vue @@ -0,0 +1,64 @@ + + + diff --git a/frontend/src/modules/switchboard/components/ImportPanel.vue b/frontend/src/modules/switchboard/components/ImportPanel.vue new file mode 100644 index 0000000..f2ceb10 --- /dev/null +++ b/frontend/src/modules/switchboard/components/ImportPanel.vue @@ -0,0 +1,60 @@ + + + diff --git a/frontend/src/modules/switchboard/components/ResetPanel.vue b/frontend/src/modules/switchboard/components/ResetPanel.vue new file mode 100644 index 0000000..15fdf4f --- /dev/null +++ b/frontend/src/modules/switchboard/components/ResetPanel.vue @@ -0,0 +1,80 @@ + + + diff --git a/frontend/src/modules/switchboard/components/SeedPanel.vue b/frontend/src/modules/switchboard/components/SeedPanel.vue new file mode 100644 index 0000000..ecb0490 --- /dev/null +++ b/frontend/src/modules/switchboard/components/SeedPanel.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/modules/switchboard/layout/SwitchboardSectionLayout.vue b/frontend/src/modules/switchboard/layout/SwitchboardSectionLayout.vue new file mode 100644 index 0000000..af742fd --- /dev/null +++ b/frontend/src/modules/switchboard/layout/SwitchboardSectionLayout.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/src/modules/switchboard/pages/SwitchboardPage.vue b/frontend/src/modules/switchboard/pages/SwitchboardPage.vue new file mode 100644 index 0000000..3505e59 --- /dev/null +++ b/frontend/src/modules/switchboard/pages/SwitchboardPage.vue @@ -0,0 +1,36 @@ + + + diff --git a/frontend/src/modules/switchboard/router.ts b/frontend/src/modules/switchboard/router.ts new file mode 100644 index 0000000..dfc8600 --- /dev/null +++ b/frontend/src/modules/switchboard/router.ts @@ -0,0 +1,10 @@ +import type { RouteRecordRaw } from 'vue-router' +import SwitchboardPage from './pages/SwitchboardPage.vue' + +export const switchboardRoutes: RouteRecordRaw[] = [ + { + path: '/switchboard', + name: 'Switchboard', + component: SwitchboardPage, + }, +] diff --git a/frontend/src/modules/switchboard/types.ts b/frontend/src/modules/switchboard/types.ts new file mode 100644 index 0000000..cbc8fc1 --- /dev/null +++ b/frontend/src/modules/switchboard/types.ts @@ -0,0 +1,35 @@ +import type { EventLink } from '@/modules/events/types' + +export interface ImportBundle { + tags: { + id: string + description?: string | null + }[] + + fields: { + name: string + description?: string | null + field_type: string // could use enum if available + example?: unknown + }[] + + events: { + name: string + description?: string | null + links?: EventLink[] + tags: string[] + fields: string[] // field names + }[] +} +export type ImportSource = 'json' | 'csv' + +export type ExportBundle = ImportBundle +export type ExportTarget = 'json' | 'csv' | 'markdown' + +export interface ResetPreview { + would_delete: { + events: number + fields: number + tags: number + } +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index eb2fa6c..62a00b8 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -2,8 +2,20 @@ import { createRouter, createWebHistory } from 'vue-router' import { fieldsRoutes } from '@/modules/fields/router' import { eventsRoutes } from '@/modules/events/router' import { tagsRoutes } from '@/modules/tags/router' +import { switchboardRoutes } from '@/modules/switchboard/router' export const router = createRouter({ history: createWebHistory(), - routes: [{ path: '/', redirect: '/events' }, ...eventsRoutes, ...fieldsRoutes, ...tagsRoutes], + routes: [ + { path: '/', redirect: '/events' }, + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: () => import('@/shared/pages/NotFoundPage.vue'), + }, + ...eventsRoutes, + ...fieldsRoutes, + ...tagsRoutes, + ...switchboardRoutes, + ], }) diff --git a/frontend/src/shared/components/data/DataTableMultiSelectFilter.vue b/frontend/src/shared/components/data/DataTableMultiSelectFilter.vue index 822f238..14a40c4 100644 --- a/frontend/src/shared/components/data/DataTableMultiSelectFilter.vue +++ b/frontend/src/shared/components/data/DataTableMultiSelectFilter.vue @@ -22,6 +22,7 @@ import { Icon } from '@iconify/vue' interface DataTableFacetedFilter { column?: Column title?: string + icon?: string options: string[] } @@ -35,8 +36,10 @@ const selectedValues = computed(() => new Set(props.column?.getFilterValue() as