diff --git a/Dockerfile b/Dockerfile index 2d1163f..541bb19 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.13-slim-bookworm +FROM python:3.13.5-slim-bookworm COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ ADD . /app diff --git a/app.py b/app.py index ec0c1ee..7537250 100644 --- a/app.py +++ b/app.py @@ -18,6 +18,46 @@ import logging +import time +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.middleware import Middleware + +#For RateLimiting + +class RateLimitMiddleware(BaseHTTPMiddleware): + def __init__(self,app,max_requests: int=100, window_seconds: int =60 ): + super().__init__(app) + self.max_requests = max_requests + self.window_seconds = window_seconds + self._requests = {} + + async def dispatch(self,request: Request, call_next): + if not request.url.path.startswith("/events"): + return await call_next(request) + + client_ip = request.client.host if request.client else "unknown" + now = time.time() + + if client_ip in self._requests: + self._requests[client_ip] = [ + ts for ts in self._requests[client_ip] + if now - ts < self.window_seconds + ] + else: + self._requests[client_ip]=[] + + #Rate Limit checking + + if len(self._requests[client_ip]) >= self.max_requests: + return JSONResponse( + {"error": "Rate limit exceeded. Try again later."}, + status_code=429 + ) + self._requests[client_ip].append(now) + return await call_next(request) + + + engine = None @@ -89,4 +129,5 @@ async def endpoint(req: Request): Route("/health",endpoint=health,methods=["GET"]) ], lifespan=lifespan, + middleware=[Middleware(RateLimitMiddleware,max_requests=100,window_seconds = 60)] ) diff --git a/isabelle/events/buttons/approve_event.py b/isabelle/events/buttons/approve_event.py index 3cc020a..43c4dc7 100644 --- a/isabelle/events/buttons/approve_event.py +++ b/isabelle/events/buttons/approve_event.py @@ -1,5 +1,6 @@ from typing import Any from typing import Callable +import logging from slack_sdk.web.async_client import AsyncWebClient @@ -52,5 +53,24 @@ async def handle_approve_event_btn(ack: Callable, body: dict[str, Any], client: channel=event["LeaderSlackID"], text=f"Your event {event["Title"]} has been approved by <@{user_id}>! Please reach out to them if you have any questions or need help.", ) + if env.mailer: + try: + user_info = await client.users_info(user=event["LeaderSlackID"]) + host_email = user_info["user"]["profile"].get("email") + if host_email: + #An example + env.mailer.send_email( + host_email, + f"Your event `{event['Title']}' has been approved!", + f"Hi {event['Leader']}!\n\n" + f"Great news - your event `{event['Title']}` has been approved by <@{user_id}>.\n" + f"Start Time: {event['StartTime']}\n" + f"Event Link: {event.get('EventLink','N/A')}\n\n" + f"Reach out to <@{user_id}> if you have any question\n\n" + + ) + + except Exception: + logging.exception("Failed to send approval email") await client.views_publish(user_id=user_id, view=await get_home(user_id, client)) diff --git a/isabelle/events/reaction_added.py b/isabelle/events/reaction_added.py index fc7f498..bc42bab 100644 --- a/isabelle/events/reaction_added.py +++ b/isabelle/events/reaction_added.py @@ -54,5 +54,5 @@ async def handle_reaction_added(body, client: AsyncWebClient): text='Successfully RSVPed to the event. You will receive reminders about the event.' ) except Exception: - pass + logging.exception("Error sending RSVP confirmation") diff --git a/isabelle/events/views/create_event.py b/isabelle/events/views/create_event.py index 0b6f5ad..03f6c05 100644 --- a/isabelle/events/views/create_event.py +++ b/isabelle/events/views/create_event.py @@ -13,14 +13,13 @@ from slack_gfm import rich_text_to_gfm from isabelle.views.app_home import get_home -from datetime import datetime async def handle_create_event_view(ack: Callable, body: dict[str, Any], client: AsyncWebClient): await ack() view = body["view"] values = view["state"]["values"] - title = (values["title"]["title"]["value"],) + title = (values["title"]["title"]["value"]) description = values["description"]["description"]["rich_text_value"]["elements"] md = rich_text_to_md(description) start_time = datetime.fromtimestamp(values["start_time"]["start_time"]["selected_date_time"]) @@ -60,7 +59,7 @@ async def handle_create_event_view(ack: Callable, body: dict[str, Any], client: await client.chat_postEphemeral( user=body["user"]["id"], channel=body["user"]["id"], - text=f'An error occurred whilst creating the event "{title[0]}".', + text=(f'Could not create "{title[0]}\n' f'An event with the same titla and start time may already exist\n' f'Please check exiting events or edit them instead') ) return diff --git a/isabelle/events/views/reject_event.py b/isabelle/events/views/reject_event.py index 4fbd020..495ee3d 100644 --- a/isabelle/events/views/reject_event.py +++ b/isabelle/events/views/reject_event.py @@ -1,6 +1,7 @@ import json from typing import Any from typing import Callable +import logging from slack_sdk.web.async_client import AsyncWebClient @@ -71,3 +72,16 @@ async def handle_reject_event_view(ack: Callable, body: dict[str, Any], client: message, ], ) + if env.mailer: + try: + info = await client.users_info(user=event["LeaderSlackID"]) + email_addr = info["user"]["profile"]["email"] + #rejection example(modificate!!!): + env.mailer.send_email( + email_addr, + f"Your event {event['Title']} has been rejected", + f"Hey {event['Leader']} has been rejected by <@{body['user']['id']}>,\n\n" + f"Please reach out to them if you have any questions or need help" + ) + except Exception: + logging.exception("Failed to send rejection email") \ No newline at end of file diff --git a/isabelle/tables.py b/isabelle/tables.py index 25396c6..d2a8eeb 100644 --- a/isabelle/tables.py +++ b/isabelle/tables.py @@ -1,6 +1,6 @@ from piccolo.table import Table -from piccolo.columns import Varchar,Boolean,Timestamp, SmallInt, Text, Array, UUID - +from piccolo.columns import Varchar,Boolean,Timestamp, SmallInt, Text, Array, UUID,Timestamptz +from datetime import datetime # Schema copied form airtable using PascalCase class Event(Table): @@ -42,4 +42,6 @@ class Event(Table): InterestedUsers = Array(base_column=Text(),default=[]) InterestCount = SmallInt() # I know this could easily be calculated but I will try to keep this as close to the airtable as possible rsvpMsg = Text(null=True) - Tags = Array(base_column=Text(), default=[]) \ No newline at end of file + Tags = Array(base_column=Text(), default=[]) + created_at = Timestamptz(auto_update=datetime.now,default=datetime.now) + updated_at = Timestamptz(auto_update=datetime.now,default=datetime.now) \ No newline at end of file diff --git a/isabelle/utils/database.py b/isabelle/utils/database.py index c0c3d62..837ed23 100644 --- a/isabelle/utils/database.py +++ b/isabelle/utils/database.py @@ -29,6 +29,21 @@ async def create_event( tags: Optional[List[str]] = None, ) -> Optional[Event]: + ##Checking for duplicate events + + existing = await Event.select().where( + Event.Title == title, + Event.StartTime == start_time, + Event.Cancelled == False + ).first() + + if existing: + logging.warning( + f"Duplicate event blocked: `{title} at {start_time}" + f"(existing ID: {existing.get('id')})" + ) + return None + raw_description_json = json.dumps({ "type": "rich_text", "elements": raw_description, @@ -84,7 +99,7 @@ async def get_all_events(self, include_unapproved: bool = False) -> List[Event]: return await query.order_by(Event.StartTime) async def get_upcoming_events(self, include_unapproved: bool = False) -> List[Event]: - now = datetime.now() + now = datetime.now(timezone.utc).replace(tzinfo=None) query = Event.select().where( Event.StartTime > now, Event.Cancelled == False diff --git a/isabelle/utils/env.py b/isabelle/utils/env.py index e6b29ee..2f3a851 100644 --- a/isabelle/utils/env.py +++ b/isabelle/utils/env.py @@ -26,6 +26,10 @@ def __init__(self): unset = [key for key, value in self.__dict__.items() if value == "unset"] + if self.environemnt == "development": + unset = [key for key in unset if key not in ("airtable_api_key", "airtable_base_id")] + + if unset: raise ValueError(f"Missing environment variables: {', '.join(unset)}") diff --git a/isabelle/utils/rsvp_checker.py b/isabelle/utils/rsvp_checker.py index 5308094..92b65ae 100644 --- a/isabelle/utils/rsvp_checker.py +++ b/isabelle/utils/rsvp_checker.py @@ -1,7 +1,7 @@ import asyncio import logging import time -from datetime import datetime +from datetime import datetime,timezone from typing import Any from slack_sdk.web.async_client import AsyncWebClient @@ -60,7 +60,7 @@ async def check_rsvps(): await env.database.update_event(str(event["id"]), **{"Sent1HourReminder": True}) elif start_time - time.time() <= 0 and not event.get( - "SentStartingReminder", True + "SentStartingReminder", False ): pass for user in rsvps: diff --git a/isabelle/utils/slack.py b/isabelle/utils/slack.py index 127b526..54e2f10 100644 --- a/isabelle/utils/slack.py +++ b/isabelle/utils/slack.py @@ -21,6 +21,7 @@ from isabelle.events.reaction_added import handle_reaction_added from isabelle.events.reaction_removed import handle_reaction_removed from isabelle.utils.env import env +from isabelle.events.commands.create_event import handle_create_event_cmd from isabelle.views.app_home import get_home from slack_bolt.async_app import AsyncApp @@ -111,4 +112,8 @@ async def edit_ama_fields_btn(ack: Callable, body: dict[str, Any], client: Async @app.view("edit_ama_fields") async def edit_ama_fields_view(ack: Callable, body: dict[str, Any], client: AsyncWebClient): - await handle_edit_ama_fields_view(ack, body, client) \ No newline at end of file + await handle_edit_ama_fields_view(ack, body, client) + +@app.command("/create-event") +async def create_event_cmd(ack: Callable, body: dict[str,Any],client: AsyncWebClient): + await handle_create_event_cmd(ack,body,client) \ No newline at end of file diff --git a/isabelle/views/app_home.py b/isabelle/views/app_home.py index c8a7536..48389d5 100644 --- a/isabelle/views/app_home.py +++ b/isabelle/views/app_home.py @@ -12,6 +12,7 @@ async def get_home(user_id: str, client: AsyncWebClient): sad_member = await user_in_safehouse(user_id) user_info = await client.users_info(user=user_id) + now = datetime.now(timezone.utc).replace(tzinfo=None) ws_admin = ( True if user_info["user"]["is_admin"] @@ -28,14 +29,14 @@ async def get_home(user_id: str, client: AsyncWebClient): event for event in events if event["StartTime"] - > datetime.now() + > now ] current_events = [ event for event in events - if datetime.now() + if now < event.get("EndTime") - and datetime.now() + and now > event["StartTime"] ] diff --git a/isabelle/views/edit_event.py b/isabelle/views/edit_event.py index 11d1550..d553777 100644 --- a/isabelle/views/edit_event.py +++ b/isabelle/views/edit_event.py @@ -13,7 +13,7 @@ async def get_edit_event_modal(event_id: str): "callback_id": "edit_event", "notify_on_close": True, "private_metadata": event_id, - "title": {"type": "plain_text", "text": "Add Event", "emoji": True}, + "title": {"type": "plain_text", "text": "Edit Event", "emoji": True}, "submit": {"type": "plain_text", "text": "Submit", "emoji": True}, "close": {"type": "plain_text", "text": "More options", "emoji": True}, "blocks": [