Skip to content

Commit 7cc19b0

Browse files
authored
Merge pull request #335 from LleidaHack/integration
Integration
2 parents 8f493f6 + 35bc6e9 commit 7cc19b0

File tree

3 files changed

+262
-14
lines changed

3 files changed

+262
-14
lines changed

src/impl/Event/router_v1.py

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from datetime import datetime
22
from typing import List, Union
33

4-
from fastapi import APIRouter, Depends
4+
from fastapi import APIRouter, Depends, BackgroundTasks, HTTPException
55

66
from src.configuration.Settings import settings
77
from src.error.AuthenticationException import AuthenticationException
@@ -20,6 +20,7 @@
2020
from src.utils.JWTBearer import JWTBearer
2121
from src.utils.service_utils import subtract_lists
2222
from src.utils.Token import AssistenceToken, BaseToken
23+
from src.utils.UserType import UserType
2324

2425
# from src.error.NotFoundException import NotFoundException
2526

@@ -380,20 +381,53 @@ def resend_accept_mail(
380381

381382

382383
@router.post("/{event_id}/send_slack_mail/")
383-
def send_slack_mail(event_id: int, slackUrl: str, token: BaseToken = Depends(JWTBearer())):
384-
event_service.send_slack_mail(event_id, slackUrl, token)
385-
return {"success": True}
384+
def send_slack_mail(
385+
event_id: int,
386+
slackUrl: str,
387+
background_tasks: BackgroundTasks,
388+
delay: float = 0.5,
389+
token: BaseToken = Depends(JWTBearer()),
390+
):
391+
"""
392+
Schedule sending slack invite mails in background. The endpoint validates
393+
permissions and immediately returns while the work continues in background.
394+
"""
395+
if not token.check([UserType.LLEIDAHACKER]):
396+
raise AuthenticationException("Not authorized")
397+
# guard: don't schedule if there's already a sending job for this event
398+
if event_service.is_sending(event_id):
399+
raise HTTPException(status_code=409, detail="Sending already in progress for this event")
400+
401+
# schedule background task to avoid request timeout
402+
background_tasks.add_task(event_service.send_slack_mail_background, event_id, slackUrl, delay)
403+
return {"success": True, "scheduled": True}
404+
405+
406+
@router.get("/{event_id}/send_progress")
407+
def get_send_progress(event_id: int, token: BaseToken = Depends(JWTBearer())):
408+
"""Return send progress for background jobs for an event."""
409+
if not token.check([UserType.LLEIDAHACKER]):
410+
raise AuthenticationException("Not authorized")
411+
return event_service.get_send_progress(event_id)
386412

387413
@router.post("/{event_id}/send_reminder_mails/")
388414
def send_reminder_mails(
389415
event_id: int,
416+
background_tasks: BackgroundTasks,
417+
delay: float = 0.0,
390418
token: BaseToken = Depends(JWTBearer()),
391419
):
392420
"""
393-
Send reminder mails to all accepted hackers of an event
421+
Schedule sending reminder mails to all accepted hackers of an event.
422+
The endpoint validates permissions and immediately returns while the work
423+
continues in background.
394424
"""
395-
event_service.send_reminder_mails(event_id, token)
396-
return {"success": True}
425+
if not token.check([UserType.LLEIDAHACKER]):
426+
raise AuthenticationException("Not authorized")
427+
428+
# schedule background task to avoid request timeout
429+
background_tasks.add_task(event_service.send_reminder_mails_background, event_id, delay)
430+
return {"success": True, "scheduled": True}
397431

398432

399433
# @router.post("/{event_id}/send_remember")

src/impl/Event/service.py

Lines changed: 206 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
from fastapi_sqlalchemy import db
2-
from sqlalchemy import desc
2+
from sqlalchemy import desc, create_engine
3+
from sqlalchemy.orm import sessionmaker
34
from datetime import datetime
5+
import time
6+
import logging
7+
import threading
8+
from src.configuration.Settings import settings
49
from generated_src.lleida_hack_mail_api_client.models.mail_create import MailCreate
510
from collections import Counter
611

@@ -40,6 +45,65 @@ class EventService(BaseService):
4045
hacker_service: HackerService = None
4146
company_service: CompanyService = None
4247
mail_client: MailClient = None
48+
# background sending job tracking: event_id -> job info
49+
_sending_jobs = {}
50+
_sending_jobs_lock = threading.Lock()
51+
52+
def _start_job(self, event_id: int, job_type: str, total: int):
53+
with self._sending_jobs_lock:
54+
self._sending_jobs[event_id] = {
55+
"type": job_type,
56+
"start": datetime.now(),
57+
"finish": None,
58+
"total": int(total) if total is not None else 0,
59+
"sent": 0,
60+
"status": "running",
61+
}
62+
63+
def _increment_sent(self, event_id: int):
64+
with self._sending_jobs_lock:
65+
job = self._sending_jobs.get(event_id)
66+
if job and job.get("status") == "running":
67+
job["sent"] = job.get("sent", 0) + 1
68+
69+
def _finish_job(self, event_id: int):
70+
with self._sending_jobs_lock:
71+
job = self._sending_jobs.get(event_id)
72+
if job:
73+
job["status"] = "finished"
74+
job["finish"] = datetime.now()
75+
76+
def is_sending(self, event_id: int) -> bool:
77+
with self._sending_jobs_lock:
78+
job = self._sending_jobs.get(event_id)
79+
return bool(job and job.get("status") == "running")
80+
81+
def get_send_progress(self, event_id: int):
82+
with self._sending_jobs_lock:
83+
job = self._sending_jobs.get(event_id)
84+
if not job:
85+
return {"running": False}
86+
# copy minimal fields to compute without lock
87+
start = job.get("start")
88+
sent = job.get("sent", 0)
89+
total = job.get("total", 0)
90+
status = job.get("status")
91+
finish = job.get("finish")
92+
elapsed = (datetime.now() - start).total_seconds() if start else 0
93+
estimated_remaining = None
94+
if sent > 0 and total > sent:
95+
avg = elapsed / sent
96+
estimated_remaining = int(max(0, avg * (total - sent)))
97+
98+
return {
99+
"running": status == "running",
100+
"sent": int(sent),
101+
"total": int(total),
102+
"elapsed_seconds": int(elapsed),
103+
"estimated_remaining_seconds": estimated_remaining,
104+
"started_at": start.isoformat() if start else None,
105+
"finished_at": finish.isoformat() if finish else None,
106+
}
43107

44108
def get_all(self):
45109
return db.session.query(Event).filter(Event.archived.is_(False)).all()
@@ -996,10 +1060,66 @@ def send_slack_mail(self, event_id: int, slackUrl: str, data: BaseToken):
9961060
)
9971061
)
9981062
# send the created mail
999-
self.mail_client.send_mail_by_id(mail.id)
1063+
resp = self.mail_client.send_mail_by_id(mail.id)
10001064

