Browse Source

implement slot template and tags

tripeur 1 year ago
parent
commit
0b660b9a73

+ 185 - 0
alembic/versions/2024052425_add_slot_template_4ca2e4cc7b95.py

@@ -0,0 +1,185 @@
+"""add_slot_template
+
+Revision ID: 4ca2e4cc7b95
+Revises: 2f90ef72e3b9
+Create Date: 2024-05-24 09:25:42.163966
+
+"""
+from datetime import datetime
+from typing import List, Optional
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import orm
+from sqlalchemy.ext.declarative import declarative_base
+
+import uuid
+from sqlalchemy.dialects.postgresql import UUID
+
+
+# revision identifiers, used by Alembic.
+revision = "4ca2e4cc7b95"
+down_revision = "2f90ef72e3b9"
+branch_labels = None
+depends_on = None
+
+
+def uid_column() -> orm.Mapped[str]:
+    """Returns a postgreSQL UUID column for SQL ORM"""
+    return orm.mapped_column(
+        UUID(as_uuid=False), primary_key=True, default=lambda _: str(uuid.uuid4())
+    )
+
+
+Base = declarative_base()
+
+
+class Project(Base):
+    __tablename__ = "projects"
+    id: orm.Mapped[str] = uid_column()
+
+
+class Slot(Base):
+    __tablename__ = "slots"
+    id = uid_column()
+    project_id = orm.mapped_column(sa.ForeignKey("projects.id", ondelete="CASCADE"))
+    title: orm.Mapped[str] = orm.mapped_column(sa.String(128), nullable=False)
+
+    description: orm.Mapped[str] = orm.mapped_column(sa.String(), default="")
+    place: orm.Mapped[str] = orm.mapped_column(sa.String(), default="")
+    responsible_contact: orm.Mapped[str] = orm.mapped_column(sa.String(), default="")
+
+    template_id: orm.Mapped[Optional[str]] = orm.mapped_column(sa.ForeignKey("slot_templates.id"))
+    template: orm.Mapped["SlotTemplate"] = orm.relationship(back_populates="slots")
+
+
+class SlotTemplate(Base):
+    __tablename__ = "slot_templates"
+
+    id = uid_column()
+    project_id = orm.mapped_column(sa.ForeignKey("projects.id", ondelete="CASCADE"))
+    created_at = orm.mapped_column(sa.DateTime(timezone=True), server_default=sa.func.now())
+    updated_at = orm.mapped_column(
+        sa.DateTime(timezone=True), default=datetime.now, onupdate=sa.func.now()
+    )
+
+    title: orm.Mapped[str] = orm.mapped_column(sa.String(), default="")
+
+    description: orm.Mapped[str] = orm.mapped_column(sa.String(), default="")
+    place: orm.Mapped[str] = orm.mapped_column(sa.String(), default="")
+    responsible_contact: orm.Mapped[str] = orm.mapped_column(sa.String(), default="")
+
+    slots: orm.Mapped[List[Slot]] = orm.relationship(back_populates="template")
+
+
+def upgrade():
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.create_table(
+        "slot_tags",
+        sa.Column("id", sa.UUID(as_uuid=False), nullable=False),
+        sa.Column("project_id", sa.UUID(as_uuid=False), nullable=False),
+        sa.Column(
+            "created_at",
+            sa.DateTime(timezone=True),
+            server_default=sa.text("now()"),
+            nullable=False,
+        ),
+        sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
+        sa.Column("title", sa.String(), nullable=False),
+        sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_table(
+        "slot_templates",
+        sa.Column("id", sa.UUID(as_uuid=False), nullable=False),
+        sa.Column("project_id", sa.UUID(as_uuid=False), nullable=False),
+        sa.Column(
+            "created_at",
+            sa.DateTime(timezone=True),
+            server_default=sa.text("now()"),
+            nullable=False,
+        ),
+        sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
+        sa.Column("title", sa.String(), nullable=False),
+        sa.Column("description", sa.String(), nullable=False),
+        sa.Column("place", sa.String(), nullable=False),
+        sa.Column("responsible_contact", sa.String(), nullable=False),
+        sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
+        sa.PrimaryKeyConstraint("id"),
+    )
+    op.create_table(
+        "association_description_tag",
+        sa.Column("description_id", sa.UUID(as_uuid=False), nullable=False),
+        sa.Column("tag_id", sa.UUID(as_uuid=False), nullable=False),
+        sa.ForeignKeyConstraint(["description_id"], ["slot_templates.id"], ondelete="CASCADE"),
+        sa.ForeignKeyConstraint(["tag_id"], ["slot_tags.id"], ondelete="CASCADE"),
+        sa.PrimaryKeyConstraint("description_id", "tag_id"),
+    )
+    op.add_column(
+        "slots", sa.Column("required_volunteers", sa.Integer(), nullable=False, server_default="0")
+    )
+    op.alter_column("slots", "required_volunteers", server_default=None)
+    op.add_column("slots", sa.Column("template_id", sa.UUID(as_uuid=False), nullable=True))
+    op.create_foreign_key(None, "slots", "slot_templates", ["template_id"], ["id"])
+
+    bind = op.get_bind()
+    session = orm.Session(bind=bind)
+    # Create 1 template for each slot
+    for slot in session.query(Slot).all():
+        template = SlotTemplate(
+            project_id=slot.project_id,
+            title="Migration template for " + slot.title,
+            description=slot.description,
+            place=slot.place,
+            responsible_contact=slot.responsible_contact,
+        )
+        session.add(template)
+        slot.template = template
+    session.commit()
+
+    op.drop_column("slots", "description")
+    op.drop_column("slots", "place")
+    op.drop_column("slots", "responsible_contact")
+    # ### end Alembic commands ###
+
+
+def downgrade():
+    op.add_column(
+        "slots",
+        sa.Column(
+            "responsible_contact",
+            sa.VARCHAR(),
+            server_default=sa.text("''::character varying"),
+            autoincrement=False,
+            nullable=False,
+        ),
+    )
+    op.add_column(
+        "slots",
+        sa.Column(
+            "place",
+            sa.VARCHAR(),
+            server_default=sa.text("''::character varying"),
+            autoincrement=False,
+            nullable=False,
+        ),
+    )
+    op.add_column(
+        "slots", sa.Column("description", sa.VARCHAR(), autoincrement=False, nullable=False)
+    )
+
+    # Revert data from template to slot
+    bind = op.get_bind()
+    session = orm.Session(bind=bind)
+
+    for slot in session.query(Slot).all():
+        slot.place = slot.template.place
+        slot.description = slot.template.description
+        slot.responsible_contact = slot.template.responsible_contact
+    session.commit()
+
+    op.drop_column("slots", "template_id")
+    op.drop_column("slots", "required_volunteers")
+    op.drop_table("association_description_tag")
+    op.drop_table("slot_templates")
+    op.drop_table("slot_tags")
+    # ### end Alembic commands ###

+ 6 - 4
app/api/api.py

@@ -1,11 +1,13 @@
 from fastapi import APIRouter
 
-from app.api.endpoints import auth, project, slots, users, volunteers, sms
+from app.api.endpoints import auth, project, slots, users, volunteers, sms, tags, templates
 
 api_router = APIRouter()
 api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
 api_router.include_router(users.router, prefix="/users", tags=["users"])
 api_router.include_router(project.router, tags=["project"])
-api_router.include_router(slots.router, tags=["project", "slots"])
-api_router.include_router(volunteers.router, tags=["project", "volunteer"])
-api_router.include_router(sms.router, tags=["project", "sms"])
+api_router.include_router(slots.router, tags=["slot"])
+api_router.include_router(tags.router, tags=["tag"])
+api_router.include_router(volunteers.router, tags=["volunteer"])
+api_router.include_router(sms.router, tags=["sms"])
+api_router.include_router(templates.router, tags=["template"])

+ 41 - 11
app/api/endpoints/project.py

@@ -7,7 +7,7 @@ from sqlalchemy.exc import IntegrityError
 from sqlalchemy.orm import Session
 
 from app.api import deps
-from app.models import Project, Slot, Sms, User, Volunteer
+from app.models import Project, Slot, SlotTemplate, SlotTag, SlotTemplate, Sms, User, Volunteer
 from app.schemas.requests import (
     ProjectImportGsheetRequest,
     ProjectRequest,
@@ -130,7 +130,32 @@ async def update_project_from_gsheet(
         volunteer_map[row.key] = volunteer
         session.add(volunteer)
 
-    creneau_names = df_creneau.nom.unique()
+    # Create creaneau templates
+    template_map = {}
+    tags_map = {}
+
+    for _, row in df_creneau.iterrows():
+        template = SlotTemplate(
+            project_id=project_id,
+            title=row.title,
+            description=row.description,
+            place=row.lieu,
+            responsible_contact=row.responsable,
+        )
+
+        for s_tag in row.tags.split(","):
+            if s_tag != "":
+                if s_tag in tags_map:
+                    tag = tags_map[s_tag]
+                else:
+                    tag = SlotTag(project_id=project_id, title=s_tag)
+                    session.add(tag)
+                    tags_map[s_tag]
+                template.tags.append(tag)
+
+        template_map[template.title] = template
+        session.add(template)
+
     # group planning entry per same name and timing
     date_format = "%Y/%m/%d %H:%M"
     df_planning["key"] = (
@@ -141,26 +166,28 @@ async def update_project_from_gsheet(
         + df_planning.end.dt.strftime(date_format)
     )
     df_slots = df_planning.groupby("key")
+
     # Create slots
     for key in df_slots.groups.keys():
         group = df_slots.get_group(key)
+        volunteers = group.benevole_nom.tolist()
         slot = Slot(
             project_id=project_id,
             title=group.nom.iloc[0],
             starting_time=group.start.iloc[0],
             ending_time=group.end.iloc[0],
+            required_volunteers=len(volunteers),
         )
         # Add volunteer to slots
-        for benevole_key in group.benevole_nom.tolist():
+        for benevole_key in volunteers:
             if benevole_key in volunteer_map:
                 slot.volunteers.append(volunteer_map[benevole_key])
+
         # add detail information if available
-        description_id = group.description_id.iloc[0]
-        if description_id in creneau_names:
-            item = df_creneau[df_creneau.nom == description_id].iloc[0]
-            slot.description = item.description
-            slot.place = item.lieu
-            slot.responsible_contact = item.responsable
+        template_id = group.template_id.iloc[0]
+        if template_id in template_map:
+            slot.template = template_map[template_id]
+
         session.add(slot)
     session.commit()
     session.refresh(p)
@@ -197,11 +224,14 @@ async def create_sms_batch(
         # Replace the slot placeholder by their value
         slot_content = (
             sms_batch.template.replace("{titre}", slot.title)
-            .replace("{description}", slot.description)
             .replace("{debut}", slot.starting_time.strftime("%Hh%M"))
             .replace("{fin}", slot.ending_time.strftime("%Hh%M"))
-            .replace("{respo}", slot.responsible_contact)
         )
+        if slot.template is not None:
+            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
         if sending_time < now:

+ 12 - 11
app/api/endpoints/slots.py

@@ -9,6 +9,7 @@ from app.api.utils import update_object_from_payload, verify_id_list
 from app.models import (
     Project,
     Slot,
+    SlotTemplate,
     User,
     Volunteer,
     association_table_volunteer_slot,
@@ -54,9 +55,7 @@ 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)
@@ -86,12 +85,15 @@ async def update_slot(
     if (slot is None) or (slot.project_id != str(project_id)):
         raise HTTPException(status_code=404, detail="Slot not found")
 
-    input_dict = new_slot.dict()
-    if input_dict["volunteers"] is not None:
-        volunteers: list[UUID] = input_dict["volunteers"]
+    if new_slot.template_id is not None:
         await verify_id_list(
-            session, volunteers, project_id, Volunteer, "Invalid volunteer list"
+            session, [new_slot.template_id], project_id, SlotTemplate, "Invalid template id"
         )
+
+    input_dict = new_slot.dict(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")
         session.execute(
             association_table_volunteer_slot.delete().where(
                 association_table_volunteer_slot.c.slot_id == slot.id
@@ -103,7 +105,8 @@ async def update_slot(
                     [(volunteer_id, slot.id) for volunteer_id in volunteers]
                 )
             )
-    del input_dict["volunteers"]
+
+        del input_dict["volunteers"]
 
     update_object_from_payload(slot, input_dict)
     session.commit()
@@ -120,7 +123,5 @@ 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()

+ 1 - 1
app/api/endpoints/sms.py

@@ -62,7 +62,7 @@ async def update_sms(
     sms = session.get(Sms, sms_id)
     if (sms is None) or (sms.project_id != str(project_id)):
         raise HTTPException(status_code=404, detail="Sms not found")
-    update_object_from_payload(sms, new_sms.dict())
+    update_object_from_payload(sms, new_sms.dict(exclude_unset=True))
     session.commit()
     session.refresh(sms)
     return sms

+ 130 - 0
app/api/endpoints/tags.py

@@ -0,0 +1,130 @@
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import delete
+from sqlalchemy.orm import Session
+
+from app.api import deps
+from app.api.utils import verify_id_list
+from app.models import (
+    Project,
+    SlotTag,
+    SlotTemplate,
+    User,
+    association_table_template_tags,
+)
+from app.schemas.requests import TagCreateRequest, TagUpdateRequest
+from app.schemas.responses import SlotResponse, TagResponse
+
+router = APIRouter()
+
+
+@router.get("/project/{project_id}/tags", response_model=list[TagResponse])
+async def list_project_tags(
+    project_id: UUID,
+    current_user: User = Depends(deps.get_current_user),
+    session: Session = Depends(deps.get_session),
+):
+    """List slots from project"""
+    p = session.get(Project, project_id)
+    if p is None:
+        raise HTTPException(status_code=404, detail="Project not found")
+    return p.tags
+
+
+@router.post("/project/{project_id}/tag", response_model=TagResponse)
+async def create_tag(
+    project_id: UUID,
+    payload: TagCreateRequest,
+    current_user: User = Depends(deps.get_current_user),
+    session: Session = Depends(deps.get_session),
+):
+    """Create a new tag to the project"""
+    p = session.get(Project, project_id)
+    if p is None:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    tag = SlotTag(project_id=p.id, title=payload.title)
+    session.add(tag)
+    session.commit()
+
+    if payload.templates is not None and len(payload.templates) > 0:
+        await verify_id_list(
+            session, payload.templates, project_id, SlotTemplate, "Invalid template list"
+        )
+        session.execute(
+            association_table_template_tags.insert().values(
+                [(t_id, tag.id) for t_id in payload.templates]
+            )
+        )
+    session.commit()
+    return tag
+
+
+@router.post("/project/{project_id}/tag/{tag_id}", response_model=TagResponse)
+async def update_tag(
+    project_id: UUID,
+    tag_id: UUID,
+    payload: TagUpdateRequest,
+    current_user: User = Depends(deps.get_current_user),
+    session: Session = Depends(deps.get_session),
+):
+    """Update a tag"""
+    tag = session.get(SlotTag, tag_id)
+    if (tag is None) or (tag.project_id != str(project_id)):
+        raise HTTPException(status_code=404, detail="Tag not found")
+
+    if payload.title is not None:
+        tag.title = payload.title
+
+    if payload.templates is not None:
+        # remove previous keys in association table
+        session.execute(
+            association_table_template_tags.delete().where(
+                association_table_template_tags.c.tag_id == tag.id
+            )
+        )
+        if len(payload.templates) > 0:
+            await verify_id_list(
+                session, payload.templates, project_id, SlotTemplate, "Invalid template list"
+            )
+            session.execute(
+                association_table_template_tags.insert().values(
+                    [(t_id, tag.id) for t_id in payload.templates]
+                )
+            )
+    session.commit()
+    session.refresh(tag)
+    return tag
+
+
+@router.get("/project/{project_id}/tag/{tag_id}/slots", response_model=list[SlotResponse])
+async def list_tagged_slot(
+    project_id: UUID,
+    tag_id: UUID,
+    current_user: User = Depends(deps.get_current_user),
+    session: Session = Depends(deps.get_session),
+):
+    """list all slot associated with a given tag"""
+    tag = session.get(SlotTag, tag_id)
+    if (tag is None) or (tag.project_id != str(project_id)):
+        raise HTTPException(status_code=404, detail="Slot not found")
+    slot_set = set()
+    for t in tag.templates:
+        for s in t.slots:
+            slot_set.add(s)
+    return list(slot_set)
+
+
+@router.delete("/project/{project_id}/tag/{tag_id}")
+async def delete_tag(
+    project_id: UUID,
+    tag_id: UUID,
+    current_user: User = Depends(deps.get_current_user),
+    session: Session = Depends(deps.get_session),
+):
+    """Delete a slot from the project"""
+    session.execute(
+        delete(SlotTag).where((SlotTag.id == tag_id) & (SlotTag.project_id == project_id))
+    )
+    session.commit()

+ 111 - 0
app/api/endpoints/templates.py

@@ -0,0 +1,111 @@
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import delete
+from sqlalchemy.orm import Session
+
+from app.api import deps
+from app.api.utils import update_object_from_payload, verify_id_list
+from app.models import (
+    Project,
+    SlotTag,
+    SlotTemplate,
+    User,
+    association_table_template_tags,
+)
+from app.schemas.requests import TemplateCreateRequest, TemplateUpdateRequest
+from app.schemas.responses import TemplateResponse
+
+router = APIRouter()
+
+
+@router.get("/project/{project_id}/templates", response_model=list[TemplateResponse])
+async def list_project_templates(
+    project_id: UUID,
+    current_user: User = Depends(deps.get_current_user),
+    session: Session = Depends(deps.get_session),
+):
+    """List slots from project"""
+    p = session.get(Project, project_id)
+    if p is None:
+        raise HTTPException(status_code=404, detail="Project not found")
+    return p.templates
+
+
+@router.post("/project/{project_id}/template", response_model=TemplateResponse)
+async def create_template(
+    project_id: UUID,
+    payload: TemplateCreateRequest,
+    current_user: User = Depends(deps.get_current_user),
+    session: Session = Depends(deps.get_session),
+):
+    """Create a new template to the project"""
+    p = session.get(Project, project_id)
+    if p is None:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    template = SlotTemplate(project_id=p.id, title=payload.title)
+    update_object_from_payload(template, payload.dict(exclude_unset=True))
+    session.add(template)
+    session.commit()
+    return template
+
+
+@router.post("/project/{project_id}/template/{template_id}", response_model=TemplateResponse)
+async def update_template(
+    project_id: UUID,
+    template_id: UUID,
+    payload: TemplateUpdateRequest,
+    current_user: User = Depends(deps.get_current_user),
+    session: Session = Depends(deps.get_session),
+):
+    """Update a template"""
+    template = session.get(SlotTemplate, template_id)
+    if (template is None) or (template.project_id != str(project_id)):
+        raise HTTPException(status_code=404, detail="Template not found")
+
+    if payload.title is not None:
+        template.title = payload.title
+    if payload.description is not None:
+        template.description = payload.description
+    if payload.responsible_contact is not None:
+        template.responsible_contact = payload.responsible_contact
+    if payload.place is not None:
+        template.place = payload.place
+
+    if payload.tags is not None:
+        # Remove previous keys in association table
+        session.execute(
+            association_table_template_tags.delete().where(
+                association_table_template_tags.c.description_id == template.id
+            )
+        )
+        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]
+                )
+            )
+
+    session.commit()
+    session.refresh(template)
+    return template
+
+
+@router.delete("/project/{project_id}/template/{template_id}")
+async def delete_template(
+    project_id: UUID,
+    template_id: UUID,
+    current_user: User = Depends(deps.get_current_user),
+    session: Session = Depends(deps.get_session),
+):
+    """Delete a slot from the project"""
+    session.execute(
+        delete(SlotTemplate).where(
+            (SlotTemplate.id == template_id) & (SlotTemplate.project_id == project_id)
+        )
+    )
+    session.commit()

+ 5 - 9
app/api/endpoints/volunteers.py

@@ -25,9 +25,7 @@ async def list_project_volunteers(
     if p is None:
         raise HTTPException(status_code=404, detail="Project not found")
 
-    results = session.execute(
-        select(Volunteer).where(Volunteer.project_id == project_id)
-    )
+    results = session.execute(select(Volunteer).where(Volunteer.project_id == project_id))
     return results.scalars().all()
 
 
@@ -66,9 +64,7 @@ async def create_volunteer(
     return volunteer
 
 
-@router.post(
-    "/project/{project_id}/volunteer/{volunteer_id}", response_model=VolunteerResponse
-)
+@router.post("/project/{project_id}/volunteer/{volunteer_id}", response_model=VolunteerResponse)
 async def update_volunteer(
     project_id: UUID,
     volunteer_id: UUID,
@@ -81,9 +77,9 @@ async def update_volunteer(
     if (volunteer is None) or (volunteer.project_id != str(project_id)):
         raise HTTPException(status_code=404, detail="Volunteer not found")
 
-    input_dict = new_volunteer.dict()
+    input_dict = new_volunteer.dict(exclude_unset=True)
     # Extract slots list from input dict
-    if input_dict["slots"] is not None:
+    if "slots" in input_dict:
         slots: list[UUID] = input_dict["slots"]
         await verify_id_list(session, slots, project_id, Slot, "Invalid slot list")
         # Remove previous values
@@ -99,7 +95,7 @@ async def update_volunteer(
                     [(volunteer.id, slot_id) for slot_id in slots]
                 )
             )
-    del input_dict["slots"]
+        del input_dict["slots"]
 
     update_object_from_payload(volunteer, input_dict)
     session.commit()

+ 2 - 3
app/api/utils.py

@@ -36,6 +36,5 @@ async def verify_id_list(
 
 def update_object_from_payload(obj, payload: dict):
     """Update the ORM model object from a pydantic payload dictionary"""
-    for var, value in payload.items():
-        if value is not None:
-            setattr(obj, var, value)
+    for attr_name, value in payload.items():
+        setattr(obj, attr_name, value)

+ 3 - 3
app/gsheet.py

@@ -99,7 +99,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 = ["nom", "lieu", "description", "responsable"]
+    df_creneau.columns = ["title", "lieu", "description", "responsable", "tags"]
     return df_creneau
 
 
@@ -162,7 +162,7 @@ def getPlanningDataFrame(csv_filename, starting_date, skip_column=3):
             if (current_benevole_name != "") and (row[j] == "" or row[j] != current_benevole_name):
                 new_creneau = {
                     "id": uuid4(),
-                    "description_id": row[0],
+                    "template_id": row[0],
                     "nom": row[1],
                     "benevole_nom": current_benevole_name,
                     "ligne": i + 1,
@@ -180,7 +180,7 @@ def getPlanningDataFrame(csv_filename, starting_date, skip_column=3):
         if current_benevole_name != "":
             new_creneau = {
                 "id": uuid4(),
-                "description_id": row[0],
+                "template_id": row[0],
                 "nom": row[1],
                 "benevole_nom": current_benevole_name,
                 "ligne": i + 1,

+ 92 - 41
app/models.py

@@ -13,10 +13,11 @@ alembic revision --autogenerate -m "migration_name"
 # apply all migrations
 alembic upgrade head
 """
+from typing import Optional
 import uuid
 from datetime import datetime
 
-from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Table
+from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Table
 from sqlalchemy.dialects.postgresql import UUID
 from sqlalchemy.ext.hybrid import hybrid_property
 from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -28,34 +29,26 @@ class Base(DeclarativeBase):
 
 
 def uid_column() -> Mapped[str]:
-    """Returns a posrtgreSQL UUID column for SQL ORM"""
-    return mapped_column(
-        UUID(as_uuid=False), primary_key=True, default=lambda _: str(uuid.uuid4())
-    )
+    """Returns a postgreSQL UUID column for SQL ORM"""
+    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"
@@ -66,6 +59,12 @@ class Project(Base):
     sms: Mapped[list["Sms"]] = relationship(
         back_populates="project", cascade="delete, delete-orphan"
     )
+    templates: Mapped[list["SlotTemplate"]] = relationship(
+        back_populates="project", cascade="delete, delete-orphan"
+    )
+    tags: Mapped[list["SlotTag"]] = relationship(
+        back_populates="project", cascade="delete, delete-orphan"
+    )
 
 
 association_table_volunteer_slot = Table(
@@ -83,13 +82,9 @@ 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()
     )
@@ -111,56 +106,112 @@ 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()
     )
     title: Mapped[str] = mapped_column(String(128), nullable=False)
-    description: Mapped[str] = mapped_column(String(), default="")
-    place: Mapped[str] = mapped_column(String(), default="")
-    responsible_contact: Mapped[str] = mapped_column(String(), default="")
 
     starting_time: Mapped[datetime] = mapped_column(DateTime(timezone=True))
     ending_time: Mapped[datetime] = mapped_column(DateTime(timezone=True))
+
+    required_volunteers: Mapped[int] = mapped_column(Integer, default=0)
+
     volunteers: Mapped[list[Volunteer]] = relationship(
         secondary=association_table_volunteer_slot, back_populates="slots"
     )
 
+    template_id: Mapped[Optional[str]] = mapped_column(ForeignKey("slot_templates.id"))
+    template: Mapped["SlotTemplate"] = relationship(back_populates="slots")
+
     @hybrid_property
     def volunteers_id(self) -> list[str]:
         return [v.id for v in self.volunteers]
 
 
+association_table_template_tags = Table(
+    "association_description_tag",
+    Base.metadata,
+    Column(
+        "description_id",
+        ForeignKey("slot_templates.id", ondelete="CASCADE"),
+        primary_key=True,
+    ),
+    Column("tag_id", ForeignKey("slot_tags.id", ondelete="CASCADE"), primary_key=True),
+)
+
+
+class SlotTag(Base):
+    __tablename__ = "slot_tags"
+
+    id: Mapped[str] = uid_column()
+    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())
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True), default=datetime.now, onupdate=func.now()
+    )
+    title: Mapped[str] = mapped_column(String(), default="")
+
+    templates: Mapped[list["SlotTemplate"]] = relationship(
+        secondary=association_table_template_tags, back_populates="tags"
+    )
+
+    @hybrid_property
+    def templates_id(self) -> list[str]:
+        return [s.id for s in self.templates]
+
+
+class SlotTemplate(Base):
+    __tablename__ = "slot_templates"
+
+    id: Mapped[str] = uid_column()
+    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())
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True), default=datetime.now, onupdate=func.now()
+    )
+    title: Mapped[str] = mapped_column(String(), default="")
+    description: Mapped[str] = mapped_column(String(), default="")
+    place: Mapped[str] = mapped_column(String(), default="")
+    responsible_contact: Mapped[str] = mapped_column(String(), default="")
+
+    slots: Mapped[list[Slot]] = relationship(back_populates="template")
+
+    tags: Mapped[list[SlotTag]] = relationship(
+        secondary=association_table_template_tags, back_populates="templates"
+    )
+
+    @hybrid_property
+    def slots_id(self) -> list[str]:
+        return [s.id for s in self.slots]
+
+    @hybrid_property
+    def tags_id(self) -> list[str]:
+        return [s.id for s in self.tags]
+
+
 class Sms(Base):
     __tablename__ = "sms"
 
     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)

+ 28 - 6
app/schemas/requests.py

@@ -90,19 +90,41 @@ class VolunteerUpdateRequest(BaseRequest):
 
 class SlotCreateRequest(BaseRequest):
     title: str
-    description: Optional[str] = None
-    place: Optional[str] = None
-    responsible_contact: Optional[str] = None
     starting_time: datetime.datetime
     ending_time: datetime.datetime
     volunteers: Optional[list[UUID4]] = None
+    template_id: Optional[UUID4] = None
 
 
 class SlotUpdateRequest(BaseRequest):
     title: Optional[str] = None
-    description: Optional[str] = None
-    place: Optional[str] = None
-    responsible_contact: Optional[str] = None
     starting_time: Optional[datetime.datetime] = None
     ending_time: Optional[datetime.datetime] = None
     volunteers: Optional[list[UUID4]] = None
+    template_id: Optional[UUID4] = None
+
+
+class TemplateCreateRequest(BaseRequest):
+    title: str
+    description: Optional[str] = None
+    place: Optional[str] = None
+    responsible_contact: Optional[str] = None
+
+
+class TemplateUpdateRequest(BaseRequest):
+    title: Optional[str]
+    description: Optional[str] = None
+    place: Optional[str] = None
+    responsible_contact: Optional[str] = None
+    # slots: Optional[list[UUID4]] = None
+    tags: Optional[list[UUID4]] = None
+
+
+class TagCreateRequest(BaseRequest):
+    title: str
+    templates: Optional[list[UUID4]] = None
+
+
+class TagUpdateRequest(BaseRequest):
+    title: Optional[str] = None
+    templates: Optional[list[UUID4]] = None

+ 25 - 19
app/schemas/responses.py

@@ -9,6 +9,12 @@ class BaseResponse(BaseModel):
         orm_mode = True
 
 
+class BaseObjectResponse(BaseResponse):
+    id: str
+    created_at: datetime
+    updated_at: datetime
+
+
 class AccessTokenResponse(BaseResponse):
     token_type: str
     access_token: str
@@ -24,10 +30,7 @@ class UserResponse(BaseResponse):
     email: EmailStr
 
 
-class VolunteerResponse(BaseResponse):
-    id: str
-    created_at: datetime
-    updated_at: datetime
+class VolunteerResponse(BaseObjectResponse):
     name: str
     surname: str
     email: str
@@ -36,23 +39,15 @@ class VolunteerResponse(BaseResponse):
     slots_id: list[str] = []
 
 
-class SlotResponse(BaseResponse):
-    id: str
-    created_at: datetime
-    updated_at: datetime
+class SlotResponse(BaseObjectResponse):
     title: str
-    description: str
-    place: str
-    responsible_contact: str
     starting_time: datetime
     ending_time: datetime
     volunteers_id: list[str] = []
+    template_id: Optional[str]
 
 
-class SMSResponse(BaseResponse):
-    id: str
-    created_at: datetime
-    updated_at: datetime
+class SMSResponse(BaseObjectResponse):
     volunteer_id: Optional[str]
     content: str
     phone_number: str
@@ -60,14 +55,25 @@ class SMSResponse(BaseResponse):
     send_time: Optional[datetime]
 
 
-class ProjectResponse(BaseResponse):
-    id: str
-    created_at: datetime
-    updated_at: datetime
+class TemplateResponse(BaseObjectResponse):
+    title: str
+    description: str
+    place: str
+    responsible_contact: str
+
+
+class TagResponse(BaseObjectResponse):
+    title: str
+    templates_id: list[str] = []
+
+
+class ProjectResponse(BaseObjectResponse):
     name: str
     volunteers: list[VolunteerResponse]
     sms: list[SMSResponse]
     slots: list[SlotResponse]
+    tags: list[TagResponse]
+    templates: list[TemplateResponse]
     is_public: bool
 
 

+ 10 - 3
app/tests/conftest.py

@@ -13,7 +13,7 @@ from app.core.session import engine
 from app.core.session import session as session_maker
 
 from app.main import app
-from app.models import Base, Project, Slot, Sms, User, Volunteer
+from app.models import Base, Project, Slot, SlotTag, SlotTemplate, Sms, User, Volunteer
 
 default_user_id = "b75365d9-7bf9-4f54-add5-aeab333a087b"
 default_user_email = "geralt@wiedzmin.pl"
@@ -28,6 +28,8 @@ default_project_name = "Default project"
 default_volunteer_id = "5514f4ef-75ee-40b2-ad99-420927c5c9e5"
 default_slot_id = "def04027-0048-48e2-8f47-f955fe080b31"
 default_sms_id = "b05c2b38-edb1-4e7a-9689-a0cba904ef29"
+default_tag_id = "47709be8-7edb-4fb1-9de0-8a529a91856d"
+default_template_id = "dcfcf2e9-8b1d-4bbf-b44b-aa511a7fe274"
 
 
 @pytest.fixture(scope="session")
@@ -131,7 +133,9 @@ def default_public_project(test_db_setup_sessionmaker) -> Project:
             slot.id = default_slot_id
             slot.volunteers.append(volunteer)
             db.add(slot)
-
+            tag = SlotTag(project_id=default_project_id, title="Royal")
+            tag.id = default_tag_id
+            db.add(tag)
             # Create a sms
             sms = Sms(
                 project_id=default_project_id,
@@ -140,7 +144,10 @@ def default_public_project(test_db_setup_sessionmaker) -> Project:
             )
             sms.id = default_sms_id
             db.add(sms)
-
+            tmp = SlotTemplate(project_id=default_project_id, title="basic template")
+            tmp.id = default_template_id
+            db.add(tmp)
+            db.commit()
             db.commit()
             db.refresh(sms)
             db.refresh(slot)

+ 7 - 47
app/tests/test_project.py

@@ -4,16 +4,14 @@ from sqlalchemy import select
 from sqlalchemy.orm import Session
 
 from app.main import app
-from app.models import Project, Slot, Sms, Volunteer
-from app.tests.conftest import default_project_id, default_project_name, default_sms_id
+from app.models import Project, Slot, Volunteer
+from app.tests.conftest import default_project_id, default_project_name
 
 
 async def test_read_list_project(
     client: AsyncClient, default_user_headers: dict, default_project: Project
 ):
-    response = await client.get(
-        app.url_path_for("list_project"), headers=default_user_headers
-    )
+    response = await client.get(app.url_path_for("list_project"), headers=default_user_headers)
     assert response.status_code == 200
     data = response.json()
     assert len(data) == 1
@@ -27,9 +25,7 @@ async def test_read_list_project_without_user(client: AsyncClient):
     assert response.status_code == 401
 
 
-async def test_read_list_public_project_without_user(
-    client: AsyncClient, default_project: Project
-):
+async def test_read_list_public_project_without_user(client: AsyncClient, default_project: Project):
     response = await client.get(app.url_path_for("list_public_project"))
     assert response.status_code == 200
     data = response.json()
@@ -65,9 +61,7 @@ async def test_get_project(
     client: AsyncClient, default_public_project: Project, default_user_headers: dict
 ):
     # Test without autentication
-    response = await client.get(
-        app.url_path_for("get_project", project_id=default_project_id)
-    )
+    response = await client.get(app.url_path_for("get_project", project_id=default_project_id))
     assert response.status_code == 401
 
     response = await client.get(
@@ -91,9 +85,7 @@ async def test_create_project_without_user(client: AsyncClient, session: Session
     assert project is None
 
 
-async def test_create_project(
-    client: AsyncClient, default_user_headers: dict, session: Session
-):
+async def test_create_project(client: AsyncClient, default_user_headers: dict, session: Session):
     # Create a private project
     response = await client.post(
         app.url_path_for("create_project"),
@@ -193,36 +185,6 @@ async def test_update_project_fail(
     assert response.status_code == 422
 
 
-async def test_create_sms_batch(
-    client: AsyncClient,
-    default_user_headers: dict,
-    session: Session,
-    default_public_project: Project,
-):
-    #
-    response = await client.post(
-        app.url_path_for("create_sms_batch", project_id=default_project_id),
-        json={"is_public": False},
-    )
-    assert response.status_code == 401
-
-    # créer un sms simple
-    response = await client.post(
-        app.url_path_for("create_sms_batch", project_id=default_project_id),
-        headers=default_user_headers,
-        json={"template": "Bonjour {prenom}!\n{titre}"},
-    )
-    assert response.status_code == 200
-    result = session.execute(
-        select(Sms).where(
-            (Sms.project_id == default_project_id) & (Sms.id != default_sms_id)
-        )
-    )
-    sms = result.scalars().first()
-    assert sms is not None
-    assert sms.content == "Bonjour Arthur!\nêtre roi"
-
-
 async def test_delete_project(
     client: AsyncClient,
     default_user_headers: dict,
@@ -248,9 +210,7 @@ async def test_delete_project(
     project = result.scalars().first()
     assert project is None
     # check deletion is cascaded to volunteers
-    result = session.execute(
-        select(Volunteer).where(Volunteer.project_id == default_project_id)
-    )
+    result = session.execute(select(Volunteer).where(Volunteer.project_id == default_project_id))
     volunteer = result.scalars().first()
     assert volunteer is None
     # check deletion is cascaded to slots

+ 97 - 41
app/tests/test_slot.py

@@ -5,7 +5,7 @@ from sqlalchemy import select
 from sqlalchemy.orm import Session
 
 from app.main import app
-from app.models import Project, Slot, Volunteer
+from app.models import Project, Slot, SlotTemplate, Volunteer
 from app.tests.conftest import (
     default_project_id,
     default_slot_id,
@@ -51,9 +51,7 @@ async def test_create_slot(
     session: Session,
 ):
     # Test without autentication
-    response = await client.post(
-        app.url_path_for("create_slot", project_id=default_project_id)
-    )
+    response = await client.post(app.url_path_for("create_slot", project_id=default_project_id))
     assert response.status_code == 401
     starting_time = datetime(1900, 1, 1)
     payload = {
@@ -83,9 +81,9 @@ async def test_create_slot(
     assert len(slots) > 1
     slot = [s for s in slots if s.id != default_slot_id][0]
     assert slot.title == "être mort"
-    assert abs(
-        slot.starting_time - starting_time.replace(tzinfo=timezone.utc)
-    ) < timedelta(minutes=30)
+    assert abs(slot.starting_time - starting_time.replace(tzinfo=timezone.utc)) < timedelta(
+        minutes=30
+    )
 
     # test invalid payload
     del payload["title"]
@@ -116,8 +114,6 @@ async def test_update_slot(
     starting_time = datetime(2000, 1, 1, tzinfo=timezone.utc)
     payload = {
         "title": "être mort 2 fois",
-        "place": "être mort 2 fois",
-        "responsible_contact": "être mort 2 fois",
         "starting_time": starting_time.isoformat(),
         "ending_time": (starting_time + timedelta(minutes=60)).isoformat(),
     }
@@ -161,13 +157,71 @@ async def test_update_slot(
         assert response.status_code == 200
         assert response.json()["id"] == default_slot_id
         if "time" in k:
-            assert datetime.fromisoformat(response.json()[k]) == datetime.fromisoformat(
-                v
-            )
+            assert datetime.fromisoformat(response.json()[k]) == datetime.fromisoformat(v)
         else:
             assert response.json()[k] == v
 
 
+async def test_update_bad_slot_template(
+    client: AsyncClient,
+    default_public_project: Project,
+    default_user_headers: dict,
+):
+    path = app.url_path_for(
+        "update_slot",
+        project_id=default_project_id,
+        slot_id=default_slot_id,
+    )
+    response = await client.post(
+        path,
+        json={"template_id": "invalid uuid"},
+        headers=default_user_headers,
+    )
+    assert response.status_code == 422
+
+    response = await client.post(
+        path,
+        json={"template_id": str(uuid.uuid4())},
+        headers=default_user_headers,
+    )
+    assert response.status_code == 400
+
+
+async def test_update_slot_template(
+    client: AsyncClient,
+    default_public_project: Project,
+    default_user_headers: dict,
+    session: Session,
+):
+    path = app.url_path_for(
+        "update_slot",
+        project_id=default_project_id,
+        slot_id=default_slot_id,
+    )
+    template = SlotTemplate(project_id=default_project_id, title="template 0")
+    session.add(template)
+    session.commit()
+
+    response = await client.post(
+        path,
+        json={"template_id": template.id},
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+    assert response.json()["template_id"] == template.id
+
+    session.refresh(template)
+    assert len(template.slots) == 1
+
+    response = await client.post(
+        path,
+        json={"template_id": None},
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+    assert response.json()["template_id"] is None
+
+
 async def test_update_slot_volunteers(
     client: AsyncClient,
     default_public_project: Project,
@@ -185,9 +239,7 @@ async def test_update_slot_volunteers(
     )
 
     assert response.status_code == 200
-    result = session.execute(
-        select(Volunteer).where(Volunteer.id == default_volunteer_id)
-    )
+    result = session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
     volunteer = result.scalars().first()
     assert volunteer is not None
     assert volunteer.slots_id == []
@@ -234,7 +286,7 @@ async def test_update_slot_volunteers(
     assert response.status_code == 422
 
 
-async def test_delete_slot(
+async def test_delete_slot_fail(
     client: AsyncClient,
     default_user_headers: dict,
     session: Session,
@@ -253,6 +305,20 @@ async def test_delete_slot(
     slot = result.scalars().first()
     assert slot is not None
 
+    # Cannot delete non uuid string
+    response = await client.delete(
+        app.url_path_for("delete_slot", project_id=default_project_id, slot_id="not uidstr"),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 422
+
+
+async def test_delete_slot(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
     # Proper deletion
     response = await client.delete(
         app.url_path_for(
@@ -268,37 +334,27 @@ async def test_delete_slot(
     assert slot is None
 
     # check deletion is cascaded to volunteers
-    result = session.execute(
-        select(Volunteer).where(Volunteer.id == default_volunteer_id)
-    )
+    result = session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
     volunteer: Volunteer | None = result.scalars().first()
+    assert volunteer is not None
     assert default_slot_id not in volunteer.slots_id
 
-    # Idempotence test
-    response = await client.delete(
-        app.url_path_for(
-            "delete_slot",
-            project_id=default_project_id,
-            slot_id=default_slot_id,
-        ),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 200
-
     # can delete random uuid
     response = await client.delete(
-        app.url_path_for(
-            "delete_slot", project_id=default_project_id, slot_id=uuid.uuid4()
-        ),
+        app.url_path_for("delete_slot", project_id=default_project_id, slot_id=uuid.uuid4()),
         headers=default_user_headers,
     )
     assert response.status_code == 200
 
-    # Cannot delete non uuid string
-    response = await client.delete(
-        app.url_path_for(
-            "delete_slot", project_id=default_project_id, slot_id="not uidstr"
-        ),
-        headers=default_user_headers,
-    )
-    assert response.status_code == 422
+
+async def test_delete_slot_idempotent(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
+    # Idempotence test
+    url = app.url_path_for("delete_slot", project_id=default_project_id, slot_id=default_slot_id)
+    response = await client.delete(url, headers=default_user_headers)
+    response = await client.delete(url, headers=default_user_headers)
+    assert response.status_code == 200

+ 6 - 14
app/tests/test_sms.py

@@ -1,7 +1,7 @@
-from datetime import datetime, timedelta, timezone
+from datetime import datetime, timezone
 import uuid
 from httpx import AsyncClient
-from sqlalchemy import false, select
+from sqlalchemy import select
 from sqlalchemy.orm import Session
 
 from app.main import app
@@ -44,9 +44,7 @@ async def test_create_sms(
     session: Session,
 ):
     # Test without autentication
-    response = await client.post(
-        app.url_path_for("create_sms", project_id=default_project_id)
-    )
+    response = await client.post(app.url_path_for("create_sms", project_id=default_project_id))
     assert response.status_code == 401
     starting_time = datetime(1900, 1, 1)
     payload = {"phone_number": "06 75 75 75 75 ", "content": "sms_content"}
@@ -68,9 +66,7 @@ async def test_create_sms(
     assert response.status_code == 200
     assert response.json()["content"] == "sms_content"
     result = session.execute(
-        select(Sms).where(
-            (Sms.project_id == default_project_id) & (Sms.id != default_sms_id)
-        )
+        select(Sms).where((Sms.project_id == default_project_id) & (Sms.id != default_sms_id))
     )
     sms = result.scalars().first()
 
@@ -214,18 +210,14 @@ async def test_delete_sms(
 
     # can delete random uuid
     response = await client.delete(
-        app.url_path_for(
-            "delete_sms", project_id=default_project_id, sms_id=uuid.uuid4()
-        ),
+        app.url_path_for("delete_sms", project_id=default_project_id, sms_id=uuid.uuid4()),
         headers=default_user_headers,
     )
     assert response.status_code == 200
 
     # Cannot delete non uuid string
     response = await client.delete(
-        app.url_path_for(
-            "delete_sms", project_id=default_project_id, sms_id="not uidstr"
-        ),
+        app.url_path_for("delete_sms", project_id=default_project_id, sms_id="not uidstr"),
         headers=default_user_headers,
     )
     assert response.status_code == 422

+ 326 - 0
app/tests/test_sms_batch.py

@@ -0,0 +1,326 @@
+from datetime import datetime, timedelta
+from httpx import AsyncClient
+import pytest
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.main import app
+from app.models import Project, Slot, SlotTemplate, Sms, Volunteer
+from app.tests.conftest import default_sms_id, default_volunteer_id
+
+
+def get_arthur(session) -> Volunteer:
+    result = session.execute(select(Volunteer).where(Volunteer.id == default_volunteer_id))
+    arthur = result.scalars().first()
+    assert arthur is not None
+    return arthur
+
+
+async def test_create_sms_batch_bad_input(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
+    response = await client.post(
+        app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+        json={"is_public": False},
+    )
+    assert response.status_code == 401
+
+
+async def test_basic(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
+    slot = Slot(
+        project_id=default_public_project.id,
+        title="replanter excalibure",
+        starting_time=datetime.now() + timedelta(minutes=30),
+        ending_time=datetime.now() + timedelta(minutes=60),
+    )
+    slot.volunteers.append(get_arthur(session))
+    session.add(slot)
+    session.commit()
+    # créer un sms simple
+    response = await client.post(
+        app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+        headers=default_user_headers,
+        json={"template": "Bonjour {prenom}!\n{titre}"},
+    )
+    assert response.status_code == 200
+    result = session.execute(
+        select(Sms).where(
+            (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+        )
+    )
+    sms = result.scalars().first()
+    assert sms is not None
+    assert sms.content == "Bonjour Arthur!\nreplanter excalibure"
+
+
+async def test_multiple_volunteer(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
+    slot = Slot(
+        project_id=default_public_project.id,
+        title="replanter excalibure",
+        starting_time=datetime.now() + timedelta(minutes=30),
+        ending_time=datetime.now() + timedelta(minutes=60),
+    )
+    slot.volunteers.append(get_arthur(session))
+    slot.volunteers.append(
+        Volunteer(
+            project_id=default_public_project.id,
+            name="benevole 2",
+            surname="echo",
+            phone_number="t",
+            email="",
+            automatic_sms=True,
+        )
+    )
+
+    session.add(slot)
+    session.commit()
+    # créer un sms simple
+    response = await client.post(
+        app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+        headers=default_user_headers,
+        json={"template": "Bonjour {prenom}!\n{titre}"},
+    )
+    assert response.status_code == 200
+    result = session.execute(
+        select(Sms).where(
+            (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+        )
+    )
+    count_sms = 0
+    for _ in result.fetchall():
+        count_sms += 1
+    assert count_sms == 2
+
+
+@pytest.mark.parametrize("automatic_sms,expected", [(True, True), (False, False)])
+async def test_volunteer_settings(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+    automatic_sms: bool,
+    expected: bool,
+):
+    slot = Slot(
+        project_id=default_public_project.id,
+        title="replanter excalibure",
+        starting_time=datetime.now() + timedelta(minutes=30),
+        ending_time=datetime.now() + timedelta(minutes=60),
+    )
+    arthur = get_arthur(session)
+    arthur.automatic_sms = automatic_sms
+    slot.volunteers.append(arthur)
+    session.add(slot)
+    session.commit()
+    # créer un sms simple
+    response = await client.post(
+        app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+        headers=default_user_headers,
+        json={"template": "Bonjour {prenom}!\n{titre}"},
+    )
+    assert response.status_code == 200
+    result = session.execute(
+        select(Sms).where(
+            (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+        )
+    )
+    sms = result.scalars().first()
+    assert (sms is not None) == expected
+
+
+async def test_only_future_slot(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
+    # Create a slot starting in the past
+    # no sms should be issued
+    slot = Slot(
+        project_id=default_public_project.id,
+        title="replanter excalibure",
+        starting_time=datetime.now() + timedelta(minutes=-30),
+        ending_time=datetime.now() + timedelta(minutes=60),
+    )
+    slot.volunteers.append(get_arthur(session))
+
+    session.add(slot)
+    session.commit()
+
+    # créer un sms simple
+    response = await client.post(
+        app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+        headers=default_user_headers,
+        json={"template": "Bonjour {prenom}!\n{titre}"},
+    )
+    assert response.status_code == 200
+    result = session.execute(
+        select(Sms).where(
+            (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+        )
+    )
+    sms = result.scalars().first()
+    assert sms is None
+
+
+@pytest.mark.parametrize("delta_t,expected", [(32, False), (28, True)])
+async def test_time_delta(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+    delta_t: int,
+    expected,
+):
+    # Create a slot starting in the past
+    # no sms should be issued
+    slot = Slot(
+        project_id=default_public_project.id,
+        title="replanter excalibure",
+        starting_time=datetime.now() + timedelta(minutes=30),
+        ending_time=datetime.now() + timedelta(minutes=60),
+    )
+    slot.volunteers.append(get_arthur(session))
+
+    session.add(slot)
+    session.commit()
+
+    # créer un sms simple
+    response = await client.post(
+        app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+        headers=default_user_headers,
+        json={"template": "Bonjour {prenom}!\n{titre}", "delta_t": delta_t},
+    )
+    assert response.status_code == 200
+    result = session.execute(
+        select(Sms).where(
+            (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+        )
+    )
+    sms = result.scalars().first()
+    assert (sms is not None) == expected
+
+
+starting_time = datetime.now() + timedelta(minutes=30)
+
+
+@pytest.mark.parametrize(
+    "template,expected",
+    [
+        ("no escape", "no escape"),
+        ("{titre}", "replanter excalibure"),
+        ("{prenom}", "Arthur"),
+        ("{prenom} {nom}", "Arthur Pandragon"),
+        ("{debut}", starting_time.strftime("%Hh%M")),
+        ("{titre} {description}, {respo}", "replanter excalibure {description}, {respo}"),
+    ],
+)
+async def test_content_no_template(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+    template: str,
+    expected: str,
+):
+    # Create a slot starting in the past
+    # no sms should be issued
+    slot = Slot(
+        project_id=default_public_project.id,
+        title="replanter excalibure",
+        starting_time=starting_time,
+        ending_time=datetime.now() + timedelta(minutes=60),
+    )
+    slot.volunteers.append(get_arthur(session))
+
+    session.add(slot)
+    session.commit()
+
+    # créer un sms simple
+    response = await client.post(
+        app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+        headers=default_user_headers,
+        json={"template": template},
+    )
+    assert response.status_code == 200
+    result = session.execute(
+        select(Sms).where(
+            (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+        )
+    )
+    sms = result.scalars().first()
+    assert sms is not None
+    assert sms.content == expected
+
+
+@pytest.mark.parametrize(
+    "template,expected",
+    [
+        ("no escape", "no escape"),
+        ("{titre}", "replanter excalibure"),
+        ("{prenom}", "Arthur"),
+        ("{prenom} {nom}", "Arthur Pandragon"),
+        ("{debut}", starting_time.strftime("%Hh%M")),
+        (
+            "{titre} {description}, {respo}",
+            "replanter excalibure rendre l'épee à son caillou, dieu",
+        ),
+    ],
+)
+async def test_content_with_template(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+    template: str,
+    expected: str,
+):
+    # Create a slot starting in the past
+    # no sms should be issued
+    slot_template = SlotTemplate(
+        project_id=default_public_project.id,
+        title="replanter excalibure",
+        description="rendre l'épee à son caillou",
+        responsible_contact="dieu",
+        place="gros caillou",
+    )
+    slot = Slot(
+        project_id=default_public_project.id,
+        title="replanter excalibure",
+        starting_time=starting_time,
+        ending_time=datetime.now() + timedelta(minutes=60),
+    )
+    slot.template = slot_template
+    slot.volunteers.append(get_arthur(session))
+    session.add(slot_template)
+    session.add(slot)
+    session.commit()
+
+    # créer un sms simple
+    response = await client.post(
+        app.url_path_for("create_sms_batch", project_id=default_public_project.id),
+        headers=default_user_headers,
+        json={"template": template},
+    )
+    assert response.status_code == 200
+    result = session.execute(
+        select(Sms).where(
+            (Sms.project_id == default_public_project.id) & (Sms.id != default_sms_id)
+        )
+    )
+    sms = result.scalars().first()
+    assert sms is not None
+    assert sms.content == expected

+ 300 - 0
app/tests/test_tag.py

@@ -0,0 +1,300 @@
+from datetime import datetime, timezone
+from re import template
+import uuid
+from httpx import AsyncClient
+import pytest
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.main import app
+from app.models import Project, Slot, SlotTag, SlotTemplate
+from app.tests.conftest import default_project_id, default_tag_id
+
+
+async def test_read_list_project_tags(
+    client: AsyncClient,
+    default_user_headers: dict,
+    default_public_project: Project,
+    session: Session,
+):
+    response = await client.get(
+        app.url_path_for("list_project_tags", project_id=default_project_id),
+    )
+    assert response.status_code == 401
+    response = await client.get(
+        app.url_path_for("list_project_tags", project_id=default_project_id),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+    assert len(response.json()) == 1
+    tag = SlotTag(title="1er tag", project_id=default_project_id)
+    session.add(tag)
+    session.commit()
+    response = await client.get(
+        app.url_path_for("list_project_tags", project_id=default_project_id),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+    assert len(response.json()) == 2
+
+
+async def test_create_tag_fail(
+    client: AsyncClient,
+    default_user_headers: dict,
+    default_public_project: Project,
+    session: Session,
+):
+    response = await client.post(
+        app.url_path_for("create_tag", project_id="default_project_id"),
+        headers=default_user_headers,
+    )
+    assert response.status_code == 422
+    response = await client.post(
+        app.url_path_for("create_tag", project_id=uuid.uuid4()),
+        json={"title": "1st tag"},
+        headers=default_user_headers,
+    )
+    assert response.status_code == 404
+    response = await client.post(
+        app.url_path_for("create_tag", project_id="default_project_id"),
+    )
+    assert response.status_code == 401
+    response = await client.post(
+        app.url_path_for("create_tag", project_id=default_project_id),
+        json={"titl": "1st tag"},
+        headers=default_user_headers,
+    )
+    assert response.status_code == 422
+
+
+async def test_create_tag(
+    client: AsyncClient,
+    default_user_headers: dict,
+    default_project: Project,
+    session: Session,
+):
+    response = await client.post(
+        app.url_path_for("create_tag", project_id=default_project_id),
+        json={"title": "1st tag"},
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+    result = session.execute(select(SlotTag).where(SlotTag.project_id == default_project_id))
+    tag = result.scalars().first()
+    assert tag is not None
+    assert tag.title == "1st tag"
+
+    response = await client.post(
+        app.url_path_for("create_tag", project_id=default_project_id),
+        json={"title": "1st tag"},
+        headers=default_user_headers,
+    )
+    result = session.execute(select(SlotTag).where(SlotTag.project_id == default_project_id))
+    tag_count = 0
+    for tag in result.scalars():
+        tag_count += 1
+    assert tag_count == 2
+
+
+async def test_create_tag_with_template(
+    client: AsyncClient,
+    default_user_headers: dict,
+    default_public_project: Project,
+    session: Session,
+):
+    template = SlotTemplate(
+        project_id=default_project_id, title="coucou", description="ceci est une description"
+    )
+    session.add(template)
+    session.commit()
+
+    response = await client.post(
+        app.url_path_for("create_tag", project_id=default_project_id),
+        json={"title": "1st tag", "templates": [template.id]},
+        headers=default_user_headers,
+    )
+    assert response.status_code == 200
+    assert response.json()["templates_id"][0] == template.id
+    session.refresh(template)
+    assert len(template.tags) > 0
+
+    template = SlotTemplate(
+        project_id=default_project_id, title="coucou", description="ceci est une description"
+    )
+    session.add(template)
+    session.commit()
+
+    project = Project(name="second project", is_public=False)
+    session.add(project)
+    session.commit()
+    session.refresh(project)
+    response = await client.post(
+        app.url_path_for("create_tag", project_id=project.id),
+        json={"title": "1st tag", "templates": [template.id]},
+        headers=default_user_headers,
+    )
+    assert response.status_code == 400
+
+
+async def test_update_tag_fail(
+    client: AsyncClient,
+    default_user_headers: dict,
+    default_public_project: Project,
+    session: Session,
+):
+    # no authent
+    url = app.url_path_for("update_tag", project_id=default_project_id, tag_id=default_tag_id)
+    response = await client.post(url, json={"title": "royaux"})
+    assert response.status_code == 401
+
+
+@pytest.mark.parametrize(
+    "payload", [{"title": [1.001, 2]}, {"title": {}}, {"templates": ["1", "2"]}]
+)
+async def test_update_tag_fail_invalid_payload(
+    client: AsyncClient,
+    default_user_headers: dict,
+    default_public_project: Project,
+    session: Session,
+    payload: dict,
+):
+    url = app.url_path_for("update_tag", project_id=default_project_id, tag_id=default_tag_id)
+    response = await client.post(url, json=payload, headers=default_user_headers)
+    assert response.status_code == 422
+
+
+async def test_update_tag(
+    client: AsyncClient,
+    default_user_headers: dict,
+    default_public_project: Project,
+    session: Session,
+):
+    tag = session.get(SlotTag, default_tag_id)
+    assert tag is not None
+    url = app.url_path_for("update_tag", project_id=default_project_id, tag_id=default_tag_id)
+
+    response = await client.post(url, json={"title": "new_title"}, headers=default_user_headers)
+    assert response.status_code == 200
+    session.refresh(tag)
+    assert tag.title == "new_title"
+    assert response.json()["title"] == "new_title"
+
+    template = SlotTemplate(project_id=default_project_id, title="template")
+    session.add(template)
+    session.commit()
+    response = await client.post(
+        url, json={"templates": [template.id]}, headers=default_user_headers
+    )
+    assert response.status_code == 200
+    session.refresh(tag)
+    assert len(tag.templates) == 1
+    response = await client.post(url, json={"templates": []}, headers=default_user_headers)
+    session.refresh(tag)
+    assert len(tag.templates) == 0
+
+
+async def test_update_tag_fail_template(
+    client: AsyncClient,
+    default_user_headers: dict,
+    default_public_project: Project,
+    session: Session,
+):
+    tag = session.get(SlotTag, default_tag_id)
+    assert tag is not None
+    project = Project(name="other project", is_public=False)
+    template = SlotTemplate(title="template 1")
+    project.templates.append(template)
+    session.add(template)
+    session.add(project)
+    session.commit()
+    url = app.url_path_for("update_tag", project_id=default_project_id, tag_id=default_tag_id)
+    response = await client.post(
+        url, json={"templates": [template.id]}, headers=default_user_headers
+    )
+    assert response.status_code == 400
+
+
+@pytest.mark.parametrize("n_slot", [1, 5, 10])
+async def test_list_slot(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+    n_slot: int,
+):
+    url = app.url_path_for("list_tagged_slot", project_id=default_project_id, tag_id=default_tag_id)
+    response = await client.get(url, headers=default_user_headers)
+    assert response.status_code == 200
+    assert len(response.json()) == 0
+    tag = session.get(SlotTag, default_tag_id)
+    assert tag is not None
+    for i in range(n_slot):
+        template = SlotTemplate(project_id=default_project_id, title=f"template {i}")
+        slot = Slot(
+            project_id=default_project_id,
+            title=f"Slot {i}",
+            starting_time=datetime(2024, 9, 9, 12, 2 * i),
+            ending_time=datetime(2024, 9, 9, 12, 2 * (i + 1)),
+        )
+        slot.template = template
+        tag.templates.append(template)
+        session.add_all([template, slot])
+
+    session.commit()
+    response = await client.get(url, headers=default_user_headers)
+    assert response.status_code == 200
+    assert len(response.json()) == n_slot
+
+
+async def test_delete_tag_fail(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
+    # no authent
+    url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id=default_tag_id)
+    response = await client.delete(url)
+    assert response.status_code == 401
+    # invalid tag
+    url = app.url_path_for("delete_tag", project_id=default_project_id, tag_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_tag", project_id="default_volunteer_id", tag_id=default_tag_id)
+    response = await client.delete(url, headers=default_user_headers)
+    assert response.status_code == 422
+
+
+async def test_delete_tag(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
+    # Proper deletion
+    url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id=default_tag_id)
+    response = await client.delete(url, headers=default_user_headers)
+    assert response.status_code == 200
+    result = session.execute(select(SlotTag).where(SlotTag.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_project_id, tag_id=uuid.uuid4())
+    response = await client.delete(url, headers=default_user_headers)
+    assert response.status_code == 200
+
+
+async def test_delete_tag_idempotent(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
+    # Idempotence test
+    url = app.url_path_for("delete_tag", project_id=default_project_id, tag_id=default_tag_id)
+    response = await client.delete(url, headers=default_user_headers)
+    response = await client.delete(url, headers=default_user_headers)
+    assert response.status_code == 200

+ 212 - 0
app/tests/test_template.py

@@ -0,0 +1,212 @@
+from uuid import uuid4
+from httpx import AsyncClient
+import pytest
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app.main import app
+from app.models import Project, SlotTemplate
+from app.tests.conftest import default_slot_id, default_template_id, default_tag_id
+
+
+async def test_create_template_fail(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
+    url = app.url_path_for("create_template", project_id=default_public_project.id)
+    response = await client.post(url, json={"title": "1st template"})
+    assert response.status_code == 401
+    response = await client.post(url, json={}, headers=default_user_headers)
+    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)
+    assert response.status_code == 404
+
+
+@pytest.mark.parametrize(
+    "payload",
+    [
+        {"title": "1st template"},
+        {"title": "1𝖘ҭ ṥ٥ι𝙪𝓉ìóη", "place": "echo"},
+        {"title": "1st template", "responsible_contact": "echo"},
+        {"title": "1st template", "description": "&é'(-è_çecho"},
+        {"title": "1st template", "place": "3", "description": "&é'(-è_çecho"},
+    ],
+)
+async def test_create_template(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+    payload: dict,
+):
+    url = app.url_path_for("create_template", project_id=default_public_project.id)
+    response = await client.post(url, json=payload, headers=default_user_headers)
+    assert response.status_code == 200
+    template = session.get(SlotTemplate, response.json()["id"])
+    assert template is not None
+    for k in ["title", "description", "place", "responsible_contact"]:
+        val = template.__getattribute__(k)
+        if k in payload:
+            assert val == payload[k]
+        else:
+            assert val == ""
+
+
+async def test_update_template_fail(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
+    payload = {"title": "1st template"}
+    project = session.get(Project, default_public_project.id)
+    assert project is not None
+    template_id = project.templates[0].id
+    url = app.url_path_for(
+        "update_template", project_id=default_public_project.id, template_id="12"
+    )
+    response = await client.post(url, json=payload)
+    assert response.status_code == 401
+    response = await client.post(url, json=payload, headers=default_user_headers)
+    assert response.status_code == 422
+    url = app.url_path_for(
+        "update_template", project_id=default_public_project.id, template_id=uuid4()
+    )
+    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)
+    response = await client.post(url, json=payload, headers=default_user_headers)
+    assert response.status_code == 404
+
+
+@pytest.mark.parametrize(
+    "code,payload",
+    [
+        (422, {"title": [1.001, 2]}),
+        (422, {"responsible_contact": {"t": None}}),
+        (422, {"place": [1, 2]}),
+        (422, {"tags": ["1", "2"]}),
+        # (422, {"slots": "1"}),
+        (400, {"tags": [default_slot_id]}),
+    ],
+)
+async def test_update_template_fail_payload_validation(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+    payload: dict,
+    code: int,
+):
+    url = app.url_path_for(
+        "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
+
+
+@pytest.mark.parametrize(
+    "payload",
+    [
+        {"title": "1st template"},
+        {"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": "3", "description": "&é'(-è_çecho"},
+    ],
+)
+async def test_update_template(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+    payload: dict,
+):
+    url = app.url_path_for(
+        "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
+    template = session.get(SlotTemplate, default_template_id)
+    assert template is not None
+    for k in ["title", "description", "place", "responsible_contact"]:
+        val = template.__getattribute__(k)
+        if k in payload:
+            assert val == payload[k]
+        else:
+            assert val == ""
+
+
+async def test_update_template_lists(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
+    url = app.url_path_for(
+        "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)
+    assert response.status_code == 200
+    template = session.get(SlotTemplate, default_template_id)
+    assert template is not None
+    assert len(template.tags) == 1
+    assert template.tags[0].id == default_tag_id
+    response = await client.post(url, json={"tags": []}, headers=default_user_headers)
+    assert response.status_code == 200
+    session.refresh(template)
+    assert len(template.tags) == 0
+
+
+async def test_delete_template_fail(
+    client: AsyncClient,
+    default_user_headers: dict,
+    session: Session,
+    default_public_project: Project,
+):
+    # Proper deletion
+    project_id = default_public_project.id
+    # no authent
+    url = app.url_path_for(
+        "delete_template", project_id=project_id, template_id=default_template_id
+    )
+    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")
+    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)
+    response = await client.delete(url, headers=default_user_headers)
+    assert response.status_code == 422
+
+
+async def test_delete_template(
+    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
+    )
+    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))
+    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())
+    response = await client.delete(url, headers=default_user_headers)
+    assert response.status_code == 200