project.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. from datetime import UTC, datetime, timedelta
  2. from uuid import UUID
  3. from fastapi import APIRouter, Depends, HTTPException
  4. from sqlalchemy import delete, select
  5. from sqlalchemy.exc import IntegrityError
  6. from sqlalchemy.orm import Session, selectinload
  7. from app.api import deps
  8. from app.api.utils import assert_project_exists_or_404, get_project_or_404
  9. from app.importData.gsheet import ParsingError, extract_doc_uid, parseGsheet
  10. from app.models import (
  11. Commission,
  12. GlobalRole,
  13. OrgRole,
  14. Project,
  15. Slot,
  16. SlotTag,
  17. SlotTemplate,
  18. Sms,
  19. User,
  20. Volunteer,
  21. VolunteerGroup,
  22. )
  23. from app.schemas.objects import PlanningConstraints
  24. from app.schemas.requests import (
  25. ProjectConstraintUpdateRequest,
  26. ProjectCreateRequest,
  27. ProjectImportGsheetRequest,
  28. ProjectSMSBatchRequest,
  29. ProjectUpdateRequest,
  30. )
  31. from app.schemas.responses import ProjectListResponse, ProjectResponse, SMSResponse
  32. router = APIRouter()
  33. @router.get("/projects", response_model=list[ProjectListResponse])
  34. async def list_project(
  35. current_user: User = Depends(deps.get_current_user),
  36. session: Session = Depends(deps.get_session),
  37. ):
  38. """Get project list, scoped to the caller's organizations (all projects for super_admin)."""
  39. if current_user.global_role == GlobalRole.SUPER_ADMIN:
  40. results = session.execute(select(Project))
  41. else:
  42. org_ids = [m.organization_id for m in current_user.organizations]
  43. if not org_ids:
  44. return []
  45. results = session.execute(select(Project).where(Project.organization_id.in_(org_ids)))
  46. return results.scalars().all()
  47. @router.get("/public-projects", response_model=list[ProjectListResponse])
  48. async def list_public_project(
  49. session: Session = Depends(deps.get_session),
  50. ):
  51. """Get the list of public projects — unchanged, stays fully public."""
  52. results = session.execute(select(Project).where(Project.is_public))
  53. return results.scalars().all()
  54. @router.post("/project", response_model=ProjectResponse)
  55. async def create_project(
  56. new_project: ProjectCreateRequest,
  57. current_user: User = Depends(deps.get_current_user),
  58. session: Session = Depends(deps.get_session),
  59. ):
  60. """Create a new project within one of the caller's organizations."""
  61. org_id = str(new_project.organization_id)
  62. if current_user.global_role != GlobalRole.SUPER_ADMIN:
  63. role = next(
  64. (m.role for m in current_user.organizations if m.organization_id == org_id),
  65. None,
  66. )
  67. if role != OrgRole.ORG_ADMIN:
  68. raise HTTPException(403, "Only an org_admin can create projects for this organization")
  69. project = Project(**new_project.model_dump())
  70. session.add(project)
  71. try:
  72. session.commit()
  73. except IntegrityError:
  74. raise HTTPException(400, "Project name already exist")
  75. session.refresh(project)
  76. return project
  77. @router.get("/public-project/{project_id}", response_model=ProjectResponse)
  78. async def get_public_project(
  79. project_id: UUID,
  80. session: Session = Depends(deps.get_session),
  81. ):
  82. """Get a project that is public — unchanged, stays fully public."""
  83. result = session.get(Project, project_id)
  84. if (result is None) or not result.is_public:
  85. raise HTTPException(status_code=404, detail="Project not found")
  86. return result
  87. @router.get("/project/{project_id}", response_model=ProjectResponse)
  88. async def get_project(
  89. project_id: UUID,
  90. current_user: User = Depends(
  91. deps.require_org_role(OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE, OrgRole.RESPO_COMMISSION)
  92. ),
  93. session: Session = Depends(deps.get_session),
  94. ):
  95. """Get a project"""
  96. project = session.scalar(
  97. select(Project)
  98. .where(Project.id == project_id)
  99. .options(
  100. selectinload(Project.volunteers),
  101. selectinload(Project.sms),
  102. selectinload(Project.slots).selectinload(Slot.volunteers),
  103. selectinload(Project.slots).selectinload(Slot.template),
  104. selectinload(Project.tags).selectinload(SlotTag.templates),
  105. selectinload(Project.templates).selectinload(SlotTemplate.tags),
  106. selectinload(Project.templates).selectinload(SlotTemplate.commission),
  107. selectinload(Project.groups).selectinload(VolunteerGroup.volunteers),
  108. selectinload(Project.commissions).selectinload(Commission.templates),
  109. selectinload(Project.commissions).selectinload(Commission.members),
  110. )
  111. )
  112. if project is None:
  113. raise HTTPException(status_code=404, detail="Project not found")
  114. return project
  115. @router.post(
  116. "/project/{project_id}",
  117. response_model=ProjectListResponse,
  118. openapi_extra={"sse_event": "project_updated"},
  119. )
  120. async def update_project(
  121. project_id: UUID,
  122. edit_project: ProjectUpdateRequest,
  123. current_user: User = Depends(deps.require_org_role(OrgRole.ORG_ADMIN)),
  124. session: Session = Depends(deps.get_session),
  125. ):
  126. """Edit project"""
  127. p = get_project_or_404(session, project_id)
  128. p.name = edit_project.name
  129. p.is_public = edit_project.is_public
  130. session.commit()
  131. return p
  132. @router.post(
  133. "/project/{project_id}/constraints",
  134. openapi_extra={"sse_event": "project_constraints_updated"},
  135. response_model=PlanningConstraints,
  136. )
  137. async def update_project_constraints(
  138. project_id: UUID,
  139. payload: ProjectConstraintUpdateRequest,
  140. current_user: User = Depends(deps.require_org_role(OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)),
  141. session: Session = Depends(deps.get_session),
  142. ):
  143. project = session.get(Project, project_id)
  144. if project is None:
  145. raise HTTPException(status_code=404, detail="Project not found")
  146. project.constraints = payload.model_dump()
  147. session.commit()
  148. session.refresh(project)
  149. return project.constraints
  150. @router.post("/project/{project_id}/import-gsheet", response_model=ProjectResponse)
  151. async def update_project_from_gsheet(
  152. project_id: UUID,
  153. gsheet: ProjectImportGsheetRequest,
  154. current_user: User = Depends(deps.require_org_role(OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)),
  155. session: Session = Depends(deps.get_session),
  156. ):
  157. """Edit project name"""
  158. p: Project = session.get(Project, project_id)
  159. if p is None:
  160. raise HTTPException(status_code=404, detail="Project not found")
  161. doc_id = extract_doc_uid(gsheet.sheet_url)
  162. if gsheet.erase_data:
  163. p.slots = []
  164. p.sms = []
  165. p.volunteers = []
  166. p.tags = []
  167. p.templates = []
  168. try:
  169. data = parseGsheet(doc_id, gsheet.satursday_date)
  170. except ParsingError as exc:
  171. raise HTTPException(status_code=422, detail=str(exc))
  172. volunteer_map: dict[str, Volunteer] = {}
  173. for _, row in data.contact.iterrows():
  174. volunteer = Volunteer(
  175. project_id=project_id,
  176. name=row["Prénom"],
  177. surname=row["Nom"],
  178. email=row["Mail"],
  179. phone_number=row["Tél"],
  180. automatic_sms=row["SMS"],
  181. )
  182. volunteer_map[row.key] = volunteer
  183. session.add(volunteer)
  184. template_map = {}
  185. tags_map = {tag.title: tag for tag in p.tags}
  186. for _, row in data.creneauData.iterrows():
  187. # NOTE: responsible_override stays free-text on gsheet import for now —
  188. # linking to a real Commission requires matching against commission
  189. # names/members, which the sheet doesn't carry. Revisit once the
  190. # gsheet format itself is updated to reference commissions.
  191. template = SlotTemplate(
  192. project_id=project_id,
  193. title=row.title,
  194. description=row.description,
  195. place=row.lieu,
  196. responsible_override=row.responsable,
  197. )
  198. for s_tag in row.tags.split(","):
  199. if s_tag != "":
  200. if s_tag in tags_map:
  201. tag = tags_map[s_tag]
  202. else:
  203. tag = SlotTag(project_id=project_id, title=s_tag)
  204. session.add(tag)
  205. tags_map[s_tag] = tag
  206. template.tags.append(tag)
  207. template_map[template.title] = template
  208. session.add(template)
  209. df_planning = data.planning
  210. date_format = "%Y/%m/%d %H:%M"
  211. df_planning["key"] = (
  212. df_planning.nom.str.strip()
  213. + "_"
  214. + df_planning.start.dt.strftime(date_format)
  215. + "-"
  216. + df_planning.end.dt.strftime(date_format)
  217. )
  218. df_slots = df_planning.groupby("key")
  219. for key in df_slots.groups.keys():
  220. group = df_slots.get_group(key)
  221. volunteers = group.benevole_nom.tolist()
  222. slot = Slot(
  223. project_id=project_id,
  224. title=group.nom.iloc[0],
  225. starting_time=group.start.iloc[0],
  226. ending_time=group.end.iloc[0],
  227. required_volunteers=len(volunteers),
  228. )
  229. for benevole_key in volunteers:
  230. if benevole_key in volunteer_map:
  231. slot.volunteers.append(volunteer_map[benevole_key])
  232. template_id = group.template_id.iloc[0]
  233. if template_id in template_map:
  234. slot.template = template_map[template_id]
  235. session.add(slot)
  236. session.commit()
  237. session.refresh(p)
  238. return p
  239. @router.post("/project/{project_id}/create-all-sms", response_model=list[SMSResponse])
  240. async def create_sms_batch(
  241. project_id: UUID,
  242. sms_batch: ProjectSMSBatchRequest,
  243. current_user: User = Depends(deps.require_org_role(OrgRole.ORG_ADMIN, OrgRole.RESPO_BENEVOLE)),
  244. session: Session = Depends(deps.get_session),
  245. ):
  246. """(docstring unchanged)"""
  247. assert_project_exists_or_404(session, project_id)
  248. slots = session.execute(select(Slot).where(Slot.project_id == project_id))
  249. sms_list = []
  250. now = datetime.now(UTC)
  251. for slot in slots.scalars():
  252. slot_content = (
  253. sms_batch.template.replace("{titre}", slot.title)
  254. .replace("{debut}", slot.starting_time.strftime("%Hh%M"))
  255. .replace("{fin}", slot.ending_time.strftime("%Hh%M"))
  256. )
  257. if slot.template is not None:
  258. slot_content = slot_content.replace("{description}", slot.template.description).replace(
  259. "{respo}", slot.template.effective_responsible_contact
  260. )
  261. sending_time = slot.starting_time - timedelta(minutes=sms_batch.delta_t)
  262. if sending_time < now:
  263. continue
  264. for volunteer in slot.volunteers:
  265. if not volunteer.automatic_sms:
  266. continue
  267. personalized_content = slot_content.replace("{prenom}", volunteer.name).replace(
  268. "{nom}", volunteer.surname
  269. )
  270. sms = Sms(
  271. project_id=project_id,
  272. volunteer_id=volunteer.id,
  273. content=personalized_content,
  274. phone_number=volunteer.phone_number,
  275. sending_time=sending_time,
  276. )
  277. sms_list.append(sms)
  278. session.add_all(sms_list)
  279. session.commit()
  280. return sms_list
  281. @router.delete("/project/{project_id}")
  282. async def delete_project(
  283. project_id: UUID,
  284. current_user: User = Depends(deps.require_org_role(OrgRole.ORG_ADMIN)),
  285. session: Session = Depends(deps.get_session),
  286. ):
  287. """Delete project"""
  288. session.execute(delete(Project).where(Project.id == project_id))
  289. session.commit()