10011065
db.session.commit()
10021066

1067+
@BaseService.needs_service(MailClient)
1068+
def send_slack_mail_background(self, event_id: int, slackUrl: str, delay: float = 0.2):
1069+
"""
1070+
Background-safe sender: creates its own DB session and sends slack invite mails
1071+
to accepted hackers with an optional `delay` between sends to avoid throttling.
1072+
"""
1073+
engine = create_engine(settings.database.url)
1074+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
1075+
session = SessionLocal()
1076+
try:
1077+
event = session.query(Event).filter(Event.id == event_id).first()
1078+
if event is None or event.archived:
1079+
return
1080+
1081+
hackers = event.accepted_hackers
1082+
total = len(hackers) if hackers is not None else 0
1083+
# start tracking job
1084+
try:
1085+
self._start_job(event_id, "slack", total)
1086+
except Exception:
1087+
# best-effort tracking, don't fail send if tracking setup fails
1088+
pass
1089+
1090+
for hacker in hackers:
1091+
try:
1092+
mail = self.mail_client.create_mail(
1093+
MailCreate(
1094+
template_id=self.mail_client.get_internall_template_id(
1095+
InternalTemplate.EVENT_SLACK_INVITE
1096+
),
1097+
subject="HackEPS2025 slack invitation",
1098+
receiver_id=str(hacker.id),
1099+
receiver_mail=str(hacker.email),
1100+
fields=slackUrl,
1101+
)
1102+
)
1103+
resp = self.mail_client.send_mail_by_id(mail.id)
1104+
# increment progress
1105+
try:
1106+
self._increment_sent(event_id)
1107+
except Exception:
1108+
pass
1109+
session.commit()
1110+
if delay and delay > 0:
1111+
time.sleep(delay)
1112+
except Exception:
1113+
session.rollback()
1114+
continue
1115+
# finish tracking
1116+
try:
1117+
self._finish_job(event_id)
1118+
except Exception:
1119+
pass
1120+
finally:
1121+
session.close()
1122+
10031123
@BaseService.needs_service(MailClient)
10041124
def send_reminder_mails(
10051125
self,
@@ -1041,7 +1161,8 @@ def send_reminder_mails(
10411161
except Exception:
10421162
days_left = 0
10431163

1044-
fields = f"{hacker.name},{event.name},{days_left},{reg.confirm_assistance_token}"
1164+
# fields order: name, days_left, token, event_name
1165+
fields = f"{hacker.name},{days_left},{reg.confirm_assistance_token},{event.name}"
10451166

10461167
mail = self.mail_client.create_mail(
10471168
MailCreate(
@@ -1055,10 +1176,90 @@ def send_reminder_mails(
10551176
)
10561177
)
10571178
# send the created mail
1058-
self.mail_client.send_mail_by_id(mail.id)
1179+
resp = self.mail_client.send_mail_by_id(mail.id)
10591180
db.session.commit()
10601181
except Exception as e:
10611182
db.session.rollback()
10621183
# Optionally log the error here, e.g.:
10631184
# print(f"Failed to send reminder to hacker {hacker.id}: {e}")
1064-
continue
1185+
continue
1186+
1187+
@BaseService.needs_service(MailClient)
1188+
def send_reminder_mails_background(self, event_id: int, delay: float = 0.0):
1189+
"""
1190+
Background-safe sender: creates its own DB session and sends reminder mails
1191+
to accepted hackers. Optional `delay` between sends to avoid throttling.
1192+
"""
1193+
engine = create_engine(settings.database.url)
1194+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
1195+
session = SessionLocal()
1196+
try:
1197+
event = session.query(Event).filter(Event.id == event_id).first()
1198+
if event is None or event.archived:
1199+
return
1200+
1201+
# build list of eligible (hacker, reg) to have accurate total
1202+
hackers = event.accepted_hackers
1203+
eligible = []
1204+
for hacker in hackers:
1205+
reg = (
1206+
session.query(HackerRegistration)
1207+
.filter(
1208+
HackerRegistration.user_id == hacker.id,
1209+
HackerRegistration.event_id == event.id,
1210+
)
1211+
.first()
1212+
)
1213+
if reg is None or reg.confirmed_assistance:
1214+
continue
1215+
eligible.append((hacker, reg))
1216+
1217+
total = len(eligible)
1218+
try:
1219+
self._start_job(event_id, "reminder", total)
1220+
except Exception:
1221+
pass
1222+
1223+
for hacker, reg in eligible:
1224+
try:
1225+
# ensure there is a confirmation token for this registration
1226+
if not reg.confirm_assistance_token:
1227+
reg.confirm_assistance_token = AssistenceToken(hacker, event.id).to_token()
1228+
1229+
try:
1230+
delta = event.start_date - datetime.now()
1231+
days_left = max(0, int(delta.total_seconds() // 86400))
1232+
except Exception:
1233+
days_left = 0
1234+
1235+
fields = f"{hacker.name},{days_left},{reg.confirm_assistance_token},{event.name}"
1236+
1237+
mail = self.mail_client.create_mail(
1238+
MailCreate(
1239+
template_id=self.mail_client.get_internall_template_id(
1240+
InternalTemplate.EVENT_HACKER_REMINDER
1241+
),
1242+
subject=f"{event.name} - Recordatori de confirmació d'assistència",
1243+
receiver_id=str(hacker.id),
1244+
receiver_mail=str(hacker.email),
1245+
fields=fields,
1246+
)
1247+
)
1248+
1249+
resp = self.mail_client.send_mail_by_id(mail.id)
1250+
try:
1251+
self._increment_sent(event_id)
1252+
except Exception:
1253+
pass
1254+
session.commit()
1255+
if delay and delay > 0:
1256+
time.sleep(delay)
1257+
except Exception:
1258+
session.rollback()
1259+
continue
1260+
try:
1261+
self._finish_job(event_id)
1262+
except Exception:
1263+
pass
1264+
finally:
1265+
session.close()

src/impl/Mail/client.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from http import HTTPStatus
22
from typing import Any
3+
import logging
34
from generated_src.lleida_hack_mail_api_client.api.health import health_check
45
from generated_src.lleida_hack_mail_api_client.api.mail import (
56
mail_create,
@@ -12,6 +13,8 @@
1213
from src.utils.Base.BaseClient import BaseClient
1314
from src.configuration.Settings import settings
1415

16+
logger = logging.getLogger(__name__)
17+
1518

1619
def initialized(func):
1720
def wrapper(*args, **kwargs):
@@ -40,7 +43,7 @@ def __init__(self) -> Any:
4043
self._initialized = True
4144
except Exception:
4245
self._initialized = False
43-
print("MailClient not initialized")
46+
logger.warning("MailClient not initialized")
4447
# raise MailClientException('MailClient is not available')
4548

4649
def check_health(self):
@@ -59,12 +62,22 @@ def create_mail(self, mail: MailCreate):
5962
r = mail_create.sync(client=self.client, body=mail)
6063
if r is None:
6164
raise Exception(f"error creating {mail}")
65+
try:
66+
mail_id = getattr(r, "id", None)
67+
logger.info("Mail created id=%s receiver=%s subject=%s", mail_id, mail.receiver_mail, mail.subject)
68+
except Exception:
69+
logger.debug("Mail created (unable to log details)")
6270
return r
6371

6472
@initialized
6573
def send_mail_by_id(self, id: int):
66-
print('estem arrivant al send nanu')
74+
logger.info("Sending mail id=%s", id)
6775
r = mail_send_by_id.sync_detailed(id, client=self.client)
76+
status = getattr(r, "status_code", None)
77+
try:
78+
logger.info("Mail send result id=%s status=%s", id, status)
79+
except Exception:
80+
logger.debug("Mail send completed id=%s", id)
6881
return r
6982

7083
def get_template_by_name(self, name):

0 commit comments

Comments
 (0)