clovis 1 year ago
parent
commit
e3127530cc

+ 0 - 6
alembic.ini

@@ -58,12 +58,6 @@ sqlalchemy.url = driver://user:pass@localhost/dbname
 # on newly generated revision scripts.  See the documentation for further
 # detail and examples
 
-# format using "black" - use the console_scripts runner, against the "black" entrypoint
-hooks = black
-
-black.type = console_scripts
-black.entrypoint = black
-black.options = REVISION_SCRIPT_FILENAME
 
 # Logging configuration
 [loggers]

+ 30 - 0
alembic/versions/2024060200_cascade_template_deletion_to_slot_eabd3ad5aaf8.py

@@ -0,0 +1,30 @@
+"""cascade template deletion to slot
+
+Revision ID: eabd3ad5aaf8
+Revises: 4ca2e4cc7b95
+Create Date: 2024-06-02 16:00:29.689607
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'eabd3ad5aaf8'
+down_revision = '4ca2e4cc7b95'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_constraint('slots_template_id_fkey', 'slots', type_='foreignkey')
+    op.create_foreign_key('slots_template_id_fkey_v2', 'slots', 'slot_templates', ['template_id'], ['id'], ondelete='CASCADE')
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_constraint('slots_template_id_fkey_v2', 'slots', type_='foreignkey')
+    op.create_foreign_key('slots_template_id_fkey', 'slots', 'slot_templates', ['template_id'], ['id'])
+    # ### end Alembic commands ###

+ 45 - 0
alembic/versions/2024060210_stop_cascade_template_deletion_to_slot_8fee990845df.py

@@ -0,0 +1,45 @@
+"""stop cascade template deletion to slot
+
+Revision ID: 8fee990845df
+Revises: eabd3ad5aaf8
+Create Date: 2024-06-02 17:10:22.363720
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "8fee990845df"
+down_revision = "eabd3ad5aaf8"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_constraint("slots_template_id_fkey_v2", "slots", type_="foreignkey")
+    op.create_foreign_key(
+        "slots_template_id_fkey_v3",
+        "slots",
+        "slot_templates",
+        ["template_id"],
+        ["id"],
+        ondelete="SET NULL",
+    )
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_constraint("slots_template_id_fkey_v3", "slots", type_="foreignkey")
+    op.create_foreign_key(
+        "slots_template_id_fkey_v2",
+        "slots",
+        "slot_templates",
+        ["template_id"],
+        ["id"],
+        ondelete="CASCADE",
+    )
+    # ### end Alembic commands ###

+ 17 - 8
app/api/endpoints/project.py

@@ -7,7 +7,16 @@ from sqlalchemy.exc import IntegrityError
 from sqlalchemy.orm import Session
 
 from app.api import deps
