diff --git a/app/database/models.py b/app/database/models.py index 94f702d8..a1b8952c 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -61,6 +61,8 @@ class Event(Base): color = Column(String, nullable=True) owner_id = Column(Integer, ForeignKey("users.id")) + invitees = Column(String) + color = Column(String, nullable=True) category_id = Column(Integer, ForeignKey("categories.id")) owner = relationship("User") diff --git a/app/internal/event.py b/app/internal/event.py index 52b031e0..a91b20b6 100644 --- a/app/internal/event.py +++ b/app/internal/event.py @@ -1,12 +1,41 @@ +import logging import re +from email_validator import validate_email, EmailSyntaxError from fastapi import HTTPException + from starlette.status import HTTP_400_BAD_REQUEST +from app.database.models import Event + ZOOM_REGEX = re.compile(r'https://.*?\.zoom.us/[a-z]/.[^.,\b\s]+') -def validate_zoom_link(location): +def raise_if_zoom_link_invalid(location): if ZOOM_REGEX.search(location) is None: raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail="VC type with no valid zoom link") + + +def get_invited_emails(invited_from_form): + invited_emails = [] + for invited_email in invited_from_form.split(','): + invited_email = invited_email.strip() + try: + validate_email(invited_email, check_deliverability=False) + invited_emails.append(invited_email) + except EmailSyntaxError: + logging.error(f'{invited_email} is not a valid email address') + + return invited_emails + + +def get_uninvited_regular_emails(session, owner_id, title, invited_emails): + regular_invitees = set() + invitees_query = session.query(Event).with_entities(Event.invitees) + similar_events_invitees = invitees_query.filter(Event.owner_id == owner_id, + Event.title == title).all() + for record in similar_events_invitees: + regular_invitees.update(record[0].split(',')) + + return regular_invitees - set(invited_emails) diff --git a/app/routers/event.py b/app/routers/event.py index c4b2c44b..0dab4c7a 100644 --- a/app/routers/event.py +++ b/app/routers/event.py @@ -1,9 +1,8 @@ -from datetime import datetime +from datetime import datetime as dt from operator import attrgetter from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Request -from loguru import logger from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound @@ -12,11 +11,25 @@ from app.database.database import get_db from app.database.models import Event, User, UserEvent +from app.dependencies import logger from app.dependencies import templates -from app.internal.event import validate_zoom_link +from app.internal.event import (raise_if_zoom_link_invalid, get_invited_emails, + get_uninvited_regular_emails) from app.internal.utils import create_model from app.routers.user import create_user +TIME_FORMAT = '%Y-%m-%d %H:%M' + +UPDATE_EVENTS_FIELDS = { + 'title': str, + 'start': dt, + 'end': dt, + 'content': (str, type(None)), + 'location': (str, type(None)), + 'category_id': (int, type(None)) +} + + router = APIRouter( prefix="/event", tags=["event"], @@ -35,10 +48,10 @@ async def create_new_event(request: Request, session=Depends(get_db)): data = await request.form() title = data['title'] content = data['description'] - start = datetime.strptime(data['start_date'] + ' ' + data['start_time'], - '%Y-%m-%d %H:%M') - end = datetime.strptime(data['end_date'] + ' ' + data['end_time'], - '%Y-%m-%d %H:%M') + start = dt.strptime(data['start_date'] + ' ' + data['start_time'], + TIME_FORMAT) + end = dt.strptime(data['end_date'] + ' ' + data['end_time'], + TIME_FORMAT) user = session.query(User).filter_by(id=1).first() user = user if user else create_user(username="u", password="p", @@ -52,14 +65,20 @@ async def create_new_event(request: Request, session=Depends(get_db)): location = data['location'] category_id = data.get('category_id') + invited_emails = get_invited_emails(data['invited']) + uninvited_contacts = get_uninvited_regular_emails(session, owner_id, + title, invited_emails) + if is_zoom: - validate_zoom_link(location) + raise_if_zoom_link_invalid(location) event = create_event(session, title, start, end, owner_id, content, - location, category_id=category_id) - return RedirectResponse(router.url_path_for('eventview', - event_id=event.id), - status_code=status.HTTP_302_FOUND) + location, invited_emails, category_id=category_id) + message = '' + if uninvited_contacts: + message = f'Forgot to invite {", ".join(uninvited_contacts)} maybe?' + return RedirectResponse(router.url_path_for('eventview', event_id=event.id) + + f'?{message}', status_code=status.HTTP_302_FOUND) @router.get("/{event_id}") @@ -69,20 +88,12 @@ async def eventview(request: Request, event_id: int, start_format = '%A, %d/%m/%Y %H:%M' end_format = ('%H:%M' if event.start.date() == event.end.date() else start_format) + message = request.query_params.get('message', '') return templates.TemplateResponse("event/eventview.html", {"request": request, "event": event, "start_format": start_format, - "end_format": end_format}) - - -UPDATE_EVENTS_FIELDS = { - 'title': str, - 'start': datetime, - 'end': datetime, - 'content': (str, type(None)), - 'location': (str, type(None)), - 'category_id': (int, type(None)) -} + "end_format": end_format, + "message": message}) def by_id(db: Session, event_id: int) -> Event: @@ -115,10 +126,8 @@ def by_id(db: Session, event_id: int) -> Event: return event -def is_end_date_before_start_date( - start_date: datetime, end_date: datetime) -> bool: +def is_end_date_before_start_date(start_date: dt, end_date: dt) -> bool: """Check if the start date is earlier than the end date""" - return start_date > end_date @@ -190,9 +199,12 @@ def update_event(event_id: int, event: Dict, db: Session def create_event(db: Session, title: str, start, end, owner_id: int, content: str = None, location: str = None, + invitees: List[str] = None, category_id: int = None): """Creates an event and an association.""" + invitees_concatenated = ','.join(invitees or []) + event = create_model( db, Event, title=title, @@ -201,6 +213,7 @@ def create_event(db: Session, title: str, start, end, owner_id: int, content=content, owner_id=owner_id, location=location, + invitees=invitees_concatenated, category_id=category_id, ) create_model( @@ -221,13 +234,11 @@ def sort_by_date(events: List[Event]) -> List[Event]: def get_participants_emails_by_event(db: Session, event_id: int) -> List[str]: """Returns a list of all the email address of the event invited users, by event id.""" - - return [email[0] for email in db.query(User.email). - select_from(Event). - join(UserEvent, UserEvent.event_id == Event.id). - join(User, User.id == UserEvent.user_id). - filter(Event.id == event_id). - all()] + return [email[0] for email in + db.query(User.email).select_from(Event).join( + UserEvent, UserEvent.event_id == Event.id).join( + User, User.id == UserEvent.user_id).filter( + Event.id == event_id).all()] def _delete_event(db: Session, event: Event): @@ -254,7 +265,7 @@ def delete_event(event_id: int, event = by_id(db, event_id) participants = get_participants_emails_by_event(db, event_id) _delete_event(db, event) - if participants and event.start > datetime.now(): + if participants and event.start > dt.now(): pass # TODO: Send them a cancellation notice # if the deletion is successful diff --git a/app/templates/event/eventview.html b/app/templates/event/eventview.html index dc343a01..84d39cb4 100644 --- a/app/templates/event/eventview.html +++ b/app/templates/event/eventview.html @@ -8,6 +8,9 @@
+
+

{{ message }}

+
+
+ + +
+
diff --git a/tests/test_event.py b/tests/test_event.py index be9adb9d..c161af89 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -20,7 +20,8 @@ 'description': 'content', 'color': 'red', 'availability': 'busy', - 'privacy': 'public' + 'privacy': 'public', + 'invited': 'a@a.com,b@b.com' } WRONG_EVENT_FORM_DATA = { @@ -34,7 +35,23 @@ 'description': 'content', 'color': 'red', 'availability': 'busy', - 'privacy': 'public' + 'privacy': 'public', + 'invited': 'a@a.com,b@b.com' +} + +BAD_EMAILS_FORM_DATA = { + 'title': 'test title', + 'start_date': '2021-01-28', + 'start_time': '15:59', + 'end_date': '2021-01-27', + 'end_time': '15:01', + 'location_type': 'vc_url', + 'location': 'https://us02web.zoom.us/j/875384596', + 'description': 'content', + 'color': 'red', + 'availability': 'busy', + 'privacy': 'public', + 'invited': 'a@a.com,b@b.com,ccc' } NONE_UPDATE_OPTIONS = [ @@ -66,6 +83,44 @@ def test_eventview_with_id(event_test_client, session, event): f'{event_detail} not in view event page' +def test_eventview_without_id(client): + response = client.get("/event/view") + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +def test_eventedit_missing_old_invites(client, user): + response = client.post(client.app.url_path_for('create_new_event'), + data=CORRECT_EVENT_FORM_DATA) + assert response.ok + assert response.status_code == status.HTTP_302_FOUND + + different_invitees_event = CORRECT_EVENT_FORM_DATA.copy() + different_invitees_event['invited'] = 'c@c.com,d@d.com' + response = client.post(client.app.url_path_for('create_new_event'), + data=different_invitees_event) + assert response.ok + assert response.status_code == status.HTTP_302_FOUND + for invitee in CORRECT_EVENT_FORM_DATA["invited"].split(","): + assert invitee in response.headers['location'] + + +def test_eventedit_bad_emails(client, user): + response = client.post(client.app.url_path_for('create_new_event'), + data=BAD_EMAILS_FORM_DATA) + assert response.ok + assert response.status_code == status.HTTP_302_FOUND + + different_invitees_event = CORRECT_EVENT_FORM_DATA.copy() + different_invitees_event['invited'] = 'c@c.com,d@d.com' + response = client.post(client.app.url_path_for('create_new_event'), + data=different_invitees_event) + assert response.ok + assert response.status_code == status.HTTP_302_FOUND + for invitee in CORRECT_EVENT_FORM_DATA["invited"].split(","): + assert invitee in response.headers['location'] + assert 'ccc' not in response.headers['location'] + + def test_eventedit_post_correct(client, user): """ Test create new event successfully.