| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282 |
- from datetime import UTC, datetime, timedelta
- from uuid import UUID
- from fastapi import APIRouter, Depends, HTTPException
- from sqlalchemy import delete, select
- from sqlalchemy.exc import IntegrityError
- from sqlalchemy.orm import Session
- from app.api import deps
- from app.api.utils import assert_project_exists, get_project_or_404
- from app.importData.gsheet import ParsingError, extract_doc_uid, parseGsheet
- from app.models import (
- GlobalRole,
- OrgRole,
- Project,
- Slot,
- SlotTag,
- SlotTemplate,
- Sms,
- User,
- Volunteer,
- )
- from app.schemas.requests import (
- ProjectCreateRequest,
- ProjectImportGsheetRequest,
- ProjectSMSBatchRequest,
- ProjectUpdateRequest,
- )
- from app.schemas.responses import ProjectListResponse, ProjectResponse, SMSResponse
- router = APIRouter()
- @router.get("/projects", response_model=list[ProjectListResponse])
- async def list_project(
- current_user: User = Depends(deps.get_current_user),
- session: Session = Depends(deps.get_session),
- ):
- """Get project list, scoped to the caller's organizations (all projects for super_admin)."""
- if current_user.global_role == GlobalRole.SUPER_ADMIN:
- results = session.execute(select(Project))
- else:
- org_ids = [m.organization_id for m in current_user.organizations]
- if not org_ids:
- return []
- results = session.execute(select(Project).where(Project.organization_id.in_(org_ids)))
- return results.scalars().all()
- @router.get("/public-projects", response_model=list[ProjectListResponse])
- async def list_public_project(
- session: Session = Depends(deps.get_session),
- ):
- """Get the list of public projects — unchanged, stays fully public."""
- results = session.execute(select(Project).where(Project.is_public))
- return results.scalars().all()
- @router.post("/project", response_model=ProjectResponse)
- async def create_project(
- new_project: ProjectCreateRequest,
- current_user: User = Depends(deps.get_current_user),
- session: Session = Depends(deps.get_session),
- ):
- """Create a new project within one of the caller's organizations."""
- org_id = str(new_project.organization_id)
- if current_user.global_role != GlobalRole.SUPER_ADMIN:
- role = next(
- (m.role for m in current_user.organizations if m.organization_id == org_id),
- None,
- )
- if role != OrgRole.ORG_ADMIN:
- raise HTTPException(403, "Only an org_admin can create projects for this organization")
- project = Project(**new_project.model_dump())
- session.add(project)
- try:
- session.commit()
- except IntegrityError:
- raise HTTPException(400, "Project name already exist")
- session.refresh(project)
- return project
- @router.get("/public-project/{project_id}", response_model=ProjectResponse)
- async def get_public_project(
- project_id: UUID,
- session: Session = Depends(deps.get_session),
- ):
- """Get a project that is public — unchanged, stays fully public."""
- result = session.get(Project, project_id)
- if (result is None) or not result.is_public:
- raise HTTPException(status_code=404, detail="Project not found")
- return result
- @router.get("/project/{project_id}", response_model=ProjectResponse)
- async def get_project(
- project_id: UUID,
- current_user: User = Depends(
- deps.require_org_role(OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
- ),
- session: Session = Depends(deps.get_session),
- ):
- """Get a project"""
- project = session.get(Project, project_id)
- if project is None:
- raise HTTPException(status_code=404, detail="Project not found")
- return project
- @router.post("/project/{project_id}", response_model=ProjectListResponse)
- async def update_project(
- project_id: UUID,
- edit_project: ProjectUpdateRequest,
- current_user: User = Depends(deps.require_org_role(OrgRole.ORG_ADMIN)),
- session: Session = Depends(deps.get_session),
- ):
- """Edit project"""
- p = get_project_or_404(session, project_id)
- p.name = edit_project.name
- p.is_public = edit_project.is_public
- session.commit()
- return p
- @router.post("/project/{project_id}/import-gsheet", response_model=ProjectResponse)
- async def update_project_from_gsheet(
- project_id: UUID,
- gsheet: ProjectImportGsheetRequest,
- current_user: User = Depends(deps.require_org_role(OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)),
- session: Session = Depends(deps.get_session),
- ):
- """Edit project name"""
- p: Project = session.get(Project, project_id)
- if p is None:
- raise HTTPException(status_code=404, detail="Project not found")
- doc_id = extract_doc_uid(gsheet.sheet_url)
- if gsheet.erase_data:
- p.slots = []
- p.sms = []
- p.volunteers = []
- p.tags = []
- p.templates = []
- try:
- data = parseGsheet(doc_id, gsheet.satursday_date)
- except ParsingError as exc:
- raise HTTPException(status_code=422, detail=str(exc))
- volunteer_map: dict[str, Volunteer] = {}
- for _, row in data.contact.iterrows():
- volunteer = Volunteer(
- project_id=project_id,
- name=row["Prénom"],
- surname=row["Nom"],
- email=row["Mail"],
- phone_number=row["Tél"],
- automatic_sms=row["SMS"],
- )
- volunteer_map[row.key] = volunteer
- session.add(volunteer)
- template_map = {}
- tags_map = {tag.title: tag for tag in p.tags}
- for _, row in data.creneauData.iterrows():
- # NOTE: responsible_contact stays free-text on gsheet import for now —
- # linking to a real Commission requires matching against commission
- # names/members, which the sheet doesn't carry. Revisit once the
- # gsheet format itself is updated to reference commissions.
- 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] = tag
- template.tags.append(tag)
- template_map[template.title] = template
- session.add(template)
- df_planning = data.planning
- date_format = "%Y/%m/%d %H:%M"
- df_planning["key"] = (
- df_planning.nom.str.strip()
- + "_"
- + df_planning.start.dt.strftime(date_format)
- + "-"
- + df_planning.end.dt.strftime(date_format)
- )
- df_slots = df_planning.groupby("key")
- 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),
- )
- for benevole_key in volunteers:
- if benevole_key in volunteer_map:
- slot.volunteers.append(volunteer_map[benevole_key])
- 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)
- return p
- @router.post("/project/{project_id}/create-all-sms", response_model=list[SMSResponse])
- async def create_sms_batch(
- project_id: UUID,
- sms_batch: ProjectSMSBatchRequest,
- current_user: User = Depends(deps.require_org_role(OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)),
- session: Session = Depends(deps.get_session),
- ):
- """(docstring unchanged)"""
- assert_project_exists(session, project_id)
- slots = session.execute(select(Slot).where(Slot.project_id == project_id))
- sms_list = []
- now = datetime.now(UTC)
- for slot in slots.scalars():
- slot_content = (
- sms_batch.template.replace("{titre}", slot.title)
- .replace("{debut}", slot.starting_time.strftime("%Hh%M"))
- .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.effective_responsible_contact
- )
- sending_time = slot.starting_time - timedelta(minutes=sms_batch.delta_t)
- if sending_time < now:
- continue
- for volunteer in slot.volunteers:
- if not volunteer.automatic_sms:
- continue
- personalized_content = slot_content.replace("{prenom}", volunteer.name).replace(
- "{nom}", volunteer.surname
- )
- sms = Sms(
- project_id=project_id,
- volunteer_id=volunteer.id,
- content=personalized_content,
- phone_number=volunteer.phone_number,
- sending_time=sending_time,
- )
- sms_list.append(sms)
- session.add_all(sms_list)
- session.commit()
- return sms_list
- @router.delete("/project/{project_id}")
- async def delete_project(
- project_id: UUID,
- current_user: User = Depends(deps.require_org_role(OrgRole.ORG_ADMIN)),
- session: Session = Depends(deps.get_session),
- ):
- """Delete project"""
- session.execute(delete(Project).where(Project.id == project_id))
- session.commit()
|