-from app.models import Project, Slot, SlotTemplate, SlotTag, SlotTemplate, Sms, User, Volunteer
+from app.models import (
+    Project,
+    Slot,
+    SlotTemplate,
+    SlotTag,
+    SlotTemplate,
+    Sms,
+    User,
+    Volunteer,
+)
 from app.schemas.requests import (
     ProjectImportGsheetRequest,
     ProjectRequest,
@@ -150,7 +159,7 @@ async def update_project_from_gsheet(
                 else:
                     tag = SlotTag(project_id=project_id, title=s_tag)
                     session.add(tag)
-                    tags_map[s_tag]
+                    tags_map[s_tag] = tag
                 template.tags.append(tag)
 
         template_map[template.title] = template
@@ -228,9 +237,9 @@ async def create_sms_batch(
             .replace("{fin}", slot.ending_time.strftime("%Hh%M"))
         )
         if slot.template is not None:
-            slot_content = slot_content.replace("{description}", slot.template.description).replace(
-                "{respo}", slot.template.responsible_contact
-            )
+            slot_content = slot_content.replace(
+                "{description}", slot.template.description
+            ).replace("{respo}", slot.template.responsible_contact)
 
         sending_time = slot.starting_time - timedelta(minutes=sms_batch.delta_t)
         # Skip SMS that should have been send before now
@@ -240,9 +249,9 @@ async def create_sms_batch(
             if not volunteer.automatic_sms:
                 continue
             # Create a new SMS customized for each user attache to the slot
-            personalized_content = slot_content.replace("{prenom}", volunteer.name).replace(
-                "{nom}", volunteer.surname
-            )
+            personalized_content = slot_content.replace(
+                "{prenom}", volunteer.name
+            ).replace("{nom}", volunteer.surname)
             sms = Sms(
                 project_id=project_id,
                 volunteer_id=volunteer.id,

+ 15 - 5
app/api/endpoints/slots.py

@@ -55,7 +55,9 @@ async def create_slot(
     volunteers: list[UUID] = []
     if input_dict["volunteers"] is not None:
         volunteers = input_dict["volunteers"]
-        await verify_id_list(session, volunteers, project_id, Volunteer, "Invalid volunteer list")
+        await verify_id_list(
+            session, volunteers, project_id, Volunteer, "Invalid volunteer list"
+        )
     del input_dict["volunteers"]
 
     slot = Slot(project_id=project_id, **input_dict)
@@ -83,17 +85,23 @@ async def update_slot(
     """Update a slot from the project"""
     slot = session.get(Slot, slot_id)
     if (slot is None) or (slot.project_id != str(project_id)):
-        raise HTTPException(status_code=404, detail="Slot not found")
+        raise HTTPException(status_code=404, detail="Slot not found : ")
 
     if new_slot.template_id is not None:
         await verify_id_list(
-            session, [new_slot.template_id], project_id, SlotTemplate, "Invalid template id"
+            session,
+            [new_slot.template_id],
+            project_id,
+            SlotTemplate,
+            "Invalid template id",
         )
 
     input_dict = new_slot.model_dump(exclude_unset=True)
     if "volunteers" in input_dict:
         volunteers: list[UUID] = input_dict["volunteers"]
-        await verify_id_list(session, volunteers, project_id, Volunteer, "Invalid volunteer list")
+        await verify_id_list(
+            session, volunteers, project_id, Volunteer, "Invalid volunteer list"
+        )
         session.execute(
             association_table_volunteer_slot.delete().where(
                 association_table_volunteer_slot.c.slot_id == slot.id
@@ -123,5 +131,7 @@ async def delete_slot(
     session: Session = Depends(deps.get_session),
 ):
     """Delete a slot from the project"""
-    session.execute(delete(Slot).where((Slot.id == slot_id) & (Slot.project_id == project_id)))
+    session.execute(
+        delete(Slot).where((Slot.id == slot_id) & (Slot.project_id == project_id))
+    )
     session.commit()

+ 19 - 2
app/api/endpoints/templates.py

@@ -45,13 +45,30 @@ async def create_template(
         raise HTTPException(status_code=404, detail="Project not found")
 
     template = SlotTemplate(project_id=p.id, title=payload.title)
-    update_object_from_payload(template, payload.model_dump(exclude_unset=True))
+
     session.add(template)
     session.commit()
+    dic = payload.model_dump(exclude_unset=True)
+    if payload.tags is not None:
+        del dic["tags"]
+        if len(payload.tags) > 0:
+            await verify_id_list(
+                session, payload.tags, project_id, SlotTag, "Invalid template list"
+            )
+            session.execute(
+                association_table_template_tags.insert().values(
+                    [(template.id, t_id) for t_id in payload.tags]
+                )
+            )
+
+    update_object_from_payload(template, dic)
+    session.commit()
     return template
 
 
-@router.post("/project/{project_id}/template/{template_id}", response_model=TemplateResponse)
+@router.post(
+    "/project/{project_id}/template/{template_id}", response_model=TemplateResponse
+)
 async def update_template(
     project_id: UUID,
     template_id: UUID,

+ 12 - 9
app/gsheet.py

@@ -100,6 +100,7 @@ def getContactDataFrame(csv_filename: str, skiprows: int = 2) -> pd.DataFrame:
 def getCreneauDataFrame(csv_filename: str) -> pd.DataFrame:
     df_creneau = pd.read_csv(csv_filename)
     df_creneau.columns = ["title", "lieu", "description", "responsable", "tags"]
+    df_creneau[df_creneau.tags.isnull()].tags = ""
     return df_creneau
 
 
@@ -125,19 +126,19 @@ def getPlanningDataFrame(csv_filename, starting_date, skip_column=3):
         raise KeyError("This day is not valid : " + s)
 
     def getTime(time_str: str) -> datetime.timedelta:
-        l = time_str.split("h")
-        hours = int(l[0])
+        splitted_time = time_str.split("h")
+        hours = int(splitted_time[0])
         if hours < 5:
             hours += 24
-        if len(l) > 1 and l[1] != "":
-            return datetime.timedelta(hours=hours, minutes=int(l[1]))
+        if len(splitted_time) > 1 and splitted_time[1] != "":
+            return datetime.timedelta(hours=hours, minutes=int(splitted_time[1]))
         else:
             return datetime.timedelta(hours=hours)
 
     def getStartEnd(time_str: str) -> tuple:
-        l = time_str.split("-")
-        if len(l) == 2:
-            return getTime(l[0]), getTime(l[1])
+        pair = time_str.split("-")
+        if len(pair) == 2:
+            return getTime(pair[0]), getTime(pair[1])
         else:
             start = getTime(time_str.split("+")[0])
             return start, start + datetime.timedelta(hours=1)
@@ -147,7 +148,7 @@ def getPlanningDataFrame(csv_filename, starting_date, skip_column=3):
     days_str = headers[0]
     hours = headers[1]
     column_to_dates: dict[int, dict[str, datetime.datetime]] = {}
-    assert len(hours) == len(hours)
+
     for i in range(skip_column, len(days_str)):
         day = getDate(days_str[i])
         start, end = getStartEnd(hours[i])
@@ -159,7 +160,9 @@ def getPlanningDataFrame(csv_filename, starting_date, skip_column=3):
         current_benevole_name = ""
         current_time: dict[str, datetime.datetime] = {}
         for j in range(skip_column, len(row)):
-            if (current_benevole_name != "") and (row[j] == "" or row[j] != current_benevole_name):
+            if (current_benevole_name != "") and (
+                row[j] == "" or row[j] != current_benevole_name
+            ):
                 new_creneau = {
                     "id": uuid4(),
                     "template_id": row[0],

+ 52 - 17
app/models.py

@@ -13,6 +13,7 @@ alembic revision --autogenerate -m "migration_name"
 # apply all migrations
 alembic upgrade head
 """
+
 from typing import Optional
 import uuid
 from datetime import datetime
@@ -30,25 +31,33 @@ class Base(DeclarativeBase):
 
 def uid_column() -> Mapped[str]:
     """Returns a postgreSQL UUID column for SQL ORM"""
-    return mapped_column(UUID(as_uuid=False), primary_key=True, default=lambda _: str(uuid.uuid4()))
+    return mapped_column(
+        UUID(as_uuid=False), primary_key=True, default=lambda _: str(uuid.uuid4())
+    )
 
 
 class User(Base):
     __tablename__ = "user_model"
 
     id: Mapped[str] = uid_column()
-    email: Mapped[str] = mapped_column(String(254), nullable=False, unique=True, index=True)
+    email: Mapped[str] = mapped_column(
+        String(254), nullable=False, unique=True, index=True
+    )
     hashed_password: Mapped[str] = mapped_column(String(128), nullable=False)
 
 
 class Project(Base):
     __tablename__ = "projects"
     id: Mapped[str] = uid_column()
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True), server_default=func.now()
+    )
     updated_at: Mapped[datetime] = mapped_column(
         DateTime(timezone=True), default=datetime.now, onupdate=func.now()
     )
-    name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True)
+    name: Mapped[str] = mapped_column(
+        String(128), nullable=False, unique=True, index=True
+    )
     is_public: Mapped[bool] = mapped_column(Boolean())
     volunteers: Mapped[list["Volunteer"]] = relationship(
         back_populates="project", cascade="delete, delete-orphan"
@@ -82,9 +91,13 @@ association_table_volunteer_slot = Table(
 class Volunteer(Base):
     __tablename__ = "volunteers"
     id: Mapped[str] = uid_column()
-    project_id: Mapped[str] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
+    project_id: Mapped[str] = mapped_column(
+        ForeignKey("projects.id", ondelete="CASCADE")
+    )
     project: Mapped["Project"] = relationship(back_populates="volunteers")
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True), server_default=func.now()
+    )
     updated_at: Mapped[datetime] = mapped_column(
         DateTime(timezone=True), default=datetime.now, onupdate=func.now()
     )
@@ -106,10 +119,14 @@ class Slot(Base):
     __tablename__ = "slots"
 
     id: Mapped[str] = uid_column()
-    project_id: Mapped[str] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
+    project_id: Mapped[str] = mapped_column(
+        ForeignKey("projects.id", ondelete="CASCADE")
+    )
     project: Mapped["Project"] = relationship(back_populates="slots")
 
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True), server_default=func.now()
+    )
     updated_at: Mapped[datetime] = mapped_column(
         DateTime(timezone=True), default=datetime.now, onupdate=func.now()
     )
@@ -124,7 +141,9 @@ class Slot(Base):
         secondary=association_table_volunteer_slot, back_populates="slots"
     )
 
-    template_id: Mapped[Optional[str]] = mapped_column(ForeignKey("slot_templates.id"))
+    template_id: Mapped[Optional[str]] = mapped_column(
+        ForeignKey("slot_templates.id", ondelete="SET NULL"), nullable=True
+    )
     template: Mapped["SlotTemplate"] = relationship(back_populates="slots")
 
     @hybrid_property
@@ -148,10 +167,14 @@ class SlotTag(Base):
     __tablename__ = "slot_tags"
 
     id: Mapped[str] = uid_column()
-    project_id: Mapped[str] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
+    project_id: Mapped[str] = mapped_column(
+        ForeignKey("projects.id", ondelete="CASCADE")
+    )
     project: Mapped["Project"] = relationship(back_populates="tags")
 
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True), server_default=func.now()
+    )
     updated_at: Mapped[datetime] = mapped_column(
         DateTime(timezone=True), default=datetime.now, onupdate=func.now()
     )
@@ -170,10 +193,14 @@ class SlotTemplate(Base):
     __tablename__ = "slot_templates"
 
     id: Mapped[str] = uid_column()
-    project_id: Mapped[str] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
+    project_id: Mapped[str] = mapped_column(
+        ForeignKey("projects.id", ondelete="CASCADE")
+    )
     project: Mapped["Project"] = relationship(back_populates="templates")
 
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True), server_default=func.now()
+    )
     updated_at: Mapped[datetime] = mapped_column(
         DateTime(timezone=True), default=datetime.now, onupdate=func.now()
     )
@@ -203,15 +230,23 @@ class Sms(Base):
     id: Mapped[str] = mapped_column(
         UUID(as_uuid=False), primary_key=True, default=lambda _: str(uuid.uuid4())
     )
-    project_id: Mapped[str] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"))
+    project_id: Mapped[str] = mapped_column(
+        ForeignKey("projects.id", ondelete="CASCADE")
+    )
     project: Mapped["Project"] = relationship(back_populates="sms")
 
-    volunteer_id: Mapped[str] = mapped_column(ForeignKey("volunteers.id"), nullable=True)
-    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+    volunteer_id: Mapped[str] = mapped_column(
+        ForeignKey("volunteers.id"), nullable=True
+    )
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True), server_default=func.now()
+    )
     updated_at: Mapped[datetime] = mapped_column(
         DateTime(timezone=True), default=datetime.now, onupdate=func.now()
     )
     content: Mapped[str] = mapped_column(String(), nullable=False)
     phone_number: Mapped[str] = mapped_column(String(24))
-    sending_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=datetime.now)
+    sending_time: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True), default=datetime.now
+    )
     send_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)

+ 1 - 0
app/schemas/requests.py

@@ -109,6 +109,7 @@ class TemplateCreateRequest(BaseRequest):
     description: Optional[str] = None
     place: Optional[str] = None
     responsible_contact: Optional[str] = None
+    tags: Optional[list[UUID4]] = None
 
 
 class TemplateUpdateRequest(BaseRequest):

+ 1 - 0
app/schemas/responses.py

@@ -59,6 +59,7 @@ class TemplateResponse(BaseObjectResponse):
     description: str
     place: str
     responsible_contact: str
+    tags_id: list[str] = []
 
 
 class TagResponse(BaseObjectResponse):

+ 59 - 12
app/tests/test_template.py

@@ -5,7 +5,7 @@ from sqlalchemy import select
 from sqlalchemy.orm import Session
 
 from app.main import app
-from app.models import Project, SlotTemplate
+from app.models import Project, Slot, SlotTemplate
 from app.tests.conftest import default_slot_id, default_template_id, default_tag_id
 
 
@@ -22,7 +22,9 @@ async def test_create_template_fail(
     assert response.status_code == 422
 
     url = app.url_path_for("create_template", project_id=uuid4())
-    response = await client.post(url, json={"title": "1st template"}, headers=default_user_headers)
+    response = await client.post(
+        url, json={"title": "1st template"}, headers=default_user_headers
+    )
     assert response.status_code == 404
 
 
@@ -79,7 +81,9 @@ async def test_update_template_fail(
     response = await client.post(url, json=payload, headers=default_user_headers)
     assert response.status_code == 404
 
-    url = app.url_path_for("update_template", project_id=uuid4(), template_id=template_id)
+    url = app.url_path_for(
+        "update_template", project_id=uuid4(), template_id=template_id
+    )
     response = await client.post(url, json=payload, headers=default_user_headers)
     assert response.status_code == 404
 
@@ -104,7 +108,9 @@ async def test_update_template_fail_payload_validation(
     code: int,
 ):
     url = app.url_path_for(
-        "update_template", project_id=default_public_project.id, template_id=default_template_id
+        "update_template",
+        project_id=default_public_project.id,
+        template_id=default_template_id,
     )
     response = await client.post(url, json=payload, headers=default_user_headers)
     assert response.status_code == code
@@ -117,7 +123,11 @@ async def test_update_template_fail_payload_validation(
         {"title": "1st template", "place": "echo"},
         {"title": "1st template", "responsible_contact": "echo"},
         {"title": "1st template", "description": "&é'(-è_çecho"},
-        {"title": "1st template", "place": "Ḽơᶉëᶆ ȋṕšᶙṁ", "description": "&é'(-è_çecho"},
+        {
+            "title": "1st template",
+            "place": "Ḽơᶉëᶆ ȋṕšᶙṁ",
+            "description": "&é'(-è_çecho",
+        },
         {"title": "1st template", "place": "3", "description": "&é'(-è_çecho"},
     ],
 )
@@ -129,7 +139,9 @@ async def test_update_template(
     payload: dict,
 ):
     url = app.url_path_for(
-        "update_template", project_id=default_public_project.id, template_id=default_template_id
+        "update_template",
+        project_id=default_public_project.id,
+        template_id=default_template_id,
     )
     response = await client.post(url, json=payload, headers=default_user_headers)
     assert response.status_code == 200
@@ -150,7 +162,9 @@ async def test_update_template_lists(
     default_public_project: Project,
 ):
     url = app.url_path_for(
-        "update_template", project_id=default_public_project.id, template_id=default_template_id
+        "update_template",
+        project_id=default_public_project.id,
+        template_id=default_template_id,
     )
     payload = {"tags": [default_tag_id]}
     response = await client.post(url, json=payload, headers=default_user_headers)
@@ -180,12 +194,16 @@ async def test_delete_template_fail(
     response = await client.delete(url)
     assert response.status_code == 401
     # invalid tag
-    url = app.url_path_for("delete_template", project_id=project_id, template_id="default_tag_id")
+    url = app.url_path_for(
+        "delete_template", project_id=project_id, template_id="default_tag_id"
+    )
     response = await client.delete(url, headers=default_user_headers)
     assert response.status_code == 422
 
     # invalid project_id
-    url = app.url_path_for("delete_template", project_id="ded", template_id=default_template_id)
+    url = app.url_path_for(
+        "delete_template", project_id="ded", template_id=default_template_id
+    )
     response = await client.delete(url, headers=default_user_headers)
     assert response.status_code == 422
 
@@ -198,15 +216,44 @@ async def test_delete_template(
 ):
     # Proper deletion
     url = app.url_path_for(
-        "delete_template", project_id=default_public_project.id, template_id=default_template_id
+        "delete_template",
+        project_id=default_public_project.id,
+        template_id=default_template_id,
     )
     response = await client.delete(url, headers=default_user_headers)
     assert response.status_code == 200
-    result = session.execute(select(SlotTemplate).where(SlotTemplate.id == default_tag_id))
+    result = session.execute(
+        select(SlotTemplate).where(SlotTemplate.id == default_tag_id)
+    )
     slot = result.scalars().first()
     assert slot is None
 
     # can delete random uuid
-    url = app.url_path_for("delete_tag", project_id=default_public_project.id, tag_id=uuid4())
+    url = app.url_path_for(
+        "delete_tag", project_id=default_public_project.id, tag_id=uuid4()
+    )
+    response = await client.delete(url, headers=default_user_headers)
+    assert response.status_code == 200
+
+
+async def test_delete_template_not_slot(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
+    # Proper deletion
+    url = app.url_path_for(
+        "delete_template",
+        project_id=default_public_project.id,
+        template_id=default_template_id,
+    )
+    slot = session.get(Slot, default_slot_id)
+    assert slot is not None
+    slot.template_id = default_template_id
+    session.commit()
+
     response = await client.delete(url, headers=default_user_headers)
     assert response.status_code == 200
+    slot = session.get(Slot, default_slot_id)
+    assert slot is not None