diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 00000000..5008ddfc Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index b8da1186..46441edc 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,7 @@ app/routers/stam .idea junit/ + +# .DS_Store +.DS_Store +DS_Store diff --git a/app/database/models.py b/app/database/models.py index 4dec81d0..63d350ae 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -92,6 +92,8 @@ class Event(Base): end = Column(DateTime, nullable=False) content = Column(String) location = Column(String, nullable=True) + latitude = Column(String, nullable=True) + longitude = Column(String, nullable=True) vc_link = Column(String, nullable=True) is_google_event = Column(Boolean, default=False) color = Column(String, nullable=True) diff --git a/app/internal/event.py b/app/internal/event.py index 57b29d27..a771149c 100644 --- a/app/internal/event.py +++ b/app/internal/event.py @@ -1,17 +1,29 @@ import logging import re -from typing import List, Set +from typing import List, NamedTuple, Set, Union from email_validator import EmailSyntaxError, validate_email from fastapi import HTTPException +from geopy.adapters import AioHTTPAdapter +from geopy.exc import GeocoderTimedOut, GeocoderUnavailable +from geopy.geocoders import Nominatim +from loguru import logger from sqlalchemy.orm import Session 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]+") +class Location(NamedTuple): + # Location type hint class. + latitude: str + longitude: str + name: str + + def raise_if_zoom_link_invalid(vc_link): if ZOOM_REGEX.search(vc_link) is None: raise HTTPException( @@ -101,3 +113,27 @@ def get_messages( f"Want to create another one {weeks_diff} after too?", ) return messages + + +async def get_location_coordinates( + address: str, +) -> Union[Location, str]: + """Return location coordinates and accurate + address of the specified location.""" + try: + async with Nominatim( + user_agent="Pylendar", + adapter_factory=AioHTTPAdapter, + ) as geolocator: + geolocation = await geolocator.geocode(address) + except (GeocoderTimedOut, GeocoderUnavailable) as e: + logger.exception(str(e)) + else: + if geolocation is not None: + location = Location( + latitude=geolocation.latitude, + longitude=geolocation.longitude, + name=geolocation.raw["display_name"], + ) + return location + return address diff --git a/app/routers/event.py b/app/routers/event.py index d87e206e..a5673307 100644 --- a/app/routers/event.py +++ b/app/routers/event.py @@ -16,18 +16,20 @@ from app.database.models import Comment, Event, User, UserEvent from app.dependencies import get_db, logger, templates +from app.internal import comment as cmt +from app.internal.emotion import get_emotion from app.internal.event import ( get_invited_emails, + get_location_coordinates, get_messages, get_uninvited_regular_emails, raise_if_zoom_link_invalid, ) -from app.internal import comment as cmt -from app.internal.emotion import get_emotion from app.internal.privacy import PrivacyKinds from app.internal.utils import create_model, get_current_user from app.routers.categories import get_user_categories + EVENT_DATA = Tuple[Event, List[Dict[str, str]], str] TIME_FORMAT = "%Y-%m-%d %H:%M" START_FORMAT = "%A, %d/%m/%Y %H:%M" @@ -132,9 +134,16 @@ async def create_new_event( title, invited_emails, ) + latitude, longitude = None, None if vc_link: raise_if_zoom_link_invalid(vc_link) + else: + location_details = await get_location_coordinates(location) + if not isinstance(location_details, str): + location = location_details.name + latitude = location_details.latitude + longitude = location_details.longitude event = create_event( db=session, @@ -145,6 +154,8 @@ async def create_new_event( owner_id=owner_id, content=content, location=location, + latitude=latitude, + longitude=longitude, vc_link=vc_link, invitees=invited_emails, category_id=category_id, @@ -411,6 +422,8 @@ def create_event( content: Optional[str] = None, location: Optional[str] = None, vc_link: str = None, + latitude: Optional[str] = None, + longitude: Optional[str] = None, color: Optional[str] = None, invitees: List[str] = None, category_id: Optional[int] = None, @@ -432,6 +445,8 @@ def create_event( content=content, owner_id=owner_id, location=location, + latitude=latitude, + longitude=longitude, vc_link=vc_link, color=color, emotion=get_emotion(title, content), diff --git a/app/static/event/eventview.css b/app/static/event/eventview.css index 3a420e0e..0b768405 100644 --- a/app/static/event/eventview.css +++ b/app/static/event/eventview.css @@ -50,10 +50,6 @@ div.event_info_row, margin-block-end: 0.2em; } -.title { - border-bottom: 4px solid blue; -} - .title h1 { white-space: nowrap; margin-block-start: 0.2em; @@ -72,4 +68,8 @@ div.event_info_row, button { height: 100%; -} \ No newline at end of file +} + +.google_maps_object { + width: 100%; +} diff --git a/app/templates/partials/calendar/event/view_event_details_tab.html b/app/templates/partials/calendar/event/view_event_details_tab.html index d2c235ed..36b96d04 100644 --- a/app/templates/partials/calendar/event/view_event_details_tab.html +++ b/app/templates/partials/calendar/event/view_event_details_tab.html @@ -1,11 +1,11 @@
- {{event.owner.username}} + {{event.owner.username}}
diff --git a/requirements.txt b/requirements.txt index 5bbdca17..5d07acea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ aiofiles==0.6.0 +aiohttp==3.7.3 aioredis==1.3.1 aiosmtpd==1.2.2 aiosmtplib==1.1.4 @@ -24,7 +25,7 @@ cachetools==4.2.0 certifi==2020.12.5 cffi==1.14.4 cfgv==3.2.0 -chardet==4.0.0 +chardet==3.0.4 click==7.1.2 colorama==0.4.4 coverage==5.3.1 @@ -44,6 +45,8 @@ fastapi-mail==0.3.3.1 filelock==3.0.12 flake8==3.8.4 frozendict==1.2 +geographiclib==1.50 +geopy==2.1.0 google-api-core==1.25.0 google-api-python-client==1.12.8 google-auth==1.24.0 @@ -77,6 +80,7 @@ mocker==1.1.1 multidict==5.1.0 mypy==0.790 mypy-extensions==0.4.3 +nest-asyncio==1.5.1 nltk==3.5 nodeenv==1.5.0 oauth2client==4.1.3 @@ -84,6 +88,7 @@ oauthlib==3.1.0 outcome==1.1.0 packaging==20.8 passlib==1.7.4 +pathspec==0.8.1 Pillow==8.1.0 pluggy==0.13.1 pre-commit==2.10.0 @@ -148,4 +153,5 @@ win32-setctime==1.0.3 word-forms==2.1.0 wsproto==1.0.0 yapf==0.30.0 -zipp==3.4.0 \ No newline at end of file +yarl==1.6.3 +zipp==3.4.0 diff --git a/tests/asyncio_fixture.py b/tests/asyncio_fixture.py index db6645c5..2506ab53 100644 --- a/tests/asyncio_fixture.py +++ b/tests/asyncio_fixture.py @@ -33,6 +33,7 @@ def fake_user_events(session): create_event( db=session, title='Cool today event', + color='red', start=today_date, end=today_date + timedelta(days=2), all_day=False, @@ -44,6 +45,7 @@ def fake_user_events(session): create_event( db=session, title='Cool (somewhen in two days) event', + color='blue', start=today_date + timedelta(days=1), end=today_date + timedelta(days=3), all_day=False, diff --git a/tests/conftest.py b/tests/conftest.py index 1d8a21d2..4c2d7f12 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,12 @@ import calendar +import nest_asyncio import pytest +from app.config import PSQL_ENVIRONMENT +from app.database.models import Base from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -from app.config import PSQL_ENVIRONMENT -from app.database.models import Base pytest_plugins = [ 'tests.user_fixture', @@ -80,3 +81,6 @@ def sqlite_engine(): @pytest.fixture def Calendar(): return calendar.Calendar(0) + + +nest_asyncio.apply() diff --git a/tests/event_fixture.py b/tests/event_fixture.py index 989c41fb..7c3d8a56 100644 --- a/tests/event_fixture.py +++ b/tests/event_fixture.py @@ -42,6 +42,7 @@ def today_event_2(sender: User, session: Session) -> Event: return create_event( db=session, title='event 2', + color='blue', start=today_date + timedelta(hours=3), end=today_date + timedelta(days=2, hours=3), all_day=False, @@ -55,6 +56,7 @@ def yesterday_event(sender: User, session: Session) -> Event: return create_event( db=session, title='event 3', + color='green', start=today_date - timedelta(hours=8), end=today_date, all_day=False, @@ -68,6 +70,7 @@ def next_week_event(sender: User, session: Session) -> Event: return create_event( db=session, title='event 4', + color='blue', start=today_date + timedelta(days=7, hours=2), end=today_date + timedelta(days=7, hours=4), all_day=False, @@ -81,6 +84,7 @@ def next_month_event(sender: User, session: Session) -> Event: return create_event( db=session, title='event 5', + color="green", start=today_date + timedelta(days=20, hours=4), end=today_date + timedelta(days=20, hours=6), all_day=False, @@ -94,6 +98,7 @@ def old_event(sender: User, session: Session) -> Event: return create_event( db=session, title='event 6', + color="red", start=today_date - timedelta(days=5), end=today_date - timedelta(days=1), all_day=False, diff --git a/tests/test_geolocation.py b/tests/test_geolocation.py new file mode 100644 index 00000000..9a9503fd --- /dev/null +++ b/tests/test_geolocation.py @@ -0,0 +1,105 @@ +import pytest + +from app.internal.event import get_location_coordinates +from app.database.models import Event +from sqlalchemy.sql import func + + +class TestGeolocation: + CORRECT_LOCATION_EVENT = { + "title": "test title", + "start_date": "2021-02-18", + "start_time": "18:00", + "end_date": "2021-02-18", + "end_time": "20:00", + "location_type": "address", + "location": "אדר 11, אשדוד", + "event_type": "on", + "description": "test1", + "color": "red", + "invited": "a@gmail.com", + "availability": "busy", + "privacy": "public", + } + + WRONG_LOCATION_EVENT = { + "title": "test title", + "start_date": "2021-02-18", + "start_time": "18:00", + "end_date": "2021-02-18", + "end_time": "20:00", + "location_type": "address", + "location": "not a real location with coords", + "event_type": "on", + "description": "test1", + "invited": "a@gmail.com", + "color": "red", + "availability": "busy", + "privacy": "public", + } + + CORRECT_LOCATIONS = [ + "Tamuz 13, Ashdod", + "Menachem Begin 21, Tel Aviv", + "רמב״ן 25, ירושלים", + ] + + WRONG_LOCATIONS = [ + "not a real location with coords", + "מיקום לא תקין", + "https://us02web.zoom.us/j/376584566", + ] + + @staticmethod + @pytest.mark.asyncio + @pytest.mark.parametrize("location", CORRECT_LOCATIONS) + async def test_get_location_coordinates_correct(location): + # Test geolocation search using valid locations. + location = await get_location_coordinates(location) + assert all(location) + + @staticmethod + @pytest.mark.asyncio + @pytest.mark.parametrize("location", WRONG_LOCATIONS) + async def test_get_location_coordinates_wrong(location): + # Test geolocation search using invalid locations. + location = await get_location_coordinates(location) + assert location == location + + @staticmethod + @pytest.mark.asyncio + async def test_event_location_correct(event_test_client, session): + # Test handling with location available on geopy servers. + response = event_test_client.post( + "event/edit", + data=TestGeolocation.CORRECT_LOCATION_EVENT, + ) + assert response.ok + event_id = session.query(func.count(Event.id)).scalar() + url = event_test_client.app.url_path_for( + "eventview", + event_id=event_id, + ) + response = event_test_client.get(url) + location = await get_location_coordinates( + TestGeolocation.CORRECT_LOCATION_EVENT["location"], + ) + address = location.name.split(" ")[0] + assert bytes(address, "utf-8") in response.content + + @staticmethod + def test_event_location_wrong(event_test_client, session): + # Test handling with location not available on geopy servers. + address = TestGeolocation.WRONG_LOCATION_EVENT["location"] + response = event_test_client.post( + "event/edit", + data=TestGeolocation.WRONG_LOCATION_EVENT, + ) + assert response.ok + event_id = session.query(func.count(Event.id)).scalar() + url = event_test_client.app.url_path_for( + "eventview", + event_id=event_id, + ) + response = event_test_client.get(url) + assert bytes(address, "utf-8") in response.content