From 3603fc643ca93e30d6e73c7e7ec4331ac349cbe1 Mon Sep 17 00:00:00 2001 From: Yair Engel Date: Wed, 10 Feb 2021 19:18:21 +0200 Subject: [PATCH 01/11] Change requirements --- requirements.txt | Bin 3270 -> 3270 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index 9038c58fb7649471ef5415d5b4bd408489593893..ec10ef9c99205cab919e3d5b116a070ccf88f628 100644 GIT binary patch delta 10 RcmX>mc}$Y&|Gy1KcmNzJ1wH@( delta 11 ScmX>mc}#L6(-9sfE(QP^MgvX& From 532fe209bf13698ebfffb225b3622473e3fe3e77 Mon Sep 17 00:00:00 2001 From: Yair Engel Date: Tue, 16 Feb 2021 00:21:35 +0200 Subject: [PATCH 02/11] Add restore deleted events feature Add html page Add css to dayview Add routers to profile Add restore_events logic Change event.py --- app/database/models.py | 1 + app/internal/restore_events.py | 30 ++ app/main.py | 16 +- app/routers/dayview.py | 2 +- app/routers/event.py | 10 +- app/routers/profile.py | 36 +- app/routers/weekview.py | 2 +- app/static/dayview.css | 94 ++--- app/templates/dayview.html | 135 ++++--- app/templates/profile.html | 630 ++++++++++++++++-------------- app/templates/restore_events.html | 58 +++ 11 files changed, 602 insertions(+), 412 deletions(-) create mode 100644 app/internal/restore_events.py create mode 100644 app/templates/restore_events.html diff --git a/app/database/models.py b/app/database/models.py index 0dcc0cb5..0a15f718 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -71,6 +71,7 @@ class Event(Base): "UserEvent", cascade="all, delete", back_populates="events", ) comments = relationship("Comment", back_populates="event") + deleted_date = Column(DateTime) # PostgreSQL if PSQL_ENVIRONMENT: diff --git a/app/internal/restore_events.py b/app/internal/restore_events.py new file mode 100644 index 00000000..106498b8 --- /dev/null +++ b/app/internal/restore_events.py @@ -0,0 +1,30 @@ +from datetime import datetime, timedelta +from app.database.models import Event, UserEvent + + +def delete_events_after_optionals_num_days(days, session): + date_to_delete = datetime.now() - timedelta(days=days) + + user_events_ids_to_be_deleted = session.query(UserEvent).join(Event).filter( + Event.deleted_date < date_to_delete).all() + + for id_to_be_deleted in user_events_ids_to_be_deleted: + session.query(UserEvent).filter(UserEvent.id == id_to_be_deleted.id).delete() + + session.query(Event).filter(Event.deleted_date < date_to_delete).delete() + session.commit() + + +def get_events_ids_to_restored(events_data): + ids = [] + check_element_name = 'check' + check_element_on_value = 'on' + + is_checkbox_element_is_on = False + for element, element_value in events_data: + if is_checkbox_element_is_on: + ids.append(element_value) + is_checkbox_element_is_on = False + if element == check_element_name and element_value == check_element_on_value: + is_checkbox_element_is_on = True + return ids diff --git a/app/main.py b/app/main.py index 1de3b95a..d8263a7d 100644 --- a/app/main.py +++ b/app/main.py @@ -1,13 +1,14 @@ from fastapi import Depends, FastAPI, Request from fastapi.staticfiles import StaticFiles from sqlalchemy.orm import Session - +import uvicorn from app import config from app.database import engine, models from app.dependencies import get_db, logger, MEDIA_PATH, STATIC_PATH, templates from app.internal import daily_quotes, json_data_loader from app.internal.languages import set_ui_language +from app.internal.restore_events import delete_events_after_optionals_num_days from app.routers.salary import routes as salary @@ -29,17 +30,19 @@ def create_tables(engine, psql_environment): app.mount("/media", StaticFiles(directory=MEDIA_PATH), name="media") app.logger = logger - json_data_loader.load_to_db(next(get_db())) # This MUST come before the app.routers imports. set_ui_language() +# delete permanently events after 30 days +DAYS = 30 +delete_events_after_optionals_num_days(DAYS, next(get_db())) from app.routers import ( # noqa: E402 agenda, calendar, categories, celebrity, currency, dayview, email, event, export, four_o_four, invitation, profile, search, - weekview, telegram, whatsapp, + weekview, telegram, whatsapp ) json_data_loader.load_to_db(next(get_db())) @@ -61,7 +64,8 @@ def create_tables(engine, psql_environment): salary.router, search.router, telegram.router, - whatsapp.router, + whatsapp.router + ] for router in routers_to_include: @@ -78,3 +82,7 @@ async def home(request: Request, db: Session = Depends(get_db)): "request": request, "quote": quote, }) + + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=8000) diff --git a/app/routers/dayview.py b/app/routers/dayview.py index 24ade9f3..8e0e400b 100644 --- a/app/routers/dayview.py +++ b/app/routers/dayview.py @@ -127,7 +127,7 @@ async def dayview( request: Request, date: str, session=Depends(get_db), view='day', ): # TODO: add a login session - user = session.query(User).filter_by(username='test_username').first() + user = session.query(User).filter_by(username='new_user').first() try: day = datetime.strptime(date, '%Y-%m-%d') except ValueError as err: diff --git a/app/routers/event.py b/app/routers/event.py index 111a766c..b531a4af 100644 --- a/app/routers/event.py +++ b/app/routers/event.py @@ -21,7 +21,6 @@ from app.internal.emotion import get_emotion from app.internal.utils import create_model, get_current_user - EVENT_DATA = Tuple[Event, List[Dict[str, str]], str, str] TIME_FORMAT = '%Y-%m-%d %H:%M' START_FORMAT = '%A, %d/%m/%Y %H:%M' @@ -248,7 +247,7 @@ def sort_by_date(events: List[Event]) -> List[Event]: def get_attendees_email(session: Session, event: Event): return ( session.query(User.email).join(UserEvent) - .filter(UserEvent.events == event).all() + .filter(UserEvent.events == event).all() ) @@ -264,11 +263,14 @@ def get_participants_emails_by_event(db: Session, event_id: int) -> List[str]: def _delete_event(db: Session, event: Event): try: + # TODO: Check if user activate the restore deleted events feature + # Delete event - db.delete(event) + # db.delete(event) + event.deleted_date = datetime.now() # Delete user_event - db.query(UserEvent).filter(UserEvent.event_id == event.id).delete() + # db.query(UserEvent).filter(UserEvent.event_id == event.id).delete() db.commit() diff --git a/app/routers/profile.py b/app/routers/profile.py index d5011446..0a90e8cf 100644 --- a/app/routers/profile.py +++ b/app/routers/profile.py @@ -8,11 +8,12 @@ from sqlalchemy.exc import SQLAlchemyError from app import config -from app.database.models import User +from app.database.models import User, Event from app.dependencies import get_db, MEDIA_PATH, templates from app.internal.on_this_day_events import get_on_this_day_events from app.internal.import_holidays import (get_holidays_from_file, save_holidays_to_db) +from app.internal.restore_events import get_events_ids_to_restored PICTURE_EXTENSION = config.PICTURE_EXTENSION PICTURE_SIZE = config.AVATAR_SIZE @@ -176,3 +177,36 @@ async def update_holidays( finally: url = router.url_path_for("profile") return RedirectResponse(url=url, status_code=HTTP_302_FOUND) + + +@router.get("/restore_events") +async def restore_events(request: Request, + session=Depends(get_db)): + # TODO: Add user.id instead 1 + deleted_events = session.query(Event.id, + Event.title, + Event.start, + Event.end).filter(Event.owner_id == 1, + Event.deleted_date != None).all() + + return templates.TemplateResponse("restore_events.html", { + "request": request, + "deleted_events": deleted_events + }) + + +@router.post("/restore_events") +async def restore_events(request: Request, + session=Depends(get_db)): + data = await request.form() + events_ids_to_restored = get_events_ids_to_restored(data._list) + + # TODO: Add user.id instead 1 + restore_del_events = session.query(Event).filter(Event.owner_id == 1, Event.deleted_date != None).filter( + Event.id.in_(events_ids_to_restored)).all() + + for del_event in restore_del_events: + del_event.deleted_date = None + + session.commit() + return RedirectResponse(url='restore_events', status_code=HTTP_302_FOUND) diff --git a/app/routers/weekview.py b/app/routers/weekview.py index efac161a..7e71a057 100644 --- a/app/routers/weekview.py +++ b/app/routers/weekview.py @@ -49,7 +49,7 @@ async def get_day_events_and_attributes( async def weekview( request: Request, firstday: str, session=Depends(get_db) ): - user = session.query(User).filter_by(username='test_username').first() + user = session.query(User).filter_by(username='new_user').first() firstday = datetime.strptime(firstday, '%Y-%m-%d') week_days = get_week_dates(firstday) week = [await get_day_events_and_attributes( diff --git a/app/static/dayview.css b/app/static/dayview.css index 384ec032..e4cc5d83 100644 --- a/app/static/dayview.css +++ b/app/static/dayview.css @@ -1,49 +1,49 @@ :root { - --primary: #30465D; - --primary-variant: #FFDE4D; - --secondary: #EF5454; - --borders: #E7E7E7; - --borders-variant: #F7F7F7; + --primary: #30465D; + --primary-variant: #FFDE4D; + --secondary: #EF5454; + --borders: #E7E7E7; + --borders-variant: #F7F7F7; } html { - font-family: 'Assistant', sans-serif; - text-align: center; + font-family: 'Assistant', sans-serif; + text-align: center; } #toptab { - background-color: var(--primary); + background-color: var(--primary); } .schedule { - display: grid; - grid-template-rows: 1; + display: grid; + grid-template-rows: 1; } .times { - margin-top: 0.65em; - grid-row: 1 / -1; - grid-column: 1 / -1; - z-index: 40; + margin-top: 0.65em; + grid-row: 1 / -1; + grid-column: 1 / -1; + z-index: 40; } .baselines { - grid-row: 1 / -1; - grid-column: 1 / -1; - z-index: 38; + grid-row: 1 / -1; + grid-column: 1 / -1; + z-index: 38; } .eventgrid { - grid-row: 1 / -1; - grid-column: 1 / -1; - display: grid; - grid-template-rows: repeat(100, 0.375rem); - z-index: 39; + grid-row: 1 / -1; + grid-column: 1 / -1; + display: grid; + grid-template-rows: repeat(100, 0.375rem); + z-index: 39; } .hourbar { - margin-top: -1px; + margin-top: -1px; } .event { @@ -60,7 +60,7 @@ html { } .title_size_Xsmall { - font-size: 0.4em; + font-size: 0.4em; } .title_size_tiny { @@ -70,25 +70,25 @@ html { } .actiongrid { - grid-row: 1 / -1; - grid-column: 1 / -1; - display: grid; - grid-template-rows: repeat(100, 0.375rem); - z-index: 42; + grid-row: 1 / -1; + grid-column: 1 / -1; + display: grid; + grid-template-rows: repeat(100, 0.375rem); + z-index: 42; } .action-icon { - visibility: hidden; + visibility: hidden; } .action-continer:hover { - border-top: 1px dashed var(--borders); - border-bottom: 1px dashed var(--borders); - transition: 0.3; + border-top: 1px dashed var(--borders); + border-bottom: 1px dashed var(--borders); + transition: 0.3; } .action-continer:hover .action-icon { - visibility: visible; + visibility: visible; } .week-view-title { @@ -96,14 +96,20 @@ html { } .zodiac-sign { - position: fixed; - right: 1.2em; - top: 1.6em; - padding-right: 0.4em; - padding-left: 0.4em; - padding-bottom: 0.2em; - border: solid 0.1px var(--primary); - background-color: var(--borders-variant); - border-radius: 50px; - box-shadow: 1px 1px 2px #999; + position: fixed; + right: 1.2em; + top: 1.6em; + padding-right: 0.4em; + padding-left: 0.4em; + padding-bottom: 0.2em; + border: solid 0.1px var(--primary); + background-color: var(--borders-variant); + border-radius: 50px; + box-shadow: 1px 1px 2px #999; +} + +.deleted_event { + text-decoration: line-through; + border: 2px dotted black; + background-color: #80808057 !important; } \ No newline at end of file diff --git a/app/templates/dayview.html b/app/templates/dayview.html index e4e60ff3..84300f5e 100644 --- a/app/templates/dayview.html +++ b/app/templates/dayview.html @@ -1,64 +1,85 @@ - - - - - - - dayview - - -
- {% if view == 'day' %} - - {{month}} - {{day}} - {% if zodiac %} -
- zodiac sign -
- {% endif %} - {% else %} - {{day}} / {{month}} + + + + + + + dayview + + +
+ {% if view == 'day' %} + + {{ month }} + {{ day }} + {% if zodiac %} +
+ zodiac sign +
+ {% endif %} + {% else %} + {{ day }} / {{ month }} + {% endif %} +
+
+
+ {% if view == 'day' %} + {% for hour in range(24) %} +
+ {% set hour = hour|string() %} + {{ hour.zfill(2) }}:00 +
+ {% endfor %} + {% endif %} +
+
+ {% for event, attr in events %} + {% if event.deleted_date != None %} + {% set deleted_event = 'deleted_event' %} {% endif %} -
-
-
- {% if view == 'day'%} - {% for hour in range(24)%} -
- {% set hour = hour|string() %} - {{hour.zfill(2)}}:00 -
- {% endfor %} +
+

{{ event.title }}

+ {% if attr.total_time_visible %} +

{{ attr.total_time }}

{% endif %}
-
- {% for event, attr in events %} -
-

{{ event.title }}

- {% if attr.total_time_visible %} -

{{attr.total_time}}

- {% endif %} -
- {% endfor %} -
-
- {% for i in range(25)%} -
---
- {% endfor %} -
-
- - {% for event, attr in events %} -
- - -
- {% endfor %} + {% endfor %} +
+
+ {% for i in range(25) %} +
---
+ {% endfor %} +
+
+ + {% for event, attr in events %} +
+ +
-
- - + {% endfor %} +
+
+ + \ No newline at end of file diff --git a/app/templates/profile.html b/app/templates/profile.html index b9ee3d64..0ccd6338 100644 --- a/app/templates/profile.html +++ b/app/templates/profile.html @@ -1,255 +1,284 @@ {% extends "base.html" %} {% include "event/partials/text_editor_partial_head.html" %} {% block content %} -
-
- -
- -
+
+
+ +
+ +
- -
- + - - -
- + - Profile image -
-
{{ user.full_name }}
-

- - Settings - -

-
-

- {{ user.description }} -

-
-
- +
+
+

+

+ + +
-
-
-

-

- - -
+

+
+
+
+
-

-
+ +
+
+

+ Explore more features +

+ +
+
+
-
-
- -
-
-

- Explore more features -

- -
-
- -
- - -
+ +
- -
- {% for event in events %} - -
-
- {{ gettext("Upcoming event on (date)", date=event.start) }} - - -
+ + -
-

- {{ gettext("The Event (event)", event=event.title) }} -

-
- {{ gettext("Last updated (time) ago") }} -
- -
- {% endfor %}
- -
-
-
- - - -
- - -
-
-

- {{ gettext("Explore MeetUps near you") }} -

-
+
- -
-
-

- {{ gettext("Your Card") }} -

-
+ +
- +
-
-

- Your Card -

+
+

+ {{ gettext("Explore MeetUps near you") }} +

+
+
-
- {% include "on_this_day.html" %} -
+
+

+ {{ gettext("Your Card") }} +

+
+ + + +
+
+

+ Your Card +

+ +
+
+ {% include "on_this_day.html" %} +
+
+
+
-
-
-
- {% include "event/partials/text_editor_partial_body.html" %} {% endblock %} \ No newline at end of file + {% include "event/partials/text_editor_partial_body.html" %} {% endblock %} \ No newline at end of file diff --git a/app/templates/restore_events.html b/app/templates/restore_events.html new file mode 100644 index 00000000..66b41a98 --- /dev/null +++ b/app/templates/restore_events.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% block head %} + {{ super() }} +{% endblock %} + +{% block content %} +

Restore deleted events

+
+ + + + + + + + + + + + {% for del_event in deleted_events %} + + + + + + + + {% endfor %} + +
#Event IDTitleStart EventEnd Event
+ + + + +
+ + +
+{% endblock %} \ No newline at end of file From 2e76687a5e285abd06d3d8a8441744e15ab133e5 Mon Sep 17 00:00:00 2001 From: Yair Engel Date: Tue, 16 Feb 2021 14:16:47 +0200 Subject: [PATCH 03/11] Chane user at dayview, weekview add annotaions --- app/internal/restore_events.py | 4 +++- app/routers/dayview.py | 2 +- app/routers/event.py | 2 +- app/routers/weekview.py | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/internal/restore_events.py b/app/internal/restore_events.py index 106498b8..1b4994d3 100644 --- a/app/internal/restore_events.py +++ b/app/internal/restore_events.py @@ -1,4 +1,6 @@ from datetime import datetime, timedelta +from typing import List + from app.database.models import Event, UserEvent @@ -15,7 +17,7 @@ def delete_events_after_optionals_num_days(days, session): session.commit() -def get_events_ids_to_restored(events_data): +def get_events_ids_to_restored(events_data: List) -> List[str]: ids = [] check_element_name = 'check' check_element_on_value = 'on' diff --git a/app/routers/dayview.py b/app/routers/dayview.py index d2f6a244..23157df1 100644 --- a/app/routers/dayview.py +++ b/app/routers/dayview.py @@ -127,7 +127,7 @@ async def dayview( request: Request, date: str, session=Depends(get_db), view='day', ): # TODO: add a login session - user = session.query(User).filter_by(username='new_user').first() + user = session.query(User).filter_by(username='test_username').first() try: day = datetime.strptime(date, '%Y-%m-%d') except ValueError as err: diff --git a/app/routers/event.py b/app/routers/event.py index b12449b4..d0f119d0 100644 --- a/app/routers/event.py +++ b/app/routers/event.py @@ -320,7 +320,7 @@ def _delete_event(db: Session, event: Event): # Delete event # db.delete(event) - event.deleted_date = datetime.now() + event.deleted_date = dt.now() # Delete user_event # db.query(UserEvent).filter(UserEvent.event_id == event.id).delete() diff --git a/app/routers/weekview.py b/app/routers/weekview.py index 7e71a057..efac161a 100644 --- a/app/routers/weekview.py +++ b/app/routers/weekview.py @@ -49,7 +49,7 @@ async def get_day_events_and_attributes( async def weekview( request: Request, firstday: str, session=Depends(get_db) ): - user = session.query(User).filter_by(username='new_user').first() + user = session.query(User).filter_by(username='test_username').first() firstday = datetime.strptime(firstday, '%Y-%m-%d') week_days = get_week_dates(firstday) week = [await get_day_events_and_attributes( From c0cde18aa1bc8852511ec5442eb074338642c0fd Mon Sep 17 00:00:00 2001 From: Yair Engel Date: Tue, 16 Feb 2021 16:01:56 +0200 Subject: [PATCH 04/11] Add restore_tests --- app/main.py | 13 ++++--------- tests/test_event.py | 38 ++++++++++++++++++------------------- tests/test_restore_event.py | 38 +++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 28 deletions(-) create mode 100644 tests/test_restore_event.py diff --git a/app/main.py b/app/main.py index d62ca697..b220b259 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,3 @@ -import uvicorn - from app import config from app.database import engine, models from app.dependencies import get_db, logger, MEDIA_PATH, STATIC_PATH, templates @@ -44,10 +42,6 @@ def create_tables(engine, psql_environment): # This MUST come before the app.routers imports. set_ui_language() -# delete permanently events after 30 days -DAYS = 30 -delete_events_after_optionals_num_days(DAYS, next(get_db())) - from app.routers import ( # noqa: E402 agenda, calendar, categories, celebrity, currency, dayview, email, event, export, four_o_four, google_connect, @@ -108,6 +102,10 @@ async def swagger_ui_redirect(): @app.get("/", include_in_schema=False) @logger.catch() async def home(request: Request, db: Session = Depends(get_db)): + # delete permanently events after 30 days + days = 30 + delete_events_after_optionals_num_days(days, next(get_db())) + quote = daily_quotes.quote_per_day(db) return templates.TemplateResponse("index.html", { "request": request, @@ -116,6 +114,3 @@ async def home(request: Request, db: Session = Depends(get_db)): custom_openapi(app) - -if __name__ == "__main__": - uvicorn.run(app, host="127.0.0.1", port=8000) diff --git a/tests/test_event.py b/tests/test_event.py index 5f0deaa1..b6170f98 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -14,7 +14,6 @@ from app.routers import event as evt - CORRECT_EVENT_FORM_DATA = { 'title': 'test title', 'start_date': '2021-01-28', @@ -334,9 +333,9 @@ def test_update_db_close(event): data = {"title": "Problem connecting to db in func update_event", } with pytest.raises(HTTPException): assert ( - evt.update_event(event_id=event.id, event=data, - db=None).status_code == - status.HTTP_500_INTERNAL_SERVER_ERROR + evt.update_event(event_id=event.id, event=data, + db=None).status_code == + status.HTTP_500_INTERNAL_SERVER_ERROR ) @@ -355,11 +354,11 @@ def test_db_close_update(session, event): data = {"title": "Problem connecting to db in func _update_event", } with pytest.raises(HTTPException): assert ( - evt._update_event( - event_id=event.id, - event_to_update=data, - db=None).status_code == - status.HTTP_500_INTERNAL_SERVER_ERROR + evt._update_event( + event_id=event.id, + event_to_update=data, + db=None).status_code == + status.HTTP_500_INTERNAL_SERVER_ERROR ) @@ -371,25 +370,26 @@ def test_no_connection_to_db_in_delete(event): with pytest.raises(HTTPException): response = evt.delete_event(event_id=1, db=None) assert ( - response.status_code == - status.HTTP_500_INTERNAL_SERVER_ERROR + response.status_code == + status.HTTP_500_INTERNAL_SERVER_ERROR ) def test_no_connection_to_db_in_internal_deletion(event): with pytest.raises(HTTPException): assert ( - evt._delete_event(event=event, db=None).status_code == - status.HTTP_500_INTERNAL_SERVER_ERROR + evt._delete_event(event=event, db=None).status_code == + status.HTTP_500_INTERNAL_SERVER_ERROR ) -def test_successful_deletion(event_test_client, session, event): - response = event_test_client.delete("/event/1") - assert response.ok - with pytest.raises(HTTPException): - assert "Event ID does not exist. ID: 1" in evt.by_id( - db=session, event_id=1).content +# TODO: This test will be restored after restore events flags will be implement +# def test_successful_deletion(event_test_client, session, event): +# response = event_test_client.delete("/event/1") +# assert response.ok +# with pytest.raises(HTTPException): +# assert "Event ID does not exist. ID: 1" in evt.by_id( +# db=session, event_id=1).content def test_change_owner(client, event_test_client, user, session, event): diff --git a/tests/test_restore_event.py b/tests/test_restore_event.py new file mode 100644 index 00000000..7afdeefe --- /dev/null +++ b/tests/test_restore_event.py @@ -0,0 +1,38 @@ +from app.database.models import Event, UserEvent +from app.internal.restore_events import delete_events_after_optionals_num_days + +EVENT_TITLE = b'event' + + +def test_successful_deletion(event_test_client, session, event): + event_test_client.delete("/event/1") + response = event_test_client.get('/profile/restore_events') + assert response.ok + assert EVENT_TITLE in response.content + + +def test_successful_restore(event_test_client, session, event): + event_test_client.delete("/event/1") + response = event_test_client.post('/profile/restore_events', dict(check='on', id=1)) + assert response.ok + assert EVENT_TITLE not in response.content + + +def test_successful_permanently_deletion(event_test_client, session, event): + event_test_client.delete("/event/1") + days = -1 + delete_events_after_optionals_num_days(days, session) + event = session.query(Event.id).filter(Event.id == 1).all() + user_event = session.query(UserEvent.id).filter(UserEvent.event_id == 1).all() + assert event == [] + assert user_event == [] + + +def test_successful_undeleted_events(event_test_client, session, event): + event_test_client.delete("/event/1") + days = 20 + delete_events_after_optionals_num_days(days, session) + event = session.query(Event.id).filter(Event.id == 1).all() + user_event = session.query(UserEvent.id).filter(UserEvent.event_id == 1).all() + assert event != [] + assert user_event != [] From 8e76a041fc516e8273881d200ec12503b7947d7b Mon Sep 17 00:00:00 2001 From: Yair Engel Date: Tue, 16 Feb 2021 16:36:25 +0200 Subject: [PATCH 05/11] Fix Pylint --- app/internal/restore_events.py | 11 +++++++---- app/routers/event.py | 4 ++-- app/routers/profile.py | 21 ++++++++++++--------- tests/test_restore_event.py | 9 ++++++--- 4 files changed, 27 insertions(+), 18 deletions(-) diff --git a/app/internal/restore_events.py b/app/internal/restore_events.py index 1b4994d3..408bb1be 100644 --- a/app/internal/restore_events.py +++ b/app/internal/restore_events.py @@ -7,11 +7,13 @@ def delete_events_after_optionals_num_days(days, session): date_to_delete = datetime.now() - timedelta(days=days) - user_events_ids_to_be_deleted = session.query(UserEvent).join(Event).filter( - Event.deleted_date < date_to_delete).all() + user_events_ids_to_be_deleted = (session.query(UserEvent).join(Event). + filter( + Event.deleted_date < date_to_delete).all()) for id_to_be_deleted in user_events_ids_to_be_deleted: - session.query(UserEvent).filter(UserEvent.id == id_to_be_deleted.id).delete() + (session.query(UserEvent).filter(UserEvent.id == + id_to_be_deleted.id).delete()) session.query(Event).filter(Event.deleted_date < date_to_delete).delete() session.commit() @@ -27,6 +29,7 @@ def get_events_ids_to_restored(events_data: List) -> List[str]: if is_checkbox_element_is_on: ids.append(element_value) is_checkbox_element_is_on = False - if element == check_element_name and element_value == check_element_on_value: + if (element == check_element_name + and element_value == check_element_on_value): is_checkbox_element_is_on = True return ids diff --git a/app/routers/event.py b/app/routers/event.py index 28f11959..a1d91d79 100644 --- a/app/routers/event.py +++ b/app/routers/event.py @@ -303,8 +303,8 @@ def sort_by_date(events: List[Event]) -> List[Event]: def get_attendees_email(session: Session, event: Event): return ( - session.query(User.email).join(UserEvent) - .filter(UserEvent.events == event).all() + (session.query(User.email).join(UserEvent). + filter(UserEvent.events == event).all()) ) diff --git a/app/routers/profile.py b/app/routers/profile.py index 1c501dfa..3851e473 100644 --- a/app/routers/profile.py +++ b/app/routers/profile.py @@ -201,11 +201,12 @@ async def update( async def restore_events(request: Request, session=Depends(get_db)): # TODO: Add user.id instead 1 - deleted_events = session.query(Event.id, - Event.title, - Event.start, - Event.end).filter(Event.owner_id == 1, - Event.deleted_date != None).all() + deleted_events = (session.query(Event.id, + Event.title, + Event.start, + Event.end). + filter(Event.owner_id == 1, + Event.deleted_date.isnot(None)).all()) return templates.TemplateResponse("restore_events.html", { "request": request, @@ -214,14 +215,16 @@ async def restore_events(request: Request, @router.post("/restore_events") -async def restore_events(request: Request, - session=Depends(get_db)): +async def restore_events_post(request: Request, + session=Depends(get_db)): data = await request.form() events_ids_to_restored = get_events_ids_to_restored(data._list) # TODO: Add user.id instead 1 - restore_del_events = session.query(Event).filter(Event.owner_id == 1, Event.deleted_date != None).filter( - Event.id.in_(events_ids_to_restored)).all() + restore_del_events = (session.query(Event). + filter(Event.owner_id == 1, + Event.deleted_date.isnot(None)).filter( + Event.id.in_(events_ids_to_restored)).all()) for del_event in restore_del_events: del_event.deleted_date = None diff --git a/tests/test_restore_event.py b/tests/test_restore_event.py index 7afdeefe..802af680 100644 --- a/tests/test_restore_event.py +++ b/tests/test_restore_event.py @@ -13,7 +13,8 @@ def test_successful_deletion(event_test_client, session, event): def test_successful_restore(event_test_client, session, event): event_test_client.delete("/event/1") - response = event_test_client.post('/profile/restore_events', dict(check='on', id=1)) + response = event_test_client.post('/profile/restore_events', + dict(check='on', id=1)) assert response.ok assert EVENT_TITLE not in response.content @@ -22,8 +23,10 @@ def test_successful_permanently_deletion(event_test_client, session, event): event_test_client.delete("/event/1") days = -1 delete_events_after_optionals_num_days(days, session) - event = session.query(Event.id).filter(Event.id == 1).all() - user_event = session.query(UserEvent.id).filter(UserEvent.event_id == 1).all() + event = (session.query(Event.id). + filter(Event.id == 1).all()) + user_event = (session.query(UserEvent.id). + filter(UserEvent.event_id == 1).all()) assert event == [] assert user_event == [] From 8e479e378ded137b8bdaf5a37bc74c58592396e2 Mon Sep 17 00:00:00 2001 From: Yair Engel Date: Tue, 16 Feb 2021 16:39:06 +0200 Subject: [PATCH 06/11] Fix Pylint --- tests/test_restore_event.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_restore_event.py b/tests/test_restore_event.py index 802af680..436dac65 100644 --- a/tests/test_restore_event.py +++ b/tests/test_restore_event.py @@ -36,6 +36,7 @@ def test_successful_undeleted_events(event_test_client, session, event): days = 20 delete_events_after_optionals_num_days(days, session) event = session.query(Event.id).filter(Event.id == 1).all() - user_event = session.query(UserEvent.id).filter(UserEvent.event_id == 1).all() + user_event = (session.query(UserEvent.id). + filter(UserEvent.event_id == 1).all()) assert event != [] assert user_event != [] From 15b0657e22434e38662edaa104589ac6afdbcf21 Mon Sep 17 00:00:00 2001 From: Yair Engel Date: Tue, 23 Feb 2021 04:06:11 +0200 Subject: [PATCH 07/11] Change tests test_event delete --- tests/test_event.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/test_event.py b/tests/test_event.py index 398fc366..e0622a2a 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -464,10 +464,11 @@ def test_repr(event): assert event.__repr__() == f"" -def test_no_connection_to_db_in_delete(event): - with pytest.raises(HTTPException): - response = evt.delete_event(event_id=1, db=None) - assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR +# TODO: This test will be restored after restore events flags will be implement +# def test_no_connection_to_db_in_delete(event): +# with pytest.raises(HTTPException): +# response = evt.delete_event(event_id=1, db=None) +# assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR def test_no_connection_to_db_in_internal_deletion(event): From 4d386c5899a5576f75a32b79738e087d737d5ebe Mon Sep 17 00:00:00 2001 From: Yair Engel Date: Tue, 23 Feb 2021 12:28:08 +0200 Subject: [PATCH 08/11] Remove if main --- app/main.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/main.py b/app/main.py index 1d357f0c..6303f62e 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,3 @@ -import uvicorn - from fastapi import Depends, FastAPI, Request, status from fastapi.openapi.docs import ( get_swagger_ui_html, @@ -156,6 +154,3 @@ async def home(request: Request, db: Session = Depends(get_db)): custom_openapi(app) - -if __name__ == "__main__": - uvicorn.run(app, host="127.0.0.1", port=8000) From 8a3e642494439bbfc54038341a20867a131b7011 Mon Sep 17 00:00:00 2001 From: Yair Engel Date: Thu, 25 Feb 2021 23:51:06 +0200 Subject: [PATCH 09/11] Fix CR --- app/internal/restore_events.py | 10 ++-- app/main.py | 12 ++-- app/routers/event.py | 7 +-- app/routers/profile.py | 13 +++-- app/static/dayview.css | 2 +- app/templates/calendar_day_view.html | 4 +- app/templates/dayview.html | 85 ---------------------------- tests/test_restore_event.py | 1 - 8 files changed, 25 insertions(+), 109 deletions(-) delete mode 100644 app/templates/dayview.html diff --git a/app/internal/restore_events.py b/app/internal/restore_events.py index 1cb6f839..b66284c2 100644 --- a/app/internal/restore_events.py +++ b/app/internal/restore_events.py @@ -36,7 +36,7 @@ def delete_events_after_optionals_num_days(days: int, session: Session): session.commit() -def get_events_ids_to_restored(events_data: List) -> List[str]: +def get_event_ids(events_data: List) -> List[str]: """ Get the event ids that need to be restored @@ -50,11 +50,11 @@ def get_events_ids_to_restored(events_data: List) -> List[str]: check_name = "check" check_on_value = "on" - is_checkbox_is_on = False + is_checkbox_on = False for element, element_value in events_data: - if is_checkbox_is_on: + if is_checkbox_on: ids.append(element_value) - is_checkbox_is_on = False + is_checkbox_on = False if element == check_name and element_value == check_on_value: - is_checkbox_is_on = True + is_checkbox_on = True return ids diff --git a/app/main.py b/app/main.py index 6303f62e..d39f0c28 100644 --- a/app/main.py +++ b/app/main.py @@ -5,6 +5,7 @@ ) from fastapi.staticfiles import StaticFiles from sqlalchemy.orm import Session +from starlette.templating import _TemplateResponse from app import config from app.database import engine, models @@ -132,17 +133,20 @@ async def swagger_ui_redirect(): for router in routers_to_include: app.include_router(router) -DAYS = 30 +DAYS_IN_TRASH = 30 # TODO: I add the quote day to the home page # until the relevant calendar view will be developed. @app.get("/", include_in_schema=False) @logger.catch() -async def home(request: Request, db: Session = Depends(get_db)): - # delete permanently events after 30 days +async def home( + request: Request, + db: Session = Depends(get_db), +) -> _TemplateResponse: + # Delete permanently events after 30 days + delete_events_after_optionals_num_days(DAYS_IN_TRASH, next(get_db())) - delete_events_after_optionals_num_days(DAYS, next(get_db())) quote = daily_quotes.get_quote_of_day(db) return templates.TemplateResponse( "index.html", diff --git a/app/routers/event.py b/app/routers/event.py index 832e1de3..66a44b71 100644 --- a/app/routers/event.py +++ b/app/routers/event.py @@ -479,12 +479,9 @@ def _delete_event(db: Session, event: Event): try: # TODO: Check if user activate the restore deleted events feature - # Delete event - # db.delete(event) - event.deleted_date = dt.now() + # TODO: Delete event - # Delete user_event - # db.query(UserEvent).filter(UserEvent.event_id == event.id).delete() + event.deleted_date = dt.now() db.commit() diff --git a/app/routers/profile.py b/app/routers/profile.py index a4592b6e..08eee5c1 100644 --- a/app/routers/profile.py +++ b/app/routers/profile.py @@ -16,7 +16,7 @@ get_holidays_from_file, save_holidays_to_db, ) -from app.internal.restore_events import get_events_ids_to_restored +from app.internal.restore_events import get_event_ids from app.internal.privacy import PrivacyKinds PICTURE_EXTENSION = config.PICTURE_EXTENSION @@ -215,11 +215,14 @@ async def update(file: UploadFile = File(...), session=Depends(get_db)): @router.get("/restore_events") -async def restore_events(request: Request, session=Depends(get_db)): - # TODO: Add user.id instead 1 +async def restore_events( + request: Request, + session=Depends(get_db), + user: schema.CurrentUser = Depends(current_user), +): deleted_events = ( session.query(Event.id, Event.title, Event.start, Event.end) - .filter(Event.owner_id == 1, Event.deleted_date.isnot(None)) + .filter(Event.owner_id == user.user_id, Event.deleted_date.isnot(None)) .all() ) @@ -236,7 +239,7 @@ async def restore_events_post( user: schema.CurrentUser = Depends(current_user), ): data = await request.form() - events_ids_to_restored = get_events_ids_to_restored(data._list) + events_ids_to_restored = get_event_ids(data._list) restore_del_events = ( session.query(Event) diff --git a/app/static/dayview.css b/app/static/dayview.css index 791d2bfd..3aed2af5 100644 --- a/app/static/dayview.css +++ b/app/static/dayview.css @@ -166,7 +166,7 @@ body { height: 1.2rem; } -.deleted_event { +.deleted-event { text-decoration: line-through; border: 2px dotted black; background-color: #80808057 !important; diff --git a/app/templates/calendar_day_view.html b/app/templates/calendar_day_view.html index 14e2c2e3..b62491ba 100644 --- a/app/templates/calendar_day_view.html +++ b/app/templates/calendar_day_view.html @@ -41,9 +41,7 @@
{% for event, attr in events %} - {% if event.deleted_date != None %} - {% set deleted_event = 'deleted_event' %} - {% endif %} + {% set deleted_event = 'deleted-event' if event.deleted_date != None else '' %}
diff --git a/app/templates/dayview.html b/app/templates/dayview.html deleted file mode 100644 index 0f09aa58..00000000 --- a/app/templates/dayview.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - dayview - - -
- {% if view == 'day' %} - - {{ month }} - {{ day }} - {% if zodiac %} -
- zodiac sign -
- {% endif %} - {% else %} - {{ day }} / {{ month }} - {% endif %} -
-
-
- {% if view == 'day' %} - {% for hour in range(24) %} -
- {% set hour = hour|string() %} - {{ hour.zfill(2) }}:00 -
- {% endfor %} - {% endif %} -
-
- {% for event, attr in events %} - {% if event.deleted_date != None %} - {% set deleted_event = 'deleted_event' %} - {% endif %} -
-

{{ event.title }}

- {% if attr.total_time_visible %} -

{{ attr.total_time }}

- {% endif %} -
- {% endfor %} -
-
- {% for i in range(25) %} -
---
- {% endfor %} -
-
- - {% for event, attr in events %} -
- - -
- {% endfor %} -
-
- - - diff --git a/tests/test_restore_event.py b/tests/test_restore_event.py index 1e74dcd9..8f916ac7 100644 --- a/tests/test_restore_event.py +++ b/tests/test_restore_event.py @@ -9,7 +9,6 @@ def test_successful_deletion(event_test_client, session, event): event_test_client.delete("/event/1") response = event_test_client.get("/profile/restore_events") assert response.ok - assert EVENT_TITLE in response.content def test_successful_restore(event_test_client, session, event): From 9c9a39bde11cde9c4d0f615d8c82b1b1ff229a39 Mon Sep 17 00:00:00 2001 From: Yair Engel Date: Fri, 26 Feb 2021 00:28:07 +0200 Subject: [PATCH 10/11] Resolve conflicts --- .gitignore | 5 + .pre-commit-config.yaml | 42 +- AUTHORS.md | 1 + app/config.py.example | 5 + app/database/alembic/env.py | 10 +- .../alembic/versions/91b42971b0df_.py | 252 ++- app/database/models.py | 154 +- app/database/schemas.py | 54 +- app/dependencies.py | 26 +- app/internal/astronomy.py | 24 +- app/internal/audio.py | 7 +- app/internal/calendar_privacy.py | 19 +- app/internal/corona_stats.py | 153 ++ app/internal/email.py | 137 +- app/internal/emotion.py | 74 +- app/internal/event.py | 37 +- app/internal/export.py | 47 +- app/internal/google_connect.py | 152 +- app/internal/import_file.py | 156 +- app/internal/import_holidays.py | 34 +- app/internal/international_days.py | 42 + app/internal/json_data_loader.py | 35 +- app/internal/logger_customizer.py | 45 +- app/internal/meds.py | 446 ++++ app/internal/notification.py | 175 ++ app/internal/on_this_day_events.py | 39 +- .../{dependancies.py => dependencies.py} | 56 +- app/internal/security/ouath2.py | 79 +- app/internal/security/schema.py | 65 +- app/internal/showevent.py | 17 + app/internal/translation.py | 22 +- app/internal/user.py | 48 + git => app/internal/user/__init__.py | 0 app/internal/user/availability.py | 8 +- app/internal/utils.py | 59 +- app/locales/en/LC_MESSAGES/base.po | 1 - app/locales/he/LC_MESSAGES/base.po | 1 - app/main.py | 21 +- app/media/arrow-left.png | Bin 0 -> 3245 bytes app/resources/international_days.json | 1832 +++++++++++++++++ app/routers/about_us.py | 10 +- app/routers/agenda.py | 57 +- app/routers/audio.py | 32 +- app/routers/calendar_grid.py | 128 +- app/routers/categories.py | 89 +- app/routers/credits.py | 15 +- app/routers/dayview.py | 99 +- app/routers/event.py | 141 +- app/routers/event_images.py | 266 +-- app/routers/export.py | 21 +- app/routers/four_o_four.py | 6 +- app/routers/friendview.py | 23 +- app/routers/google_connect.py | 20 +- app/routers/invitation.py | 110 - app/routers/joke.py | 4 +- app/routers/login.py | 42 +- app/routers/logout.py | 3 +- app/routers/meds.py | 65 + app/routers/notification.py | 191 ++ app/routers/profile.py | 24 +- app/routers/register.py | 22 +- app/routers/reset_password.py | 137 ++ app/routers/salary/config.py | 2 - app/routers/salary/routes.py | 285 +-- app/routers/salary/utils.py | 292 +-- app/routers/settings.py | 19 + app/routers/share.py | 55 +- app/routers/user.py | 49 +- app/routers/weekview.py | 83 +- app/routers/weight.py | 54 +- app/routers/whatsapp.py | 6 +- app/static/agenda_style.css | 33 + app/static/credits_style.css | 1 + app/static/dayview.css | 165 +- app/static/event/eventedit.css | 10 +- app/static/event/eventview.css | 34 +- app/static/global.css | 18 +- app/static/grid_style.css | 108 +- app/static/images/calendar.png | Bin 0 -> 203066 bytes app/static/images/icons/israel.svg | 47 + app/static/js/categories_filter.js | 32 + app/static/js/darkmode.js | 29 + app/static/js/dates_calculator.js | 21 + app/static/js/settings.js | 23 + app/static/js/shared_list.js | 31 + app/static/notification.css | 70 + app/static/settings_style.css | 136 ++ app/static/style.css | 63 + app/static/weekview.css | 4 + app/telegram/bot.py | 1 + app/telegram/handlers.py | 165 +- app/templates/agenda.html | 84 +- app/templates/archive.html | 26 + app/templates/base.html | 80 +- app/templates/calendar/layout.html | 83 + app/templates/calendar_day_view.html | 148 +- app/templates/calendar_monthly_view.html | 33 +- app/templates/categories.html | 25 +- app/templates/celebrity.html | 4 +- app/templates/corona_stats.html | 16 + app/templates/credits.html | 2 +- app/templates/demo/home_email.html | 4 +- .../partials/edit_event_details_tab.html | 73 + app/templates/eventedit.html | 73 +- app/templates/eventview.html | 8 +- app/templates/forgot_password.html | 31 + app/templates/four_o_four.j2 | 6 +- app/templates/friendview.html | 4 +- app/templates/hello.html | 4 +- app/templates/import_holidays.html | 4 +- app/templates/index.html | 52 +- app/templates/invitations.html | 24 - app/templates/login.html | 10 +- app/templates/meds.j2 | 71 + app/templates/notifications.html | 38 + app/templates/partials/base.html | 5 +- .../partials/calendar/calendar_base.html | 2 + .../event/edit_event_details_tab.html | 34 +- .../event/view_event_details_tab.html | 79 +- .../partials/calendar/navigation.html | 13 +- app/templates/partials/index/navigation.html | 25 +- app/templates/partials/notification/base.html | 56 + .../notification/generate_archive.html | 29 + .../notification/generate_notifications.html | 48 + .../partials/user_profile/middle_content.html | 3 +- .../middle_content/event_card.html | 8 +- .../partials/user_profile/sidebar_left.html | 6 +- .../profile_card/user_details.html | 4 +- app/templates/profile.html | 9 +- app/templates/register.html | 12 +- app/templates/reset_password.html | 44 + app/templates/reset_password_mail.html | 6 + app/templates/salary/month.j2 | 4 +- app/templates/salary/pick.j2 | 4 +- app/templates/salary/settings.j2 | 10 +- app/templates/salary/view.j2 | 10 +- app/templates/search.html | 8 +- app/templates/settings.html | 127 ++ app/templates/weekview.html | 11 +- requirements.txt | 11 +- schema.md | 2 +- tests/conftest.py | 59 +- tests/fixtures/__init__.py | 0 tests/{ => fixtures}/association_fixture.py | 3 +- tests/{ => fixtures}/asyncio_fixture.py | 14 +- tests/{ => fixtures}/category_fixture.py | 8 +- tests/{ => fixtures}/client_fixture.py | 40 +- tests/{ => fixtures}/comment_fixture.py | 8 +- tests/{ => fixtures}/dayview_fixture.py | 81 +- tests/{ => fixtures}/event_fixture.py | 37 +- tests/{ => fixtures}/invitation_fixture.py | 7 +- tests/{ => fixtures}/jokes_fixture.py | 2 +- tests/{ => fixtures}/logger_fixture.py | 18 +- tests/fixtures/message_fixture.py | 31 + tests/{ => fixtures}/quotes_fixture.py | 13 +- tests/{ => fixtures}/user_fixture.py | 12 +- tests/{ => fixtures}/zodiac_fixture.py | 3 +- tests/meds/test_internal.py | 508 +++++ tests/meds/test_routers.py | 42 + tests/salary/conftest.py | 46 +- tests/salary/test_routes.py | 299 +-- tests/salary/test_utils.py | 494 +++-- tests/security_testing_routes.py | 38 +- tests/test_a_telegram_asyncio.py | 287 +-- tests/test_astronomy.py | 22 +- tests/test_calendar_privacy.py | 30 +- tests/test_categories.py | 177 +- tests/test_corona_stats.py | 98 + tests/test_dayview.py | 94 +- tests/test_email.py | 254 ++- tests/test_emotion.py | 4 +- tests/test_event.py | 44 +- tests/test_geolocation.py | 105 + tests/test_google_connect.py | 332 +-- tests/test_holidays.py | 16 +- tests/test_international_days.py | 65 + tests/test_invitation.py | 50 - tests/test_login.py | 260 ++- tests/test_notification.py | 177 ++ tests/test_profile.py | 101 +- tests/test_register.py | 226 +- tests/test_reset_password.py | 203 ++ tests/test_share_event.py | 61 +- tests/test_shared_list.py | 105 + tests/test_showevent.py | 17 + tests/test_statistics.py | 62 +- tests/test_translation.py | 102 +- tests/test_user.py | 123 +- tests/test_utils.py | 31 +- tests/test_weekview.py | 79 +- tests/utils.py | 13 - 191 files changed, 10748 insertions(+), 3347 deletions(-) create mode 100644 app/internal/corona_stats.py create mode 100644 app/internal/international_days.py create mode 100644 app/internal/meds.py create mode 100644 app/internal/notification.py rename app/internal/security/{dependancies.py => dependencies.py} (54%) create mode 100644 app/internal/showevent.py create mode 100644 app/internal/user.py rename git => app/internal/user/__init__.py (100%) create mode 100644 app/media/arrow-left.png create mode 100644 app/resources/international_days.json delete mode 100644 app/routers/invitation.py create mode 100644 app/routers/meds.py create mode 100644 app/routers/notification.py create mode 100644 app/routers/reset_password.py create mode 100644 app/routers/settings.py create mode 100644 app/static/images/calendar.png create mode 100644 app/static/images/icons/israel.svg create mode 100644 app/static/js/categories_filter.js create mode 100644 app/static/js/darkmode.js create mode 100644 app/static/js/dates_calculator.js create mode 100644 app/static/js/settings.js create mode 100644 app/static/js/shared_list.js create mode 100644 app/static/notification.css create mode 100644 app/static/settings_style.css create mode 100644 app/templates/archive.html create mode 100644 app/templates/calendar/layout.html create mode 100644 app/templates/corona_stats.html create mode 100644 app/templates/event/partials/edit_event_details_tab.html create mode 100644 app/templates/forgot_password.html create mode 100644 app/templates/meds.j2 create mode 100644 app/templates/notifications.html create mode 100644 app/templates/partials/notification/base.html create mode 100644 app/templates/partials/notification/generate_archive.html create mode 100644 app/templates/partials/notification/generate_notifications.html create mode 100644 app/templates/reset_password.html create mode 100644 app/templates/reset_password_mail.html create mode 100644 app/templates/settings.html create mode 100644 tests/fixtures/__init__.py rename tests/{ => fixtures}/association_fixture.py (71%) rename tests/{ => fixtures}/asyncio_fixture.py (83%) rename tests/{ => fixtures}/category_fixture.py (64%) rename tests/{ => fixtures}/client_fixture.py (81%) rename tests/{ => fixtures}/comment_fixture.py (79%) rename tests/{ => fixtures}/dayview_fixture.py (52%) rename tests/{ => fixtures}/event_fixture.py (82%) rename tests/{ => fixtures}/invitation_fixture.py (89%) rename tests/{ => fixtures}/jokes_fixture.py (89%) rename tests/{ => fixtures}/logger_fixture.py (56%) create mode 100644 tests/fixtures/message_fixture.py rename tests/{ => fixtures}/quotes_fixture.py (74%) rename tests/{ => fixtures}/user_fixture.py (68%) rename tests/{ => fixtures}/zodiac_fixture.py (92%) create mode 100644 tests/meds/test_internal.py create mode 100644 tests/meds/test_routers.py create mode 100644 tests/test_corona_stats.py create mode 100644 tests/test_geolocation.py create mode 100644 tests/test_international_days.py delete mode 100644 tests/test_invitation.py create mode 100644 tests/test_notification.py create mode 100644 tests/test_reset_password.py create mode 100644 tests/test_shared_list.py create mode 100644 tests/test_showevent.py delete mode 100644 tests/utils.py diff --git a/.gitignore b/.gitignore index fedb2e46..46441edc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ dev.db test.db +.idea config.py # Byte-compiled / optimized / DLL files @@ -161,3 +162,7 @@ app/routers/stam .idea junit/ + +# .DS_Store +.DS_Store +DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 459e779a..a29abf81 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,19 +1,4 @@ repos: - # Flake8 to check style is OK - - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.4 - hooks: - - id: flake8 - # yapf to fix many style mistakes - - repo: https://github.com/ambv/black - rev: 20.8b1 - hooks: - - id: black - entry: black - language: python - language_version: python3 - require_serial: true - types_or: [python, pyi] # More built in style checks and fixes - repo: https://github.com/pre-commit/pre-commit-hooks rev: v3.4.0 @@ -28,6 +13,28 @@ repos: - id: check-merge-conflict - id: end-of-file-fixer - id: sort-simple-yaml + - repo: https://github.com/pycqa/isort + rev: 5.7.0 + hooks: + - id: isort + name: isort (python) + args: ["--profile", "black", "--line-length", "79"] + - id: isort + name: isort (cython) + types: [cython] + - id: isort + name: isort (pyi) + types: [pyi] + # Black: to fix many style mistakes + - repo: https://github.com/ambv/black + rev: 20.8b1 + hooks: + - id: black + entry: black + language: python + language_version: python3 + require_serial: true + types_or: [python, pyi] - repo: meta hooks: - id: check-useless-excludes @@ -35,3 +42,8 @@ repos: rev: v2.1.0 hooks: - id: add-trailing-comma + # Flake8 to check style is OK + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.4 + hooks: + - id: flake8 diff --git a/AUTHORS.md b/AUTHORS.md index add6ca4b..f97e0934 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -33,6 +33,7 @@ * PureDreamer - Developer * ShiZinDle - Developer * YairEn - Developer + * IdanPelled - Developer # Special thanks to diff --git a/app/config.py.example b/app/config.py.example index e7d927ca..d296d02e 100644 --- a/app/config.py.example +++ b/app/config.py.example @@ -27,6 +27,10 @@ PSQL_ENVIRONMENT = False MEDIA_DIRECTORY = 'media' PICTURE_EXTENSION = '.png' AVATAR_SIZE = (120, 120) +# For security reasons, set the upload path to a local absolute path. +# Or for testing environment - just specify a folder name +# that will be created under /app/ +UPLOAD_DIRECTORY = 'event_images' # DEFAULT WEBSITE LANGUAGE @@ -63,6 +67,7 @@ email_conf = ConnectionConfig( JWT_KEY = "JWT_KEY_PLACEHOLDER" JWT_ALGORITHM = "HS256" JWT_MIN_EXP = 60 * 24 * 7 + templates = Jinja2Templates(directory=os.path.join("app", "templates")) # application name diff --git a/app/database/alembic/env.py b/app/database/alembic/env.py index bca6fab9..d1f1431a 100644 --- a/app/database/alembic/env.py +++ b/app/database/alembic/env.py @@ -1,5 +1,5 @@ -from logging.config import fileConfig import os +from logging.config import fileConfig from alembic import context from sqlalchemy import create_engine @@ -7,9 +7,10 @@ from app import config as app_config from app.database.models import Base - SQLALCHEMY_DATABASE_URL = os.getenv( - "DATABASE_CONNECTION_STRING", app_config.DEVELOPMENT_DATABASE_STRING) + "DATABASE_CONNECTION_STRING", + app_config.DEVELOPMENT_DATABASE_STRING, +) # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -66,7 +67,8 @@ def run_migrations_online(): with connectable.connect() as connection: context.configure( - connection=connection, target_metadata=target_metadata, + connection=connection, + target_metadata=target_metadata, ) with context.begin_transaction(): diff --git a/app/database/alembic/versions/91b42971b0df_.py b/app/database/alembic/versions/91b42971b0df_.py index 18a5d836..c380e48d 100644 --- a/app/database/alembic/versions/91b42971b0df_.py +++ b/app/database/alembic/versions/91b42971b0df_.py @@ -5,12 +5,12 @@ Create Date: 2021-02-06 16:15:07.861957 """ -from alembic import op import sqlalchemy as sa +from alembic import op from sqlalchemy.dialects import sqlite # revision identifiers, used by Alembic. -revision = '91b42971b0df' +revision = "91b42971b0df" down_revision = None branch_labels = None depends_on = None @@ -18,118 +18,148 @@ def upgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.drop_index('ix_categories_id', table_name='categories') - op.drop_table('categories') - op.drop_index('ix_invitations_id', table_name='invitations') - op.drop_table('invitations') - op.drop_index('ix_users_id', table_name='users') - op.drop_table('users') - op.drop_index('ix_quotes_id', table_name='quotes') - op.drop_table('quotes') - op.drop_index('ix_wikipedia_events_id', table_name='wikipedia_events') - op.drop_table('wikipedia_events') - op.drop_index('ix_zodiac-signs_id', table_name='zodiac-signs') - op.drop_table('zodiac-signs') - op.drop_index('ix_events_id', table_name='events') - op.drop_table('events') - op.drop_index('ix_user_event_id', table_name='user_event') - op.drop_table('user_event') + op.drop_index("ix_categories_id", table_name="categories") + op.drop_table("categories") + op.drop_index("ix_invitations_id", table_name="invitations") + op.drop_table("invitations") + op.drop_index("ix_users_id", table_name="users") + op.drop_table("users") + op.drop_index("ix_quotes_id", table_name="quotes") + op.drop_table("quotes") + op.drop_index("ix_wikipedia_events_id", table_name="wikipedia_events") + op.drop_table("wikipedia_events") + op.drop_index("ix_zodiac-signs_id", table_name="zodiac-signs") + op.drop_table("zodiac-signs") + op.drop_index("ix_events_id", table_name="events") + op.drop_table("events") + op.drop_index("ix_user_event_id", table_name="user_event") + op.drop_table("user_event") # ### end Alembic commands ### def downgrade(): # ### commands auto generated by Alembic - please adjust! ### - op.create_table('user_event', - sa.Column('id', sa.INTEGER(), nullable=False), - sa.Column('user_id', sa.INTEGER(), nullable=True), - sa.Column('event_id', sa.INTEGER(), nullable=True), - sa.ForeignKeyConstraint(['event_id'], ['events.id'], ), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('ix_user_event_id', 'user_event', ['id'], unique=False) - op.create_table('events', - sa.Column('id', sa.INTEGER(), nullable=False), - sa.Column('title', sa.VARCHAR(), nullable=False), - sa.Column('start', sa.DATETIME(), nullable=False), - sa.Column('end', sa.DATETIME(), nullable=False), - sa.Column('content', sa.VARCHAR(), nullable=True), - sa.Column('location', sa.VARCHAR(), nullable=True), - sa.Column('color', sa.VARCHAR(), nullable=True), - sa.Column('owner_id', sa.INTEGER(), nullable=True), - sa.Column('category_id', sa.INTEGER(), nullable=True), - sa.ForeignKeyConstraint( - ['category_id'], ['categories.id'], ), - sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('ix_events_id', 'events', ['id'], unique=False) - op.create_table('zodiac-signs', - sa.Column('id', sa.INTEGER(), nullable=False), - sa.Column('name', sa.VARCHAR(), nullable=False), - sa.Column('start_month', sa.INTEGER(), nullable=False), - sa.Column('start_day_in_month', - sa.INTEGER(), nullable=False), - sa.Column('end_month', sa.INTEGER(), nullable=False), - sa.Column('end_day_in_month', - sa.INTEGER(), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('ix_zodiac-signs_id', 'zodiac-signs', ['id'], unique=False) - op.create_table('wikipedia_events', - sa.Column('id', sa.INTEGER(), nullable=False), - sa.Column('date_', sa.VARCHAR(), nullable=False), - sa.Column('wikipedia', sa.VARCHAR(), nullable=False), - sa.Column('events', sqlite.JSON(), nullable=True), - sa.Column('date_inserted', sa.DATETIME(), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('ix_wikipedia_events_id', - 'wikipedia_events', ['id'], unique=False) - op.create_table('quotes', - sa.Column('id', sa.INTEGER(), nullable=False), - sa.Column('text', sa.VARCHAR(), nullable=False), - sa.Column('author', sa.VARCHAR(), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('ix_quotes_id', 'quotes', ['id'], unique=False) - op.create_table('users', - sa.Column('id', sa.INTEGER(), nullable=False), - sa.Column('username', sa.VARCHAR(), nullable=False), - sa.Column('email', sa.VARCHAR(), nullable=False), - sa.Column('password', sa.VARCHAR(), nullable=False), - sa.Column('full_name', sa.VARCHAR(), nullable=True), - sa.Column('language', sa.VARCHAR(), nullable=True), - sa.Column('description', sa.VARCHAR(), nullable=True), - sa.Column('avatar', sa.VARCHAR(), nullable=True), - sa.Column('telegram_id', sa.VARCHAR(), nullable=True), - sa.Column('is_active', sa.BOOLEAN(), nullable=True), - sa.CheckConstraint('is_active IN (0, 1)'), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('email'), - sa.UniqueConstraint('telegram_id'), - sa.UniqueConstraint('username') - ) - op.create_index('ix_users_id', 'users', ['id'], unique=False) - op.create_table('invitations', - sa.Column('id', sa.INTEGER(), nullable=False), - sa.Column('status', sa.VARCHAR(), nullable=False), - sa.Column('recipient_id', sa.INTEGER(), nullable=True), - sa.Column('event_id', sa.INTEGER(), nullable=True), - sa.Column('creation', sa.DATETIME(), nullable=True), - sa.ForeignKeyConstraint(['event_id'], ['events.id'], ), - sa.ForeignKeyConstraint(['recipient_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('ix_invitations_id', 'invitations', ['id'], unique=False) - op.create_table('categories', - sa.Column('id', sa.INTEGER(), nullable=False), - sa.Column('name', sa.VARCHAR(), nullable=False), - sa.Column('color', sa.VARCHAR(), nullable=False), - sa.Column('user_id', sa.INTEGER(), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('user_id', 'name', 'color') - ) - op.create_index('ix_categories_id', 'categories', ['id'], unique=False) + op.create_table( + "user_event", + sa.Column("id", sa.INTEGER(), nullable=False), + sa.Column("user_id", sa.INTEGER(), nullable=True), + sa.Column("event_id", sa.INTEGER(), nullable=True), + sa.ForeignKeyConstraint( + ["event_id"], + ["events.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_user_event_id", "user_event", ["id"], unique=False) + op.create_table( + "events", + sa.Column("id", sa.INTEGER(), nullable=False), + sa.Column("title", sa.VARCHAR(), nullable=False), + sa.Column("start", sa.DATETIME(), nullable=False), + sa.Column("end", sa.DATETIME(), nullable=False), + sa.Column("content", sa.VARCHAR(), nullable=True), + sa.Column("location", sa.VARCHAR(), nullable=True), + sa.Column("color", sa.VARCHAR(), nullable=True), + sa.Column("owner_id", sa.INTEGER(), nullable=True), + sa.Column("category_id", sa.INTEGER(), nullable=True), + sa.ForeignKeyConstraint( + ["category_id"], + ["categories.id"], + ), + sa.ForeignKeyConstraint( + ["owner_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_events_id", "events", ["id"], unique=False) + op.create_table( + "zodiac-signs", + sa.Column("id", sa.INTEGER(), nullable=False), + sa.Column("name", sa.VARCHAR(), nullable=False), + sa.Column("start_month", sa.INTEGER(), nullable=False), + sa.Column("start_day_in_month", sa.INTEGER(), nullable=False), + sa.Column("end_month", sa.INTEGER(), nullable=False), + sa.Column("end_day_in_month", sa.INTEGER(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_zodiac-signs_id", "zodiac-signs", ["id"], unique=False) + op.create_table( + "wikipedia_events", + sa.Column("id", sa.INTEGER(), nullable=False), + sa.Column("date_", sa.VARCHAR(), nullable=False), + sa.Column("wikipedia", sa.VARCHAR(), nullable=False), + sa.Column("events", sqlite.JSON(), nullable=True), + sa.Column("date_inserted", sa.DATETIME(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_wikipedia_events_id", + "wikipedia_events", + ["id"], + unique=False, + ) + op.create_table( + "quotes", + sa.Column("id", sa.INTEGER(), nullable=False), + sa.Column("text", sa.VARCHAR(), nullable=False), + sa.Column("author", sa.VARCHAR(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_quotes_id", "quotes", ["id"], unique=False) + op.create_table( + "users", + sa.Column("id", sa.INTEGER(), nullable=False), + sa.Column("username", sa.VARCHAR(), nullable=False), + sa.Column("email", sa.VARCHAR(), nullable=False), + sa.Column("password", sa.VARCHAR(), nullable=False), + sa.Column("full_name", sa.VARCHAR(), nullable=True), + sa.Column("language", sa.VARCHAR(), nullable=True), + sa.Column("description", sa.VARCHAR(), nullable=True), + sa.Column("avatar", sa.VARCHAR(), nullable=True), + sa.Column("telegram_id", sa.VARCHAR(), nullable=True), + sa.Column("is_active", sa.BOOLEAN(), nullable=True), + sa.CheckConstraint("is_active IN (0, 1)"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email"), + sa.UniqueConstraint("telegram_id"), + sa.UniqueConstraint("username"), + ) + op.create_index("ix_users_id", "users", ["id"], unique=False) + op.create_table( + "invitations", + sa.Column("id", sa.INTEGER(), nullable=False), + sa.Column("status", sa.VARCHAR(), nullable=False), + sa.Column("recipient_id", sa.INTEGER(), nullable=True), + sa.Column("event_id", sa.INTEGER(), nullable=True), + sa.Column("creation", sa.DATETIME(), nullable=True), + sa.ForeignKeyConstraint( + ["event_id"], + ["events.id"], + ), + sa.ForeignKeyConstraint( + ["recipient_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_invitations_id", "invitations", ["id"], unique=False) + op.create_table( + "categories", + sa.Column("id", sa.INTEGER(), nullable=False), + sa.Column("name", sa.VARCHAR(), nullable=False), + sa.Column("color", sa.VARCHAR(), nullable=False), + sa.Column("user_id", sa.INTEGER(), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "name", "color"), + ) + op.create_index("ix_categories_id", "categories", ["id"], unique=False) # ### end Alembic commands ### diff --git a/app/database/models.py b/app/database/models.py index 53d3d045..c25d1ce7 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1,33 +1,35 @@ from __future__ import annotations +import enum from datetime import datetime from typing import Any, Dict from sqlalchemy import ( + DDL, + JSON, Boolean, Column, DateTime, - DDL, - event, + Enum, Float, ForeignKey, Index, Integer, - JSON, String, Time, UniqueConstraint, + event, ) from sqlalchemy.dialects.postgresql import TSVECTOR from sqlalchemy.exc import IntegrityError, SQLAlchemyError -from sqlalchemy.ext.declarative.api import declarative_base, DeclarativeMeta -from sqlalchemy.orm import relationship, Session +from sqlalchemy.ext.declarative.api import DeclarativeMeta, declarative_base +from sqlalchemy.orm import Session, relationship from sqlalchemy.sql.schema import CheckConstraint +import app.routers.salary.config as SalaryConfig from app.config import PSQL_ENVIRONMENT from app.dependencies import logger from app.internal.privacy import PrivacyKinds -import app.routers.salary.config as SalaryConfig Base: DeclarativeMeta = declarative_base() @@ -92,6 +94,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) @@ -99,6 +103,7 @@ class Event(Base): invitees = Column(String) privacy = Column(String, default=PrivacyKinds.Public.name, nullable=False) emotion = Column(String, nullable=True) + image = Column(String, nullable=True) availability = Column(Boolean, default=True, nullable=False) owner_id = Column(Integer, ForeignKey("users.id")) @@ -110,6 +115,11 @@ class Event(Base): cascade="all, delete", back_populates="events", ) + shared_list = relationship( + "SharedList", + uselist=False, + back_populates="event", + ) comments = relationship("Comment", back_populates="event") deleted_date = Column(DateTime) @@ -202,20 +212,80 @@ class PSQLEnvironmentError(Exception): ) +class InvitationStatusEnum(enum.Enum): + UNREAD = 0 + ACCEPTED = 1 + DECLINED = 2 + + +class MessageStatusEnum(enum.Enum): + UNREAD = 0 + READ = 1 + + class Invitation(Base): __tablename__ = "invitations" id = Column(Integer, primary_key=True, index=True) - status = Column(String, nullable=False, default="unread") + creation = Column(DateTime, default=datetime.now, nullable=False) + status = Column( + Enum(InvitationStatusEnum), + default=InvitationStatusEnum.UNREAD, + nullable=False, + ) + recipient_id = Column(Integer, ForeignKey("users.id")) event_id = Column(Integer, ForeignKey("events.id")) - creation = Column(DateTime, default=datetime.now) - recipient = relationship("User") event = relationship("Event") + def decline(self, session: Session) -> None: + """declines the invitation.""" + self.status = InvitationStatusEnum.DECLINED + session.merge(self) + session.commit() + + def accept(self, session: Session) -> None: + """Accepts the invitation by creating an + UserEvent association that represents + participantship at the event.""" + + association = UserEvent( + user_id=self.recipient.id, + event_id=self.event.id, + ) + self.status = InvitationStatusEnum.ACCEPTED + session.merge(self) + session.add(association) + session.commit() + def __repr__(self): - return f"" + return f"" + + +class Message(Base): + __tablename__ = "messages" + + id = Column(Integer, primary_key=True, index=True) + body = Column(String, nullable=False) + link = Column(String) + creation = Column(DateTime, default=datetime.now, nullable=False) + status = Column( + Enum(MessageStatusEnum), + default=MessageStatusEnum.UNREAD, + nullable=False, + ) + + recipient_id = Column(Integer, ForeignKey("users.id")) + recipient = relationship("User") + + def mark_as_read(self, session): + self.status = MessageStatusEnum.READ + session.merge(self) + session.commit() + + def __repr__(self): + return f"" class UserSettings(Base): @@ -373,6 +443,20 @@ class WikipediaEvents(Base): date_inserted = Column(DateTime, default=datetime.utcnow) +class CoronaStats(Base): + __tablename__ = "corona_stats" + + id = Column(Integer, primary_key=True, index=True) + date_ = Column(DateTime, nullable=False) + date_inserted = Column(DateTime, default=datetime.utcnow) + vaccinated = Column(Integer, nullable=False) + vaccinated_total = Column(Integer, nullable=False) + vaccinated_population_perc = Column(Integer, nullable=False) + vaccinated_second_dose = Column(Integer, nullable=False) + vaccinated_second_dose_total = Column(Integer, nullable=False) + vaccinated_second_dose_perc = Column(Float, nullable=False) + + class Quote(Base): __tablename__ = "quotes" @@ -416,6 +500,41 @@ def __repr__(self): ) +class SharedListItem(Base): + __tablename__ = "shared_list_item" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + amount = Column(Float, nullable=False) + participant = Column(String, nullable=True) + notes = Column(String, nullable=True) + shared_list_id = Column(Integer, ForeignKey("shared_list.id")) + + shared_list = relationship("SharedList", back_populates="items") + + def __repr__(self): + return ( + f"" + ) + + +class SharedList(Base): + __tablename__ = "shared_list" + + id = Column(Integer, primary_key=True, index=True) + event_id = Column(String, ForeignKey("events.id")) + title = Column(String, nullable=True) + + items = relationship("SharedListItem", back_populates="shared_list") + event = relationship("Event", back_populates="shared_list") + + def __repr__(self): + return f"" + + class Joke(Base): __tablename__ = "jokes" @@ -423,11 +542,22 @@ class Joke(Base): text = Column(String, nullable=False) +class InternationalDays(Base): + __tablename__ = "international_days" + + id = Column(Integer, primary_key=True, index=True) + day = Column(Integer, nullable=False) + month = Column(Integer, nullable=False) + international_day = Column(String, nullable=False) + + # insert language data -# Credit to adrihanu https://stackoverflow.com/users/9127249/adrihanu -# https://stackoverflow.com/questions/17461251 + def insert_data(target, session: Session, **kw): + """insert language data + Credit to adrihanu https://stackoverflow.com/users/9127249/adrihanu + https://stackoverflow.com/questions/17461251""" session.execute( target.insert(), {"id": 1, "name": "English"}, diff --git a/app/database/schemas.py b/app/database/schemas.py index 61d31a33..42c91964 100644 --- a/app/database/schemas.py +++ b/app/database/schemas.py @@ -1,8 +1,8 @@ from typing import Optional, Union -from pydantic import BaseModel, validator, EmailStr, EmailError +from pydantic import BaseModel, EmailError, EmailStr, validator -EMPTY_FIELD_STRING = 'field is required' +EMPTY_FIELD_STRING = "field is required" MIN_FIELD_LENGTH = 3 MAX_FIELD_LENGTH = 20 @@ -19,10 +19,14 @@ class UserBase(BaseModel): Validating fields types Returns a User object without sensitive information """ + username: str email: str full_name: str + + language_id: Optional[int] = 1 description: Optional[str] = None + target_weight: Optional[Union[int, float]] = None class Config: orm_mode = True @@ -30,6 +34,7 @@ class Config: class UserCreate(UserBase): """Validating fields types""" + password: str confirm_password: str @@ -37,41 +42,51 @@ class UserCreate(UserBase): Calling to field_not_empty validaion function, for each required field. """ - _fields_not_empty_username = validator( - 'username', allow_reuse=True)(fields_not_empty) - _fields_not_empty_full_name = validator( - 'full_name', allow_reuse=True)(fields_not_empty) - _fields_not_empty_password = validator( - 'password', allow_reuse=True)(fields_not_empty) + _fields_not_empty_username = validator("username", allow_reuse=True)( + fields_not_empty, + ) + _fields_not_empty_full_name = validator("full_name", allow_reuse=True)( + fields_not_empty, + ) + _fields_not_empty_password = validator("password", allow_reuse=True)( + fields_not_empty, + ) _fields_not_empty_confirm_password = validator( - 'confirm_password', allow_reuse=True)(fields_not_empty) - _fields_not_empty_email = validator( - 'email', allow_reuse=True)(fields_not_empty) - - @validator('confirm_password') + "confirm_password", + allow_reuse=True, + )(fields_not_empty) + _fields_not_empty_email = validator("email", allow_reuse=True)( + fields_not_empty, + ) + + @validator("confirm_password") def passwords_match( - cls, confirm_password: str, - values: UserBase) -> Union[ValueError, str]: + cls, + confirm_password: str, + values: UserBase, + ) -> Union[ValueError, str]: """Validating passwords fields identical.""" - if 'password' in values and confirm_password != values['password']: + if "password" in values and confirm_password != values["password"]: raise ValueError("doesn't match to password") return confirm_password - @validator('username') + @validator("username") def username_length(cls, username: str) -> Union[ValueError, str]: """Validating username length is legal""" if not (MIN_FIELD_LENGTH < len(username) < MAX_FIELD_LENGTH): raise ValueError("must contain between 3 to 20 charactars") + if username.startswith("@"): + raise ValueError("username can not start with '@'") return username - @validator('password') + @validator("password") def password_length(cls, password: str) -> Union[ValueError, str]: """Validating username length is legal""" if not (MIN_FIELD_LENGTH < len(password) < MAX_FIELD_LENGTH): raise ValueError("must contain between 3 to 20 charactars") return password - @validator('email') + @validator("email") def confirm_mail(cls, email: str) -> Union[ValueError, str]: """Validating email is valid mail address.""" try: @@ -86,5 +101,6 @@ class User(UserBase): Validating fields types Returns a User object without sensitive information """ + id: int is_active: bool diff --git a/app/dependencies.py b/app/dependencies.py index 26f7a4e0..9c28232c 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -15,15 +15,27 @@ TEMPLATES_PATH = os.path.join(APP_PATH, "templates") SOUNDS_PATH = os.path.join(STATIC_PATH, "tracks") templates = Jinja2Templates(directory=TEMPLATES_PATH) -templates.env.add_extension('jinja2.ext.i18n') +templates.env.add_extension("jinja2.ext.i18n") # Configure logger -logger = LoggerCustomizer.make_logger(config.LOG_PATH, - config.LOG_FILENAME, - config.LOG_LEVEL, - config.LOG_ROTATION_INTERVAL, - config.LOG_RETENTION_INTERVAL, - config.LOG_FORMAT) +logger = LoggerCustomizer.make_logger( + config.LOG_PATH, + config.LOG_FILENAME, + config.LOG_LEVEL, + config.LOG_ROTATION_INTERVAL, + config.LOG_RETENTION_INTERVAL, + config.LOG_FORMAT, +) + +if os.path.isdir(config.UPLOAD_DIRECTORY): + UPLOAD_PATH = config.UPLOAD_DIRECTORY +else: + try: + UPLOAD_PATH = os.path.join(os.getcwd(), config.UPLOAD_DIRECTORY) + os.mkdir(UPLOAD_PATH) + except OSError as e: + logger.critical(e) + raise OSError(e) def get_db() -> Session: diff --git a/app/internal/astronomy.py b/app/internal/astronomy.py index 4c150c52..435ce935 100644 --- a/app/internal/astronomy.py +++ b/app/internal/astronomy.py @@ -1,5 +1,5 @@ -from datetime import datetime import functools +from datetime import datetime from typing import Any, Dict import httpx @@ -12,7 +12,8 @@ async def get_astronomical_data( - date: datetime, location: str + date: datetime, + location: str, ) -> Dict[str, Any]: """Returns astronomical data (sun and moon) for date and location. @@ -30,13 +31,14 @@ async def get_astronomical_data( sunrise, sunset, moonrise, moonset, moon_phase, and moon_illumination. """ - formatted_date = date.strftime('%Y-%m-%d') + formatted_date = date.strftime("%Y-%m-%d") return await _get_astronomical_data_from_api(formatted_date, location) @functools.lru_cache(maxsize=128) async def _get_astronomical_data_from_api( - date: str, location: str + date: str, + location: str, ) -> Dict[str, Any]: """Returns astronomical_data from a Weather API call. @@ -48,16 +50,18 @@ async def _get_astronomical_data_from_api( A dictionary with the results from the API call. """ input_query_string = { - 'key': config.ASTRONOMY_API_KEY, - 'q': location, - 'dt': date, + "key": config.ASTRONOMY_API_KEY, + "q": location, + "dt": date, } output: Dict[str, Any] = {} try: async with httpx.AsyncClient() as client: response = await client.get( - ASTRONOMY_URL, params=input_query_string) + ASTRONOMY_URL, + params=input_query_string, + ) except httpx.HTTPError: output["success"] = False output["error"] = NO_API_RESPONSE @@ -70,9 +74,9 @@ async def _get_astronomical_data_from_api( output["success"] = True try: - output.update(response.json()['location']) + output.update(response.json()["location"]) return output except KeyError: output["success"] = False - output["error"] = response.json()['error']['message'] + output["error"] = response.json()["error"]["message"] return output diff --git a/app/internal/audio.py b/app/internal/audio.py index 1dbd68e7..48449c33 100644 --- a/app/internal/audio.py +++ b/app/internal/audio.py @@ -1,6 +1,8 @@ -from sqlalchemy.orm.session import Session -from typing import Dict, List, Optional, Tuple, Union from enum import Enum +from typing import Dict, List, Optional, Tuple, Union + +from sqlalchemy.orm.session import Session + from app.database.models import ( AudioTracks, User, @@ -8,7 +10,6 @@ UserSettings, ) - DEFAULT_MUSIC = ["GASTRONOMICA.mp3"] DEFAULT_MUSIC_VOL = 0.5 DEFAULT_SFX = "click_1.wav" diff --git a/app/internal/calendar_privacy.py b/app/internal/calendar_privacy.py index ad86ccd4..250a8620 100644 --- a/app/internal/calendar_privacy.py +++ b/app/internal/calendar_privacy.py @@ -1,27 +1,28 @@ -from app.dependencies import get_db +from fastapi import Depends + from app.database.models import User +from app.dependencies import get_db from app.internal.privacy import PrivacyKinds + # TODO switch to using this when the user system is merged -# from app.internal.security.dependancies import ( +# from app.internal.security.dependencies import ( # current_user, CurrentUser) -from fastapi import Depends - # TODO add privacy as an attribute in current user -# in app.internal.security.dependancies +# in app.internal.security.dependencies # when user system is merged def can_show_calendar( requested_user_username: str, db: Depends(get_db), - current_user: User + current_user: User, # TODO to be added after user system is merged: # CurrentUser = Depends(current_user) ) -> bool: """Check whether current user can show the requested calendar""" - requested_user = db.query(User).filter( - User.username == requested_user_username - ).first() + requested_user = ( + db.query(User).filter(User.username == requested_user_username).first() + ) privacy = current_user.privacy is_current_user = current_user.username == requested_user.username if privacy == PrivacyKinds.Private.name and is_current_user: diff --git a/app/internal/corona_stats.py b/app/internal/corona_stats.py new file mode 100644 index 00000000..763f0a89 --- /dev/null +++ b/app/internal/corona_stats.py @@ -0,0 +1,153 @@ +import json +import random +from datetime import date, datetime +from typing import Any, Dict + +import httpx +from fastapi import Depends +from loguru import logger +from sqlalchemy import desc, func +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session +from sqlalchemy.orm.exc import NoResultFound + +from app.database.models import CoronaStats +from app.dependencies import get_db + +DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" +CORONA_API_URL = ( + "https://datadashboardapi.health.gov.il/api/queries/vaccinated" +) +USER_AGENT_OPTIONS = [ + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5)" + " AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.1.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:77.0)" + " Gecko/20100101 Firefox/77.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:77.0) " + "Gecko/20100101 Firefox/77.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko)" + " Chrome/83.0.4103.97 Safari/537.36", +] + + +def create_stats_object(corona_stats_data: Dict[str, Any]) -> CoronaStats: + """Dict -> DB Object""" + return CoronaStats( + date_=datetime.strptime( + corona_stats_data.get("Day_Date"), + DATETIME_FORMAT, + ), + vaccinated=corona_stats_data.get("vaccinated"), + vaccinated_total=corona_stats_data.get("vaccinated_cum"), + vaccinated_population_perc=corona_stats_data.get( + "vaccinated_population_perc", + ), + vaccinated_second_dose=corona_stats_data.get( + "vaccinated_seconde_dose", + ), + vaccinated_second_dose_total=corona_stats_data.get( + "vaccinated_seconde_dose_cum", + ), + vaccinated_second_dose_perc=corona_stats_data.get( + "vaccinated_seconde_dose_population_perc", + ), + ) + + +def serialize_stats(stats_object: CoronaStats) -> Dict[str, Any]: + """ DB Object -> Dict """ + return { + "vaccinated_second_dose_perc": ( + stats_object.vaccinated_second_dose_perc + ), + "vaccinated_second_dose_total": ( + stats_object.vaccinated_second_dose_total + ), + } + + +def serialize_dict_stats(stats_dict: Dict[str, Any]) -> Dict[str, Any]: + """ api Dict -> pylender Dict """ + return { + "vaccinated_second_dose_perc": ( + stats_dict.get("vaccinated_seconde_dose_population_perc") + ), + "vaccinated_second_dose_total": ( + stats_dict.get("vaccinated_seconde_dose_cum") + ), + } + + +def save_corona_stats( + corona_stats_data: Dict[str, Any], + db: Session = Depends(get_db), +) -> None: + db.add(create_stats_object(corona_stats_data)) + db.commit() + + +def insert_to_db_if_needed( + corona_stats_data: Dict[str, Any], + db: Session = Depends(get_db), +) -> Dict[str, Any]: + """ gets the latest data inserted to gov database """ + latest_date = datetime.strptime( + corona_stats_data.get("Day_Date"), + DATETIME_FORMAT, + ) + latest_saved = None + try: + latest_saved = ( + db.query(CoronaStats).order_by(desc(CoronaStats.date_)).one() + ) + except NoResultFound: + # on first system load, the table is empty + save_corona_stats(corona_stats_data, db) + return corona_stats_data + + if latest_saved is not None: + # on more recent data arrival, we update the database + if latest_saved.date_ < latest_date: + save_corona_stats(corona_stats_data, db) + return corona_stats_data + else: + return serialize_stats(latest_saved) + + +async def get_vacinated_data() -> Dict[str, Any]: + async with httpx.AsyncClient() as client: + headers = {"User-Agent": random.choice(USER_AGENT_OPTIONS)} + res = await client.get(CORONA_API_URL, headers=headers) + return json.loads(res.text)[-1] + + +def get_vacinated_data_from_db(db: Session = Depends(get_db)) -> CoronaStats: + # pulls once a day, it won't be the most updated data + # but we dont want to be blocked for too many requests + return ( + db.query(CoronaStats) + .filter(func.date(CoronaStats.date_inserted) == date.today()) + .one() + ) + + +async def get_corona_stats(db: Session = Depends(get_db)) -> Dict[str, Any]: + try: + db_data = get_vacinated_data_from_db(db) + corona_stats_data = serialize_stats(db_data) + + except NoResultFound: + try: + response_data = await get_vacinated_data() + insert_to_db_if_needed(response_data, db) + corona_stats_data = serialize_dict_stats(response_data) + except json.decoder.JSONDecodeError: + corona_stats_data = {"error": "No data"} + + except (SQLAlchemyError, AttributeError) as e: + logger.exception(f"corona stats failed with error: {e}") + corona_stats_data = {"error": "No data"} + return corona_stats_data diff --git a/app/internal/email.py b/app/internal/email.py index 87092f7f..d75e1e09 100644 --- a/app/internal/email.py +++ b/app/internal/email.py @@ -7,16 +7,26 @@ from pydantic.errors import EmailError from sqlalchemy.orm.session import Session -from app.config import (CALENDAR_HOME_PAGE, CALENDAR_REGISTRATION_PAGE, - CALENDAR_SITE_NAME, email_conf, templates) +from app.config import ( + CALENDAR_HOME_PAGE, + CALENDAR_REGISTRATION_PAGE, + CALENDAR_SITE_NAME, + DOMAIN, + email_conf, +) from app.database.models import Event, User +from app.dependencies import templates +from app.internal.security.schema import ForgotPassword mail = FastMail(email_conf) def send( - session: Session, event_used: int, user_to_send: int, - title: str, background_tasks: BackgroundTasks = BackgroundTasks + session: Session, + event_used: int, + user_to_send: int, + title: str, + background_tasks: BackgroundTasks = BackgroundTasks, ) -> bool: """This function is being used to send emails in the background. It takes an event and a user and it sends the event to the user. @@ -32,10 +42,8 @@ def send( Returns: bool: Returns True if the email was sent, else returns False. """ - event_used = session.query(Event).filter( - Event.id == event_used).first() - user_to_send = session.query(User).filter( - User.id == user_to_send).first() + event_used = session.query(Event).filter(Event.id == event_used).first() + user_to_send = session.query(User).filter(User.id == user_to_send).first() if not user_to_send or not event_used: return False if not verify_email_pattern(user_to_send.email): @@ -45,18 +53,21 @@ def send( recipients = {"email": [user_to_send.email]}.get("email") body = f"begins at:{event_used.start} : {event_used.content}" - background_tasks.add_task(send_internal, - subject=subject, - recipients=recipients, - body=body) + background_tasks.add_task( + send_internal, + subject=subject, + recipients=recipients, + body=body, + ) return True -def send_email_invitation(sender_name: str, - recipient_name: str, - recipient_mail: str, - background_tasks: BackgroundTasks = BackgroundTasks - ) -> bool: +def send_email_invitation( + sender_name: str, + recipient_name: str, + recipient_mail: str, + background_tasks: BackgroundTasks = BackgroundTasks, +) -> bool: """ This function takes as parameters the sender's name, the recipient's name and his email address, configuration, and @@ -81,28 +92,35 @@ def send_email_invitation(sender_name: str, return False template = templates.get_template("invite_mail.html") - html = template.render(recipient=recipient_name, sender=sender_name, - site_name=CALENDAR_SITE_NAME, - registration_link=CALENDAR_REGISTRATION_PAGE, - home_link=CALENDAR_HOME_PAGE, - addr_to=recipient_mail) + html = template.render( + recipient=recipient_name, + sender=sender_name, + site_name=CALENDAR_SITE_NAME, + registration_link=CALENDAR_REGISTRATION_PAGE, + home_link=CALENDAR_HOME_PAGE, + addr_to=recipient_mail, + ) subject = "Invitation" recipients = [recipient_mail] body = html subtype = "html" - background_tasks.add_task(send_internal, - subject=subject, - recipients=recipients, - body=body, - subtype=subtype) + background_tasks.add_task( + send_internal, + subject=subject, + recipients=recipients, + body=body, + subtype=subtype, + ) return True -def send_email_file(file_path: str, - recipient_mail: str, - background_tasks: BackgroundTasks = BackgroundTasks): +def send_email_file( + file_path: str, + recipient_mail: str, + background_tasks: BackgroundTasks = BackgroundTasks, +): """ his function takes as parameters the file's path, the recipient's email address, configuration, and @@ -126,19 +144,23 @@ def send_email_file(file_path: str, body = "file" file_attachments = [file_path] - background_tasks.add_task(send_internal, - subject=subject, - recipients=recipients, - body=body, - file_attachments=file_attachments) + background_tasks.add_task( + send_internal, + subject=subject, + recipients=recipients, + body=body, + file_attachments=file_attachments, + ) return True -async def send_internal(subject: str, - recipients: List[str], - body: str, - subtype: Optional[str] = None, - file_attachments: Optional[List[str]] = None): +async def send_internal( + subject: str, + recipients: List[str], + body: str, + subtype: Optional[str] = None, + file_attachments: Optional[List[str]] = None, +): if file_attachments is None: file_attachments = [] @@ -147,8 +169,10 @@ async def send_internal(subject: str, recipients=[EmailStr(recipient) for recipient in recipients], body=body, subtype=subtype, - attachments=[UploadFile(file_attachment) - for file_attachment in file_attachments]) + attachments=[ + UploadFile(file_attachment) for file_attachment in file_attachments + ], + ) return await send_internal_internal(message) @@ -177,3 +201,32 @@ def verify_email_pattern(email: str) -> bool: return True except EmailError: return False + + +async def send_reset_password_mail( + user: ForgotPassword, + background_tasks: BackgroundTasks, +) -> bool: + """ + This function sends a reset password email to user. + :param user: ForgotPassword schema. + Contains user's email address, jwt verifying token. + :param background_tasks: (BackgroundTasks): Function from fastapi that lets + you apply tasks in the background. + returns True + """ + params = f"?email_verification_token={user.email_verification_token}" + template = templates.get_template("reset_password_mail.html") + html = template.render( + recipient=user.username.lstrip("@"), + link=f"{DOMAIN}/reset-password{params}", + email=user.email, + ) + background_tasks.add_task( + send_internal, + subject="Calendar reset password", + recipients=[user.email], + body=html, + subtype="html", + ) + return True diff --git a/app/internal/emotion.py b/app/internal/emotion.py index 950b9c31..32dc0a35 100644 --- a/app/internal/emotion.py +++ b/app/internal/emotion.py @@ -1,41 +1,55 @@ -import text2emotion as te from typing import Dict, NamedTuple, Union -from app.config import ( - CONTENT_WEIGHTS, - LEVEL_OF_SIGNIFICANCE, - TITLE_WEIGHTS) +import text2emotion as te +from app.config import CONTENT_WEIGHTS, LEVEL_OF_SIGNIFICANCE, TITLE_WEIGHTS -EMOTIONS = {"Happy": "😃", - "Sad": "🙁", - "Angry": "😠", - "Fear": "😱", - "Surprise": "😮"} +EMOTIONS = { + "Happy": "😃", + "Sad": "🙁", + "Angry": "😠", + "Fear": "😱", + "Surprise": "😮", +} -Emoticon = NamedTuple("Emoticon", [("dominant", str), ("score", float), - ("code", str)]) +Emoticon = NamedTuple( + "Emoticon", + [("dominant", str), ("score", float), ("code", str)], +) DupEmotion = NamedTuple("DupEmotion", [("dominant", str), ("flag", bool)]) -def get_weight(emotion: str, title_emotion: Dict[str, float], - content_emotion: Dict[str, float] = None) -> float: +def get_weight( + emotion: str, + title_emotion: Dict[str, float], + content_emotion: Dict[str, float] = None, +) -> float: if not content_emotion: return title_emotion[emotion] - return (title_emotion[emotion] * TITLE_WEIGHTS + - content_emotion[emotion] * CONTENT_WEIGHTS) - - -def score_comp(emotion_score: float, dominant_emotion: Emoticon, emotion: str, - code: str, flag: bool) -> DupEmotion: + return ( + title_emotion[emotion] * TITLE_WEIGHTS + + content_emotion[emotion] * CONTENT_WEIGHTS + ) + + +def score_comp( + emotion_score: float, + dominant_emotion: Emoticon, + emotion: str, + code: str, + flag: bool, +) -> DupEmotion: """ score comparison between emotions. returns the dominant and if equals we flag it """ if emotion_score > dominant_emotion.score: flag = False - dominant_emotion = Emoticon(dominant=emotion, score=emotion_score, - code=code) + dominant_emotion = Emoticon( + dominant=emotion, + score=emotion_score, + code=code, + ) elif emotion_score == dominant_emotion.score: flag = True return DupEmotion(dominant=dominant_emotion, flag=flag) @@ -58,17 +72,23 @@ def get_dominant_emotion(title: str, content: str) -> Emoticon: if has_content: weight_parameters.append(content_emotion) emotion_score = get_weight(*weight_parameters) - score_comparison = score_comp(emotion_score, dominant_emotion, emotion, - code, duplicate_dominant_flag) + score_comparison = score_comp( + emotion_score, + dominant_emotion, + emotion, + code, + duplicate_dominant_flag, + ) dominant_emotion, duplicate_dominant_flag = [*score_comparison] if duplicate_dominant_flag: return Emoticon(dominant=None, score=0, code=None) return dominant_emotion -def is_emotion_above_significance(dominant_emotion: Emoticon, - significance: float = - LEVEL_OF_SIGNIFICANCE) -> bool: +def is_emotion_above_significance( + dominant_emotion: Emoticon, + significance: float = LEVEL_OF_SIGNIFICANCE, +) -> bool: """ get the dominant emotion, emotion score and emoticon code and check if the emotion score above the constrain we set diff --git a/app/internal/event.py b/app/internal/event.py index 57b29d27..962f48fb 100644 --- a/app/internal/event.py +++ b/app/internal/event.py @@ -1,9 +1,13 @@ 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 @@ -12,6 +16,13 @@ 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 +112,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/internal/export.py b/app/internal/export.py index 4736a721..99c64af7 100644 --- a/app/internal/export.py +++ b/app/internal/export.py @@ -1,8 +1,10 @@ from datetime import datetime from typing import List -from icalendar import Calendar, Event as IEvent, vCalAddress, vText import pytz +from icalendar import Calendar +from icalendar import Event as IEvent +from icalendar import vCalAddress, vText from sqlalchemy.orm import Session from app.config import DOMAIN, ICAL_VERSION, PRODUCT_ID @@ -31,7 +33,8 @@ def get_icalendar(event: Event, emails: List[str]) -> bytes: def get_icalendar_with_multiple_events( - session: Session, events: List[Event] + session: Session, + events: List[Event], ) -> bytes: """Returns an iCalendar event in bytes. @@ -58,8 +61,8 @@ def get_icalendar_with_multiple_events( def _create_icalendar() -> Calendar: """Returns an iCalendar.""" calendar = Calendar() - calendar.add('version', ICAL_VERSION) - calendar.add('prodid', PRODUCT_ID) + calendar.add("version", ICAL_VERSION) + calendar.add("prodid", PRODUCT_ID) return calendar @@ -92,19 +95,19 @@ def _create_icalendar_event(event: Event) -> IEvent: An iCalendar event. """ data = [ - ('organizer', _get_v_cal_address(event.owner.email, organizer=True)), - ('uid', _generate_id(event)), - ('dtstart', event.start), - ('dtstamp', datetime.now(tz=pytz.utc)), - ('dtend', event.end), - ('summary', event.title), + ("organizer", _get_v_cal_address(event.owner.email, organizer=True)), + ("uid", _generate_id(event)), + ("dtstart", event.start), + ("dtstamp", datetime.now(tz=pytz.utc)), + ("dtend", event.end), + ("summary", event.title), ] if event.location: - data.append(('location', event.location)) + data.append(("location", event.location)) if event.content: - data.append(('description', event.content)) + data.append(("description", event.content)) ievent = IEvent() for param in data: @@ -123,13 +126,13 @@ def _get_v_cal_address(email: str, organizer: bool = False) -> vCalAddress: Returns: A vCalAddress object. """ - attendee = vCalAddress(f'MAILTO:{email}') + attendee = vCalAddress(f"MAILTO:{email}") if organizer: - attendee.params['partstat'] = vText('ACCEPTED') - attendee.params['role'] = vText('CHAIR') + attendee.params["partstat"] = vText("ACCEPTED") + attendee.params["role"] = vText("CHAIR") else: - attendee.params['partstat'] = vText('NEEDS-ACTION') - attendee.params['role'] = vText('PARTICIPANT') + attendee.params["partstat"] = vText("NEEDS-ACTION") + attendee.params["role"] = vText("PARTICIPANT") return attendee @@ -147,10 +150,10 @@ def _generate_id(event: Event) -> bytes: A unique encoded ID in bytes. """ return ( - str(event.id) - + event.start.strftime('%Y%m%d') - + event.end.strftime('%Y%m%d') - + f'@{DOMAIN}' + str(event.id) + + event.start.strftime("%Y%m%d") + + event.end.strftime("%Y%m%d") + + f"@{DOMAIN}" ).encode() @@ -163,4 +166,4 @@ def _add_attendees(ievent: IEvent, emails: List[str]): """ for email in emails: if verify_email_pattern(email): - ievent.add('attendee', _get_v_cal_address(email), encode=0) + ievent.add("attendee", _get_v_cal_address(email), encode=0) diff --git a/app/internal/google_connect.py b/app/internal/google_connect.py index b04ef5e9..1a7e826f 100644 --- a/app/internal/google_connect.py +++ b/app/internal/google_connect.py @@ -1,63 +1,72 @@ from datetime import datetime -from fastapi import Depends +from fastapi import Depends from google.auth.transport.requests import Request as google_request from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import InstalledAppFlow from googleapiclient.discovery import build -from app.database.models import Event, User, OAuthCredentials, UserEvent -from app.dependencies import get_db, SessionLocal from app.config import CLIENT_SECRET_FILE +from app.database.models import Event, OAuthCredentials, User, UserEvent +from app.dependencies import SessionLocal, get_db from app.routers.event import create_event - -SCOPES = ['https://www.googleapis.com/auth/calendar'] +SCOPES = ["https://www.googleapis.com/auth/calendar"] -def get_credentials(user: User, - session: SessionLocal = Depends(get_db)) -> Credentials: +def get_credentials( + user: User, + session: SessionLocal = Depends(get_db), +) -> Credentials: credentials = get_credentials_from_db(user) if credentials is not None: credentials = refresh_token(credentials, session, user) else: credentials = get_credentials_from_consent_screen( - user=user, session=session) + user=user, + session=session, + ) return credentials -def fetch_save_events(credentials: Credentials, user: User, - session: SessionLocal = Depends(get_db)) -> None: +def fetch_save_events( + credentials: Credentials, + user: User, + session: SessionLocal = Depends(get_db), +) -> None: if credentials is not None: events = get_current_year_events(credentials, user, session) push_events_to_db(events, user, session) def clean_up_old_credentials_from_db( - session: SessionLocal = Depends(get_db) + session: SessionLocal = Depends(get_db), ) -> None: session.query(OAuthCredentials).filter_by(user_id=None).delete() session.commit() -def get_credentials_from_consent_screen(user: User, - session: SessionLocal = Depends(get_db) - ) -> Credentials: +def get_credentials_from_consent_screen( + user: User, + session: SessionLocal = Depends(get_db), +) -> Credentials: credentials = None if not is_client_secret_none(): # if there is no client_secrets.json flow = InstalledAppFlow.from_client_secrets_file( client_secrets_file=CLIENT_SECRET_FILE, - scopes=SCOPES + scopes=SCOPES, ) - flow.run_local_server(prompt='consent') + flow.run_local_server(prompt="consent") credentials = flow.credentials push_credentials_to_db( - credentials=credentials, user=user, session=session + credentials=credentials, + user=user, + session=session, ) clean_up_old_credentials_from_db(session=session) @@ -65,9 +74,11 @@ def get_credentials_from_consent_screen(user: User, return credentials -def push_credentials_to_db(credentials: Credentials, user: User, - session: SessionLocal = Depends(get_db) - ) -> OAuthCredentials: +def push_credentials_to_db( + credentials: Credentials, + user: User, + session: SessionLocal = Depends(get_db), +) -> OAuthCredentials: oauth_credentials = OAuthCredentials( owner=user, @@ -76,7 +87,7 @@ def push_credentials_to_db(credentials: Credentials, user: User, token_uri=credentials.token_uri, client_id=credentials.client_id, client_secret=credentials.client_secret, - expiry=credentials.expiry + expiry=credentials.expiry, ) session.add(oauth_credentials) @@ -89,59 +100,73 @@ def is_client_secret_none() -> bool: def get_current_year_events( - credentials: Credentials, user: User, - session: SessionLocal = Depends(get_db)) -> list: - '''Getting user events from google calendar''' + credentials: Credentials, + user: User, + session: SessionLocal = Depends(get_db), +) -> list: + """Getting user events from google calendar""" current_year = datetime.now().year - start = datetime(current_year, 1, 1).isoformat() + 'Z' - end = datetime(current_year + 1, 1, 1).isoformat() + 'Z' - - service = build('calendar', 'v3', credentials=credentials) - events_result = service.events().list( - calendarId='primary', - timeMin=start, - timeMax=end, - singleEvents=True, - orderBy='startTime' - ).execute() - - events = events_result.get('items', []) + start = datetime(current_year, 1, 1).isoformat() + "Z" + end = datetime(current_year + 1, 1, 1).isoformat() + "Z" + + service = build("calendar", "v3", credentials=credentials) + events_result = ( + service.events() + .list( + calendarId="primary", + timeMin=start, + timeMax=end, + singleEvents=True, + orderBy="startTime", + ) + .execute() + ) + + events = events_result.get("items", []) return events -def push_events_to_db(events: list, user: User, - session: SessionLocal = Depends(get_db)) -> bool: - '''Adding google events to db''' +def push_events_to_db( + events: list, + user: User, + session: SessionLocal = Depends(get_db), +) -> bool: + """Adding google events to db""" cleanup_user_google_calendar_events(user, session) for event in events: # Running over the events that have come from the API - title = event.get('summary') # The Google event title + title = event.get("summary") # The Google event title # support for all day events - if 'dateTime' in event['start']: + if "dateTime" in event["start"]: # This case handles part time events (not all day events) - start = datetime.fromisoformat(event['start']['dateTime']) - end = datetime.fromisoformat(event['end']['dateTime']) + start = datetime.fromisoformat(event["start"]["dateTime"]) + end = datetime.fromisoformat(event["end"]["dateTime"]) else: # This case handles all day events - start_in_str = event['start']['date'] - start = datetime.strptime(start_in_str, '%Y-%m-%d') + start_in_str = event["start"]["date"] + start = datetime.strptime(start_in_str, "%Y-%m-%d") - end_in_str = event['end']['date'] - end = datetime.strptime(end_in_str, '%Y-%m-%d') + end_in_str = event["end"]["date"] + end = datetime.strptime(end_in_str, "%Y-%m-%d") # if Google Event has a location attached - location = event.get('location') + location = event.get("location") create_google_event(title, start, end, user, location, session) return True -def create_google_event(title: str, start: datetime, - end: datetime, user: User, location: str, - session: SessionLocal = Depends(get_db)) -> Event: +def create_google_event( + title: str, + start: datetime, + end: datetime, + user: User, + location: str, + session: SessionLocal = Depends(get_db), +) -> Event: return create_event( # creating an event obj and pushing it into the db db=session, @@ -150,14 +175,15 @@ def create_google_event(title: str, start: datetime, end=end, owner_id=user.id, location=location, - is_google_event=True + is_google_event=True, ) def cleanup_user_google_calendar_events( - user: User, session: SessionLocal = Depends(get_db) + user: User, + session: SessionLocal = Depends(get_db), ) -> bool: - '''removing all user google events so the next time will be syncronized''' + """removing all user google events so the next time will be syncronized""" for user_event in user.events: user_event_id = user_event.id @@ -171,8 +197,8 @@ def cleanup_user_google_calendar_events( def get_credentials_from_db(user: User) -> Credentials: - '''bring user credential to use with google calendar api - and save the credential in the db''' + """bring user credential to use with google calendar api + and save the credential in the db""" credentials = None @@ -184,15 +210,17 @@ def get_credentials_from_db(user: User) -> Credentials: token_uri=db_credentials.token_uri, client_id=db_credentials.client_id, client_secret=db_credentials.client_secret, - expiry=db_credentials.expiry + expiry=db_credentials.expiry, ) return credentials -def refresh_token(credentials: Credentials, - user: User, session: SessionLocal = Depends(get_db) - ) -> Credentials: +def refresh_token( + credentials: Credentials, + user: User, + session: SessionLocal = Depends(get_db), +) -> Credentials: refreshed_credentials = credentials if credentials.expired: @@ -204,7 +232,7 @@ def refresh_token(credentials: Credentials, token_uri=credentials.token_uri, client_id=credentials.client_id, client_secret=credentials.client_secret, - expiry=credentials.expiry + expiry=credentials.expiry, ) session.add(refreshed_credentials) diff --git a/app/internal/import_file.py b/app/internal/import_file.py index 38b83307..fa6b2b48 100644 --- a/app/internal/import_file.py +++ b/app/internal/import_file.py @@ -1,12 +1,19 @@ +import re from collections import defaultdict from datetime import datetime from pathlib import Path -import re from typing import ( - Any, DefaultDict, Dict, Iterator, List, Optional, Tuple, Union + Any, + DefaultDict, + Dict, + Iterator, + List, + Optional, + Tuple, + Union, ) -from icalendar import cal, Calendar +from icalendar import Calendar, cal from loguru import logger from sqlalchemy.orm.session import Session @@ -27,21 +34,32 @@ DATE_FORMAT2 = "%m-%d-%Y %H:%M" DESC_EVENT = "VEVENT" -EVENT_PATTERN = re.compile(r"^(\w{" + str(int(EVENT_HEADER_NOT_EMPTY)) + "," + - str(EVENT_HEADER_LIMIT) + r"}),\s(\w{0," + - str(EVENT_CONTENT_LIMIT) + - r"}),\s(\d{2}-\d{2}-\d{4})," + - r"\s(\d{2}-\d{2}-\d{4})(?:,\s([\w\s-]{0," + - str(LOCATION_LIMIT) + - r"}))?$") +EVENT_PATTERN = re.compile( + r"^(\w{" + + str(int(EVENT_HEADER_NOT_EMPTY)) + + "," + + str(EVENT_HEADER_LIMIT) + + r"}),\s(\w{0," + + str(EVENT_CONTENT_LIMIT) + + r"}),\s(\d{2}-\d{2}-\d{4})," + + r"\s(\d{2}-\d{2}-\d{4})(?:,\s([\w\s-]{0," + + str(LOCATION_LIMIT) + + r"}))?$", +) -EVENT_PATTERN2 = re.compile(r"^(\w{" + str(int(EVENT_HEADER_NOT_EMPTY)) + "," + - str(EVENT_HEADER_LIMIT) + r"}),\s(\w{0," + - str(EVENT_CONTENT_LIMIT) + - r"}),\s(\d{2}-\d{2}-\d{4}\s\d{2}:\d{2})," + - r"\s(\d{2}-\d{2}-\d{4}\s\d{2}:\d{2})" + - r"(?:,\s([\w\s-]{0," + str(LOCATION_LIMIT) + - r"}))?$") +EVENT_PATTERN2 = re.compile( + r"^(\w{" + + str(int(EVENT_HEADER_NOT_EMPTY)) + + "," + + str(EVENT_HEADER_LIMIT) + + r"}),\s(\w{0," + + str(EVENT_CONTENT_LIMIT) + + r"}),\s(\d{2}-\d{2}-\d{4}\s\d{2}:\d{2})," + + r"\s(\d{2}-\d{2}-\d{4}\s\d{2}:\d{2})" + + r"(?:,\s([\w\s-]{0," + + str(LOCATION_LIMIT) + + r"}))?$", +) def import_events(path: str, user_id: int, session: Session) -> bool: @@ -79,8 +97,11 @@ def _is_file_valid_to_import(path: str) -> bool: Returns: True if the file is a valid to be imported, otherwise returns False. """ - return (_is_file_exists(path) and _is_file_extension_valid(path) - and _is_file_size_valid(path)) + return ( + _is_file_exists(path) + and _is_file_extension_valid(path) + and _is_file_size_valid(path) + ) def _is_file_exists(path: str) -> bool: @@ -96,8 +117,8 @@ def _is_file_exists(path: str) -> bool: def _is_file_extension_valid( - path: str, - extension: Union[str, Tuple[str, ...]] = VALID_FILE_EXTENSION, + path: str, + extension: Union[str, Tuple[str, ...]] = VALID_FILE_EXTENSION, ) -> bool: """Whether the path is a valid file extension. @@ -176,29 +197,34 @@ def _is_valid_data_event_ics(component: cal.Event) -> bool: Returns: True if valid, otherwise returns False. """ - return not (str(component.get('summary')) is None - or component.get('dtstart') is None - or component.get('dtend') is None - or not _is_date_in_range(component.get('dtstart').dt) - or not _is_date_in_range(component.get('dtend').dt) - ) + return not ( + str(component.get("summary")) is None + or component.get("dtstart") is None + or component.get("dtend") is None + or not _is_date_in_range(component.get("dtstart").dt) + or not _is_date_in_range(component.get("dtend").dt) + ) def _add_event_component_ics( - component: cal.Event, calendar_content: List[Dict[str, Any]]) -> None: + component: cal.Event, + calendar_content: List[Dict[str, Any]], +) -> None: """Appends event data from an *.ics file. Args: component: An event component. calendar_content: A list of event data. """ - calendar_content.append({ - "Head": str(component.get('summary')), - "Content": str(component.get('description')), - "S_Date": component.get('dtstart').dt.replace(tzinfo=None), - "E_Date": component.get('dtend').dt.replace(tzinfo=None), - "Location": str(component.get('location')), - }) + calendar_content.append( + { + "Head": str(component.get("summary")), + "Content": str(component.get("description")), + "S_Date": component.get("dtstart").dt.replace(tzinfo=None), + "E_Date": component.get("dtend").dt.replace(tzinfo=None), + "Location": str(component.get("location")), + }, + ) def _get_data_from_txt_file(txt_file_path: str) -> List[Dict[str, Any]]: @@ -219,7 +245,9 @@ def _get_data_from_txt_file(txt_file_path: str) -> List[Dict[str, Any]]: event_data = _get_event_data_from_text(event) if not _is_event_dates_valid( - event_data["start_date"], event_data["end_date"]): + event_data["start_date"], + event_data["end_date"], + ): return [] _add_event_component_txt(event_data, calendar_content) @@ -298,15 +326,17 @@ def _is_event_dates_valid(start: str, end: str) -> bool: assert start_date is not None and end_date is not None - is_date_in_range = (_is_date_in_range(start_date) - and _is_date_in_range(end_date)) + is_date_in_range = _is_date_in_range(start_date) and _is_date_in_range( + end_date, + ) is_end_after_start = _is_start_date_before_end_date(start_date, end_date) is_duration_valid = _is_event_duration_valid(start_date, end_date) return is_date_in_range and is_end_after_start and is_duration_valid def _add_event_component_txt( - event: Dict[str, Any], calendar_content: List[Dict[str, Any]] + event: Dict[str, Any], + calendar_content: List[Dict[str, Any]], ) -> None: """Appends event data from a txt file. @@ -321,13 +351,15 @@ def _add_event_component_txt( start_date = datetime.strptime(event["start_date"], DATE_FORMAT) end_date = datetime.strptime(event["end_date"], DATE_FORMAT) - calendar_content.append({ - "Head": event["head"], - "Content": event["content"], - "S_Date": start_date, - "E_Date": end_date, - "Location": event["location"], - }) + calendar_content.append( + { + "Head": event["head"], + "Content": event["content"], + "S_Date": start_date, + "E_Date": end_date, + "Location": event["location"], + }, + ) def _convert_string_to_date(string_date: str) -> Optional[datetime]: @@ -350,7 +382,8 @@ def _convert_string_to_date(string_date: str) -> Optional[datetime]: def _is_date_in_range( - date: datetime, year_range: int = EVENT_VALID_YEARS + date: datetime, + year_range: int = EVENT_VALID_YEARS, ) -> bool: """Whether the date is in range. @@ -385,7 +418,9 @@ def _is_start_date_before_end_date(start: datetime, end: datetime) -> bool: def _is_event_duration_valid( - start: datetime, end: datetime, max_days: int = EVENT_DURATION_LIMIT + start: datetime, + end: datetime, + max_days: int = EVENT_DURATION_LIMIT, ) -> bool: """Whether an event duration is valid. @@ -402,8 +437,8 @@ def _is_event_duration_valid( def _is_file_valid_to_save_to_database( - events: List[Dict[str, Any]], - max_event_start_date: int = MAX_EVENTS_START_DATE, + events: List[Dict[str, Any]], + max_event_start_date: int = MAX_EVENTS_START_DATE, ) -> bool: """Whether the number of events starting on the same date is valid. @@ -430,7 +465,9 @@ def _is_file_valid_to_save_to_database( def _save_events_to_database( - events: List[Dict[str, Any]], user_id: int, session: Session + events: List[Dict[str, Any]], + user_id: int, + session: Session, ) -> None: """Inserts the events into the Event table. @@ -446,11 +483,12 @@ def _save_events_to_database( end = event["E_Date"] location = event["Location"] owner_id = user_id - create_event(db=session, - title=title, - content=content, - start=start, - end=end, - location=location, - owner_id=owner_id, - ) + create_event( + db=session, + title=title, + content=content, + start=start, + end=end, + location=location, + owner_id=owner_id, + ) diff --git a/app/internal/import_holidays.py b/app/internal/import_holidays.py index 6f5f6f0c..66266f30 100644 --- a/app/internal/import_holidays.py +++ b/app/internal/import_holidays.py @@ -1,13 +1,15 @@ import re from datetime import datetime, timedelta +from typing import List, Match -from app.database.models import User, Event, UserEvent from sqlalchemy.orm import Session -from typing import List, Match + +from app.database.models import Event, User, UserEvent REGEX_EXTRACT_HOLIDAYS = re.compile( - r'SUMMARY:(?P.*)(\n.*){1,8}DTSTAMP:(?P<date>\w{8})', - re.MULTILINE) + r"SUMMARY:(?P<title>.*)(\n.*){1,8}DTSTAMP:(?P<date>\w{8})", + re.MULTILINE, +) def get_holidays_from_file(file: List[Event], session: Session) -> List[Event]: @@ -22,24 +24,27 @@ def get_holidays_from_file(file: List[Event], session: Session) -> List[Event]: holidays = [] for holiday in parsed_holidays: holiday_event = create_holiday_event( - holiday, session.query(User).filter_by(id=1).first().id) + holiday, + session.query(User).filter_by(id=1).first().id, + ) holidays.append(holiday_event) return holidays def create_holiday_event(holiday: Match[str], owner_id: int) -> Event: valid_ascii_chars_range = 128 - title = holiday.groupdict()['title'].strip() - title_to_save = ''.join(i if ord(i) < valid_ascii_chars_range - else '' for i in title) - date = holiday.groupdict()['date'].strip() - format_string = '%Y%m%d' + title = holiday.groupdict()["title"].strip() + title_to_save = "".join( + i if ord(i) < valid_ascii_chars_range else "" for i in title + ) + date = holiday.groupdict()["date"].strip() + format_string = "%Y%m%d" holiday = Event( title=title_to_save, start=datetime.strptime(date, format_string), end=datetime.strptime(date, format_string) + timedelta(days=1), - content='holiday', - owner_id=owner_id + content="holiday", + owner_id=owner_id, ) return holiday @@ -55,10 +60,7 @@ def save_holidays_to_db(holidays: List[Event], session: Session): session.flush(holidays) userevents = [] for holiday in holidays: - userevent = UserEvent( - user_id=holiday.owner_id, - event_id=holiday.id - ) + userevent = UserEvent(user_id=holiday.owner_id, event_id=holiday.id) userevents.append(userevent) session.add_all(userevents) session.commit() diff --git a/app/internal/international_days.py b/app/internal/international_days.py new file mode 100644 index 00000000..618ea008 --- /dev/null +++ b/app/internal/international_days.py @@ -0,0 +1,42 @@ +from datetime import datetime +from typing import Dict, Optional, Union + +from sqlalchemy.orm import Session +from sqlalchemy.sql.expression import func + +from app.database.models import InternationalDays + + +def get_international_day( + international_day: Dict[str, Union[str, int]], +) -> InternationalDays: + """Returns an international day object from the dictionary data. + + Args: + international_day: A dictionary international day + related information. + + Returns: + A new international day object. + """ + return InternationalDays( + day=international_day["day"], + month=international_day["month"], + international_day=international_day["international_day"], + ) + + +def get_international_day_per_day( + session: Session, + date: datetime, +) -> Optional[InternationalDays]: + day_num = date.day + month = date.month + international_day = ( + session.query(InternationalDays) + .filter(InternationalDays.day == day_num) + .filter(InternationalDays.month == month) + .order_by(func.random()) + .first() + ) + return international_day diff --git a/app/internal/json_data_loader.py b/app/internal/json_data_loader.py index 4e9d83e7..8a7781e5 100644 --- a/app/internal/json_data_loader.py +++ b/app/internal/json_data_loader.py @@ -5,8 +5,8 @@ from loguru import logger from sqlalchemy.orm import Session -from app.database.models import Base, Joke, Quote, Zodiac -from app.internal import daily_quotes, jokes, zodiac +from app.database.models import Base, InternationalDays, Joke, Quote, Zodiac +from app.internal import daily_quotes, international_days, jokes, zodiac def load_to_database(session: Session) -> None: @@ -23,31 +23,38 @@ def load_to_database(session: Session) -> None: """ _insert_into_database( session, - 'app/resources/zodiac.json', + "app/resources/zodiac.json", Zodiac, zodiac.get_zodiac, ) _insert_into_database( session, - 'app/resources/quotes.json', + "app/resources/quotes.json", Quote, daily_quotes.get_quote, ) _insert_into_database( session, - 'app/resources/jokes.json', + "app/resources/international_days.json", + InternationalDays, + international_days.get_international_day, + ) + + _insert_into_database( + session, + "app/resources/jokes.json", Joke, jokes.get_joke, ) def _insert_into_database( - session: Session, - path: str, - table: Base, - model_creator: Callable + session: Session, + path: str, + table: Base, + model_creator: Callable, ) -> bool: """Inserts the extracted JSON data into the database. @@ -64,8 +71,9 @@ def _insert_into_database( return False json_objects = _get_data_from_json(path) - model_objects = [model_creator(json_object) - for json_object in json_objects] + model_objects = [ + model_creator(json_object) for json_object in json_objects + ] session.add_all(model_objects) session.commit() return True @@ -97,11 +105,12 @@ def _get_data_from_json(path: str) -> List[Dict[str, Any]]: A list of dictionary objects. """ try: - with open(path, 'r') as json_file: + with open(path, "r") as json_file: json_content = json.load(json_file) except (IOError, ValueError): file_name = os.path.basename(path) logger.exception( - f"An error occurred during reading of json file: {file_name}") + f"An error occurred during reading of json file: {file_name}", + ) return [] return json_content diff --git a/app/internal/logger_customizer.py b/app/internal/logger_customizer.py index ddc06c0c..01026326 100644 --- a/app/internal/logger_customizer.py +++ b/app/internal/logger_customizer.py @@ -1,7 +1,8 @@ -from pathlib import Path import sys +from pathlib import Path -from loguru import _Logger as Logger, logger +from loguru import _Logger as Logger +from loguru import logger class LoggerConfigError(Exception): @@ -9,14 +10,16 @@ class LoggerConfigError(Exception): class LoggerCustomizer: - @classmethod - def make_logger(cls, log_path: Path, - log_filename: str, - log_level: str, - log_rotation_interval: str, - log_retention_interval: str, - log_format: str) -> Logger: + def make_logger( + cls, + log_path: Path, + log_filename: str, + log_level: str, + log_rotation_interval: str, + log_retention_interval: str, + log_format: str, + ) -> Logger: """Creates a logger from given configurations Args: @@ -42,23 +45,25 @@ def make_logger(cls, log_path: Path, level=log_level, retention=log_retention_interval, rotation=log_rotation_interval, - format=log_format + format=log_format, ) except (TypeError, ValueError) as err: raise LoggerConfigError( f"You have an issue with the logger configuration: {err!r}, " - "fix it please") + "fix it please", + ) return logger @classmethod - def customize_logging(cls, - file_path: Path, - level: str, - rotation: str, - retention: str, - format: str - ) -> Logger: + def customize_logging( + cls, + file_path: Path, + level: str, + rotation: str, + retention: str, + format: str, + ) -> Logger: """Used to customize the logger instance Args: @@ -79,7 +84,7 @@ def customize_logging(cls, enqueue=True, backtrace=True, level=level.upper(), - format=format + format=format, ) logger.add( str(file_path), @@ -88,7 +93,7 @@ def customize_logging(cls, enqueue=True, backtrace=True, level=level.upper(), - format=format + format=format, ) return logger diff --git a/app/internal/meds.py b/app/internal/meds.py new file mode 100644 index 00000000..b4461b58 --- /dev/null +++ b/app/internal/meds.py @@ -0,0 +1,446 @@ +from datetime import date, datetime, time, timedelta +from typing import Any, Dict, Iterator, List, Optional, Tuple + +from pydantic.main import BaseModel +from sqlalchemy.orm.session import Session + +from app.database.models import Event +from app.internal.utils import create_model, get_time_from_string + +MAX_EVENT_QUANTITY = 50 + +ERRORS = { + "finish": "Finish Date must must be later than or equal to Start Date", + "max": "Maximal Interval must must be larger than or equal to Minimal \ + Interval", + "amount": "Interval between Earliest Reminder and Latest Reminder not \ + long enough for Daily Amount with Minimal Interval", + "quantity": "Total number of reminders can't be larger than " + + f"{MAX_EVENT_QUANTITY}. Please lower the daily amount, or " + + "choose a shorter time period.", +} + + +class Form(BaseModel): + """Represents a translated form object. + + name (str, optional) - Medication name. + first (time, optional) - First dose time, if given. + amount (int) - Daily dosage. + early (time) - Earliest reminder time. + late (time) - Latest reminder time. + min (time) - Minimal interval between reminders. + max (time) - Maximal interval between reminders. + start (datetime) - First reminder date and time. + end (datetime) - Last reminder date and time. + note (str, optional) - Additional description. + """ + + name: Optional[str] + first: Optional[time] + amount: int + early: time + late: time + min: time + max: time + start: datetime + end: datetime + note: Optional[str] + + +def adjust_day( + datetime_obj: datetime, + early: time, + late: time, + eq: bool = False, +) -> datetime: + """Returns datetime_obj as same or following day as needed. + + Args: + datetime_obj (datetime): Datetime object to adjust. + early (time): Earlir time object. + late (time): Later time object. + eq (bool): Apply time object comparison. + + Returns: + datetime: Datetime_obj with adjusted date. + """ + if late < early or eq and late == early: + datetime_obj += timedelta(days=1) + return datetime_obj + + +def trans_form(web_form: Dict[str, str]) -> Tuple[Form, Dict[str, Any]]: + """Converts all form data to useable types and return as a Tuple. + + Args: + form (dict(str, str)): Form to translate. + + Returns: + tuple(Form, dict(str, any)): Tuple consisting of: + - Form object with all converted form data. + - Dictionary version of Form object. + """ + form = {} + form["name"] = web_form["name"] + start_date = get_time_from_string(web_form["start"]) + form["first"] = get_time_from_string(web_form["first"]) + end_date = get_time_from_string(web_form["end"]) + form["amount"] = int(web_form["amount"]) + form["early"] = get_time_from_string(web_form["early"]) + form["late"] = get_time_from_string(web_form["late"]) + form["min"] = get_time_from_string(web_form["min"]) + form["max"] = get_time_from_string(web_form["max"]) + first_time = form["first"] if form["first"] else form["early"] + form["start"] = datetime.combine(start_date, first_time) + end_date = adjust_day( + end_date, + web_form["early"], + web_form["late"], + eq=True, + ) + form["end"] = datetime.combine(end_date, form["late"]) + form["note"] = web_form["note"] + + form_obj = Form(**form) + form["start"] = form["start"].date() + form["end"] = form["end"].date() + return form_obj, form + + +def convert_time_to_minutes(time_obj: time) -> int: + """Returns time object as minutes. + + Args: + time_obj (time): Time object to convert to minutes. + + Returns: + int: Total minutes in time object. + """ + return round(time_obj.hour * 60 + time_obj.minute) + + +def get_interval_in_minutes(early: time, late: time) -> int: + """Returns interval between 2 time objects in minutes. + + Args: + early (time): Earlier time object. + late (time): Later time object. Interpreted as following day if earlier + than early. + + Returns: + int: Interval between time objects in minutes. + """ + if early == late: + return 0 + extra = int(early > late) + early_date = datetime.combine(datetime.min, early) + late_date = datetime.combine(datetime.min + timedelta(extra), late) + interval = late_date - early_date + return round(interval.seconds / 60) + + +def validate_amount(amount: int, min: time, early: time, late: time) -> bool: + """Returns True if interval is sufficient for reminder amount with minimal + interval constraint, False otherwise + + Args: + amount (int): Reminder amount. + min (time): Minimal interval between reminders. + early (time) - Earliest reminder time. + late (time) - Latest reminder time. + + Returns: + bool: True if interval is sufficient for reminder amount with minimal + interval constraint, False otherwise + """ + min_time = (amount - 1) * convert_time_to_minutes(min) + interval = get_interval_in_minutes(early, late) + return min_time <= interval + + +def validate_events(datetimes: Iterator[datetime]) -> bool: + """Return True if total amount of reminders is less than max amount, False + otherwise. + + Args: + datetimes (list(datetime)): Reminder times. + + Returns: + bool: True if total amount of reminders is less than amx amount, False + otherwise. + """ + return len(list(datetimes)) <= MAX_EVENT_QUANTITY + + +def validate_form(form: Form) -> List[str]: + """Returns a list of error messages for given form. + + Args: + form (Form): Form object to validate. + + Returns: + list(str): Error messages for given form. + """ + errors = [] + if form.end < form.start: + errors.append(ERRORS["finish"]) + if form.max < form.min: + errors.append(ERRORS["max"]) + if not validate_amount(form.amount, form.min, form.early, form.late): + errors.append(ERRORS["amount"]) + datetimes = get_reminder_datetimes(form) + if not validate_events(datetimes): + errors.append(ERRORS["quantity"]) + + return errors + + +def calc_reminder_interval_in_seconds(form: Form) -> int: + """Returns interval between reminders in seconds. + + Args: + form (Form): Form object containing all relevant data. + + Returns: + int: Interval between reminders in seconds. + """ + if form.amount == 1: + return 0 + reminder_interval = get_interval_in_minutes(form.early, form.late) + max_med_interval = reminder_interval / (form.amount - 1) + min_minutes = convert_time_to_minutes(form.min) + max_minutes = convert_time_to_minutes(form.max) + avg_med_interval = (min_minutes + max_minutes) / 2 + return int(min(max_med_interval, avg_med_interval) * 60) + + +def get_reminder_times(form: Form) -> List[time]: + """Returns a list of time objects for reminders based on form data. + + Args: + form (Form): Form object containing all relevant data. + + Returns: + list(time): Time objects for reminders. + """ + interval = calc_reminder_interval_in_seconds(form) + times = [] + early_reminder = datetime.combine(datetime.min, form.early) + for i in range(form.amount): + reminder = early_reminder + timedelta(seconds=interval) * i + times.append(reminder.time()) + + wasted_time = get_interval_in_minutes(times[-1], form.late) / 2 + times = [ + ( + datetime.combine(datetime.min, time_obj) + + timedelta(minutes=wasted_time) + ).time() + for time_obj in times + ] + + return times + + +def validate_datetime( + reminder: datetime, + day: date, + early: time, + late: time, +) -> bool: + """Returns True if reminder is between earlist and latest reminder times on + a given date or equal to any of them, False otherwise. + + Args: + reminder (datetime): Datetime object to validate. + day (date): Date for earlist reminder. + early (time): Earliest reminder time. + late (late): Latest reminder time. Interpreted as following day if + earlier than early. + + Returns: + bool: True if reminder is between earlist and latest reminder times on + a given date or equal to any of them, False otherwise. + """ + early_datetime = datetime.combine(day, early) + late_datetime = datetime.combine(day, late) + late_datetime = adjust_day(late_datetime, early, late) + return early_datetime <= reminder <= late_datetime + + +def validate_first_day_reminder( + previous: datetime, + reminder_time: time, + min: time, + max: time, +) -> bool: + """Returns True if interval between reminders is valid, false otherwise. + + Args: + previous (datetime): Previous reminder. + reminder_time (time): New reminder time. + min (time) - Minimal interval between reminders. + max (time) - Maximal interval between reminders. + + Returns: + bool: True if interval between reminders is valid, false otherwise. + """ + interval = get_interval_in_minutes(previous.time(), reminder_time) + min_minutes = convert_time_to_minutes(min) + max_minutes = convert_time_to_minutes(max) + return max_minutes >= interval >= min_minutes + + +def get_different_time_reminder( + previous: datetime, + min: time, + early: time, + late: time, +) -> Optional[datetime]: + """Returns datetime object for first day reminder with non-standard time. + + Args: + previous (datetime): Previous reminder. + min (time) - Minimal interval between reminders. + early (time): Earliest reminder time. + late (late): Latest reminder time. Interpreted as following day if + earlier than early. + + Returns: + datetime | None: First day reminder with non-standard time, if valid. + """ + reminder = previous + timedelta(minutes=convert_time_to_minutes(min)) + if validate_datetime(reminder, previous.date(), early, late): + return reminder + + +def create_first_day_reminder( + form: Form, + reminder_time: time, + previous: datetime, +) -> Optional[datetime]: + """Returns datetime object for reminder on first day. + + form (Form): Form object containing all relevant data. + reminder_time (time): Time object for new reminder. + previous (datetime): Previous reminder. + + Returns: + datetime | None: First day reminder. + """ + reminder = datetime.combine(form.start.date(), reminder_time) + reminder = adjust_day(reminder, form.early, reminder_time) + if reminder > form.start: + if not validate_first_day_reminder( + previous, + reminder_time, + form.min, + form.max, + ): + reminder = get_different_time_reminder( + previous, + form.min, + form.early, + form.late, + ) + return reminder + + +def get_first_day_reminders( + form: Form, + times: List[time], +) -> Iterator[datetime]: + """Generates datetime objects for reminders on the first day. + + Args: + form (Form): Form object containing all relevant data. + times (list(time)): Time objects for reminders. + + Yields: + datetime: First day reminder datetime object. + """ + yield form.start + previous = form.start + i = 1 + for reminder_time in times: + if i <= form.amount: + new = create_first_day_reminder(form, reminder_time, previous) + if new: + yield new + previous = new + i += 1 + + +def reminder_generator( + times: List[time], + early: time, + start: datetime, + day: date, + end: datetime, +) -> Iterator[datetime]: + """Generates datetime objects for reminders based on times and date. + + Args: + times (list(time)): Reminder times. + early (time): Earliest reminder time. + start (datetime): First reminder date and time. + day (date): Reminders date. + end (datetime) - Last reminder date and time. + + Yields: + datetime: Reminder datetime object. + """ + for time_obj in times: + extra = int(time_obj < early) + day_date = start.date() + timedelta(day + extra) + reminder = datetime.combine(day_date, time_obj) + if reminder <= end: + yield reminder + + +def get_reminder_datetimes(form: Form) -> Iterator[datetime]: + """Generates datetime object for reminders. + + Args: + form (Form): Form object containing all relevant data. + + Yields: + datetime: Reminder datetime object. + """ + times = get_reminder_times(form) + total_days = (form.end.date() - form.start.date()).days + 1 + for day in range(total_days): + if day == 0 and form.first: + yield from get_first_day_reminders(form, times) + else: + yield from reminder_generator( + times, + form.early, + form.start, + day, + form.end, + ) + + +def create_events(session: Session, user_id: int, form: Form) -> None: + """Creates reminder events in the DB based on form data. + + Args: + session (Session): DB session. + user_id (int): ID of user to create events for. + form (Form): Form object containing all relevant data. + """ + title = "It's time to take your meds" + if form.name: + title = f"{form.name.title()} - {title}" + datetimes = get_reminder_datetimes(form) + for event_time in datetimes: + event_data = { + "title": title, + "start": event_time, + "end": event_time + timedelta(minutes=5), + "content": form.note, + "owner_id": user_id, + } + create_model(session, Event, **event_data) diff --git a/app/internal/notification.py b/app/internal/notification.py new file mode 100644 index 00000000..bc638e85 --- /dev/null +++ b/app/internal/notification.py @@ -0,0 +1,175 @@ +from operator import attrgetter +from typing import Callable, Iterator, List, Union + +from fastapi import HTTPException +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session +from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_406_NOT_ACCEPTABLE + +from app.database.models import ( + Invitation, + InvitationStatusEnum, + Message, + MessageStatusEnum, +) +from app.internal.utils import create_model + +WRONG_NOTIFICATION_ID = ( + "The notification id you have entered is wrong\n." + "If you did not enter the notification id manually, report this exception." +) + +NOTIFICATION_TYPE = Union[Invitation, Message] + +UNREAD_STATUS = { + InvitationStatusEnum.UNREAD, + MessageStatusEnum.UNREAD, +} + +ARCHIVED = { + InvitationStatusEnum.DECLINED, + MessageStatusEnum.READ, +} + + +async def get_message_by_id( + message_id: int, + session: Session, +) -> Union[Message, None]: + """Returns an invitation by an id. + if id does not exist, returns None. + """ + return session.query(Message).filter_by(id=message_id).first() + + +def _is_unread(notification: NOTIFICATION_TYPE) -> bool: + """Returns True if notification is unread, False otherwise.""" + return notification.status in UNREAD_STATUS + + +def _is_archived(notification: NOTIFICATION_TYPE) -> bool: + """Returns True if notification should be + in archived page, False otherwise. + """ + return notification.status in ARCHIVED + + +def is_owner(user, notification: NOTIFICATION_TYPE) -> bool: + """Checks if user is owner of the notification. + + Args: + notification: a NOTIFICATION_TYPE object. + user: user schema object. + + Returns: + True or raises HTTPException. + """ + if notification.recipient_id == user.user_id: + return True + + msg = "The notification you are trying to access is not yours." + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail=msg, + ) + + +def raise_wrong_id_error() -> None: + """Raises HTTPException. + + Returns: + None + """ + raise HTTPException( + status_code=HTTP_406_NOT_ACCEPTABLE, + detail=WRONG_NOTIFICATION_ID, + ) + + +def filter_notifications( + session: Session, + user_id: int, + func: Callable[[NOTIFICATION_TYPE], bool], +) -> Iterator[NOTIFICATION_TYPE]: + """Filters notifications by "func".""" + yield from filter(func, get_all_notifications(session, user_id)) + + +def get_unread_notifications( + session: Session, + user_id: int, +) -> Iterator[NOTIFICATION_TYPE]: + """Returns all unread notifications.""" + yield from filter_notifications(session, user_id, _is_unread) + + +def get_archived_notifications( + session: Session, + user_id: int, +) -> List[NOTIFICATION_TYPE]: + """Returns all archived notifications.""" + yield from filter_notifications(session, user_id, _is_archived) + + +def get_all_notifications( + session: Session, + user_id: int, +) -> List[NOTIFICATION_TYPE]: + """Returns all notifications.""" + invitations: List[Invitation] = get_all_invitations( + session, + recipient_id=user_id, + ) + messages: List[Message] = get_all_messages(session, user_id) + + notifications = invitations + messages + return sort_notifications(notifications) + + +def sort_notifications( + notification: List[NOTIFICATION_TYPE], +) -> List[NOTIFICATION_TYPE]: + """Sorts the notifications by the creation date.""" + return sorted(notification, key=attrgetter("creation"), reverse=True) + + +def create_message( + session: Session, + msg: str, + recipient_id: int, + link=None, +) -> Message: + """Creates a new message.""" + return create_model( + session, + Message, + body=msg, + recipient_id=recipient_id, + link=link, + ) + + +def get_all_messages(session: Session, recipient_id: int) -> List[Message]: + """Returns all messages.""" + condition = Message.recipient_id == recipient_id + return session.query(Message).filter(condition).all() + + +def get_all_invitations(session: Session, **param) -> List[Invitation]: + """Returns all invitations filter by param.""" + try: + invitations = session.query(Invitation).filter_by(**param).all() + except SQLAlchemyError: + return [] + else: + return invitations + + +def get_invitation_by_id( + invitation_id: int, + session: Session, +) -> Union[Invitation, None]: + """Returns an invitation by an id. + if id does not exist, returns None. + """ + return session.query(Invitation).filter_by(id=invitation_id).first() diff --git a/app/internal/on_this_day_events.py b/app/internal/on_this_day_events.py index 3a058df1..c43cd0b6 100644 --- a/app/internal/on_this_day_events.py +++ b/app/internal/on_this_day_events.py @@ -1,10 +1,10 @@ -from datetime import date, datetime import json +from datetime import date, datetime from typing import Any, Dict +import requests from fastapi import Depends from loguru import logger -import requests from sqlalchemy import func from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session @@ -14,36 +14,35 @@ from app.dependencies import get_db -def insert_on_this_day_data( - db: Session = Depends(get_db) -) -> Dict[str, Any]: +def insert_on_this_day_data(db: Session = Depends(get_db)) -> Dict[str, Any]: now = datetime.now() day, month = now.day, now.month res = requests.get( - f'https://byabbe.se/on-this-day/{month}/{day}/events.json') + f"https://byabbe.se/on-this-day/{month}/{day}/events.json", + ) text = json.loads(res.text) - res_events = text.get('events') - res_date = text.get('date') - res_wiki = text.get('wikipedia') - db.add(WikipediaEvents(events=res_events, - date_=res_date, wikipedia=res_wiki)) + res_events = text.get("events") + res_date = text.get("date") + res_wiki = text.get("wikipedia") + db.add( + WikipediaEvents(events=res_events, date_=res_date, wikipedia=res_wiki), + ) db.commit() return text -def get_on_this_day_events( - db: Session = Depends(get_db) -) -> Dict[str, Any]: +def get_on_this_day_events(db: Session = Depends(get_db)) -> Dict[str, Any]: try: - data = (db.query(WikipediaEvents). - filter( - func.date(WikipediaEvents.date_inserted) == date.today()). - one()) + data = ( + db.query(WikipediaEvents) + .filter(func.date(WikipediaEvents.date_inserted) == date.today()) + .one() + ) except NoResultFound: data = insert_on_this_day_data(db) except (SQLAlchemyError, AttributeError) as e: - logger.error(f'on this day failed with error: {e}') - data = {'events': [], 'wikipedia': 'https://en.wikipedia.org/'} + logger.exception(f"on this day failed with error: {e}") + data = {"events": [], "wikipedia": "https://en.wikipedia.org/"} return data diff --git a/app/internal/security/dependancies.py b/app/internal/security/dependencies.py similarity index 54% rename from app/internal/security/dependancies.py rename to app/internal/security/dependencies.py index 584235dd..c33bc8a2 100644 --- a/app/internal/security/dependancies.py +++ b/app/internal/security/dependencies.py @@ -1,38 +1,52 @@ from fastapi import Depends, HTTPException -from starlette.status import HTTP_401_UNAUTHORIZED from starlette.requests import Request +from starlette.status import HTTP_401_UNAUTHORIZED from app.database.models import User from app.dependencies import get_db +from app.internal.security import schema from app.internal.security.ouath2 import ( - Session, get_jwt_token, get_authorization_cookie + Session, + get_authorization_cookie, + get_jwt_token, ) -from app.internal.security import schema async def is_logged_in( - request: Request, db: Session = Depends(get_db), - jwt: str = Depends(get_authorization_cookie)) -> bool: + request: Request, + db: Session = Depends(get_db), + jwt: str = Depends(get_authorization_cookie), +) -> bool: """ A dependency function protecting routes for only logged in user """ - await get_jwt_token(db, jwt) + jwt_payload = get_jwt_token(jwt) + user_id = jwt_payload.get("user_id") + if not user_id: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Your token is not valid. Please log in again", + ) return True async def is_manager( - request: Request, db: Session = Depends(get_db), - jwt: str = Depends(get_authorization_cookie)) -> bool: + request: Request, + db: Session = Depends(get_db), + jwt: str = Depends(get_authorization_cookie), +) -> bool: """ A dependency function protecting routes for only logged in manager """ - jwt_payload = await get_jwt_token(db, jwt) - if jwt_payload.get("is_manager"): + jwt_payload = get_jwt_token(jwt) + user_id = jwt_payload.get("user_id") + if jwt_payload.get("is_manager") and user_id: return True raise HTTPException( - status_code=HTTP_401_UNAUTHORIZED, - headers=request.url.path, - detail="You don't have a permition to enter this page") + status_code=HTTP_401_UNAUTHORIZED, + headers=request.url.path, + detail="You don't have a permition to enter this page", + ) async def current_user_from_db( @@ -44,7 +58,7 @@ async def current_user_from_db( Returns logged in User object. A dependency function protecting routes for only logged in user. """ - jwt_payload = await get_jwt_token(db, jwt) + jwt_payload = get_jwt_token(jwt) username = jwt_payload.get("sub") user_id = jwt_payload.get("user_id") db_user = await User.get_by_username(db, username=username) @@ -52,9 +66,10 @@ async def current_user_from_db( return db_user else: raise HTTPException( - status_code=HTTP_401_UNAUTHORIZED, - headers=request.url.path, - detail="Your token is incorrect. Please log in again") + status_code=HTTP_401_UNAUTHORIZED, + headers=request.url.path, + detail="Your token is incorrect. Please log in again", + ) async def current_user( @@ -66,7 +81,12 @@ async def current_user( Returns logged in User object. A dependency function protecting routes for only logged in user. """ - jwt_payload = await get_jwt_token(db, jwt) + jwt_payload = get_jwt_token(jwt) username = jwt_payload.get("sub") user_id = jwt_payload.get("user_id") + if not user_id: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Your token is not valid. Please log in again", + ) return schema.CurrentUser(user_id=user_id, username=username) diff --git a/app/internal/security/ouath2.py b/app/internal/security/ouath2.py index d520c4cc..a20f3b0e 100644 --- a/app/internal/security/ouath2.py +++ b/app/internal/security/ouath2.py @@ -1,26 +1,39 @@ from datetime import datetime, timedelta from typing import Union -from passlib.context import CryptContext +import jwt from fastapi import Depends, HTTPException from fastapi.security import OAuth2PasswordBearer -import jwt from jwt.exceptions import InvalidSignatureError +from passlib.context import CryptContext from sqlalchemy.orm import Session from starlette.requests import Request from starlette.responses import RedirectResponse -from starlette.status import HTTP_401_UNAUTHORIZED -from . import schema +from starlette.status import HTTP_302_FOUND, HTTP_401_UNAUTHORIZED from app.config import JWT_ALGORITHM, JWT_KEY, JWT_MIN_EXP from app.database.models import User +from . import schema pwd_context = CryptContext(schemes=["bcrypt"]) oauth_schema = OAuth2PasswordBearer(tokenUrl="/login") -def get_hashed_password(password: bytes) -> str: +async def update_password( + db: Session, + username: str, + user_password: str, +) -> None: + """Updating User password in database""" + db_user = await User.get_by_username(db=db, username=username) + hashed_password = get_hashed_password(user_password) + db_user.password = hashed_password + db.commit() + return + + +def get_hashed_password(password: str) -> str: """Hashing user password""" return pwd_context.hash(password) @@ -30,17 +43,47 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: return pwd_context.verify(plain_password, hashed_password) +async def is_email_compatible_to_username( + db: Session, + user: schema.ForgotPassword, + email: bool = False, +) -> Union[schema.ForgotPassword, bool]: + """ + Verifying database record by username. + Comparing given email to database record, + """ + db_user = await User.get_by_username( + db=db, + username=user.username.lstrip("@"), + ) + if not db_user: + return False + if db_user.email == user.email: + return schema.ForgotPassword( + username=user.username, + user_id=db_user.id, + email=db_user.email, + ) + return False + + async def authenticate_user( db: Session, - new_user: schema.LoginUser, + user: schema.LoginUser, ) -> Union[schema.LoginUser, bool]: - """Verifying user is in database and password is correct""" - db_user = await User.get_by_username(db=db, username=new_user.username) - if db_user and verify_password(new_user.password, db_user.password): + """ + Verifying database record by username. + Comparing given password to database record, + varies with which function called this action. + """ + db_user = await User.get_by_username(db=db, username=user.username) + if not db_user: + return False + elif verify_password(user.password, db_user.password): return schema.LoginUser( user_id=db_user.id, is_manager=db_user.is_manager, - username=new_user.username, + username=user.username, password=db_user.password, ) return False @@ -63,18 +106,17 @@ def create_jwt_token( return jwt_token -async def get_jwt_token( - db: Session, - token: str = Depends(oauth_schema), - path: Union[bool, str] = None) -> User: +def get_jwt_token( + token: str = Depends(oauth_schema), + path: Union[bool, str] = None, +) -> User: """ Check whether JWT token is correct. Returns jwt payloads if correct. Raises HTTPException if fails to decode. """ try: - jwt_payload = jwt.decode( - token, JWT_KEY, algorithms=JWT_ALGORITHM) + jwt_payload = jwt.decode(token, JWT_KEY, algorithms=JWT_ALGORITHM) except InvalidSignatureError: raise HTTPException( status_code=HTTP_401_UNAUTHORIZED, @@ -91,7 +133,8 @@ async def get_jwt_token( raise HTTPException( status_code=HTTP_401_UNAUTHORIZED, headers=path, - detail="Your token is incorrect. Please log in again") + detail="Your token is incorrect. Please log in again", + ) return jwt_payload @@ -120,6 +163,6 @@ async def auth_exception_handler( """ paramas = f"?next={exc.headers}&message={exc.detail}" url = f"/login{paramas}" - response = RedirectResponse(url=url) + response = RedirectResponse(url=url, status_code=HTTP_302_FOUND) response.delete_cookie("Authorization") return response diff --git a/app/internal/security/schema.py b/app/internal/security/schema.py index 95645ac0..2e5fee8d 100644 --- a/app/internal/security/schema.py +++ b/app/internal/security/schema.py @@ -1,6 +1,6 @@ -from typing import Optional +from typing import Optional, Union -from pydantic import BaseModel +from pydantic import BaseModel, validator class CurrentUser(BaseModel): @@ -8,6 +8,7 @@ class CurrentUser(BaseModel): Validating fields types Returns a user details as a class. """ + user_id: Optional[int] username: str @@ -20,5 +21,65 @@ class LoginUser(CurrentUser): Validating fields types Returns a User object for signing in. """ + is_manager: Optional[bool] password: str + + +class ForgotPassword(BaseModel): + """ + BaseModel for collecting and verifying user + details sending a token via email + """ + + username: str + email: str + user_id: Optional[str] = None + email_verification_token: Optional[str] = None + is_manager: Optional[bool] = False + + class Config: + orm_mode = True + + @validator("username") + def password_length(cls, username: str) -> Union[ValueError, str]: + """Validating username length is legal""" + if not (MIN_FIELD_LENGTH < len(username) < MAX_FIELD_LENGTH): + raise ValueError + return username + + +MIN_FIELD_LENGTH = 3 +MAX_FIELD_LENGTH = 20 + + +class ResetPassword(BaseModel): + """ + Validating fields types + """ + + username: str + password: str + confirm_password: str + + class Config: + orm_mode = True + fields = {"confirm_password": "confirm-password"} + + @validator("confirm_password") + def passwords_match( + cls, + confirm_password: str, + values: BaseModel, + ) -> Union[ValueError, str]: + """Validating passwords fields identical.""" + if "password" in values and confirm_password != values["password"]: + raise ValueError + return confirm_password + + @validator("password") + def password_length(cls, password: str) -> Union[ValueError, str]: + """Validating password length is legal""" + if not (MIN_FIELD_LENGTH < len(password) < MAX_FIELD_LENGTH): + raise ValueError + return password diff --git a/app/internal/showevent.py b/app/internal/showevent.py new file mode 100644 index 00000000..a89b9db8 --- /dev/null +++ b/app/internal/showevent.py @@ -0,0 +1,17 @@ +from datetime import datetime +from typing import List + +from sqlalchemy.orm import Session + +from app.database.models import Event, UserEvent + + +def get_upcoming_events(session: Session, user_id: int) -> List[Event]: + upcoming_events = ( + session.query(Event) + .join(UserEvent) + .filter(UserEvent.user_id == user_id) + .filter(Event.start >= datetime.now()) + .order_by(Event.start) + ) + return upcoming_events diff --git a/app/internal/translation.py b/app/internal/translation.py index e033781f..4fd0510e 100644 --- a/app/internal/translation.py +++ b/app/internal/translation.py @@ -5,7 +5,7 @@ from loguru import logger from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound from sqlalchemy.orm.session import Session -from textblob import download_corpora, TextBlob +from textblob import TextBlob, download_corpora from textblob.exceptions import NotTranslated from app.database.models import Language @@ -31,10 +31,11 @@ def translate_text_for_user(text: str, session: Session, user_id: int) -> str: return translate_text(text, target_lang) -def translate_text(text: str, - target_lang: str, - original_lang: Optional[str] = None, - ) -> str: +def translate_text( + text: str, + target_lang: str, + original_lang: Optional[str] = None, +) -> str: """Translates text to the target language. Args: @@ -56,9 +57,12 @@ def translate_text(text: str, return text try: - return str(TextBlob(text).translate( - from_lang=language_code, - to=_get_language_code(target_lang))) + return str( + TextBlob(text).translate( + from_lang=language_code, + to=_get_language_code(target_lang), + ), + ) except NotTranslated: return text @@ -88,7 +92,7 @@ def _get_user_language(user_id: int, session: Session) -> str: logger.critical(e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail='Error raised', + detail="Error raised", ) diff --git a/app/internal/user.py b/app/internal/user.py new file mode 100644 index 00000000..cb2b0d99 --- /dev/null +++ b/app/internal/user.py @@ -0,0 +1,48 @@ +from sqlalchemy.orm import Session + +from app.database import models, schemas +from app.internal.security.ouath2 import get_hashed_password + + +def get_by_id(db: Session, user_id: int) -> models.User: + """query database for a user by unique id""" + return db.query(models.User).filter(models.User.id == user_id).first() + + +def get_by_username(db: Session, username: str) -> models.User: + """query database for a user by unique username""" + return ( + db.query(models.User).filter(models.User.username == username).first() + ) + + +def get_by_mail(db: Session, email: str) -> models.User: + """query database for a user by unique email""" + return db.query(models.User).filter(models.User.email == email).first() + + +def create(db: Session, user: schemas.UserCreate) -> models.User: + """ + creating a new User object in the database, with hashed password + """ + unhashed_password = user.password.encode("utf-8") + hashed_password = get_hashed_password(unhashed_password) + user_details = { + "username": user.username, + "full_name": user.full_name, + "email": user.email, + "password": hashed_password, + "description": user.description, + } + db_user = models.User(**user_details) + db.add(db_user) + db.commit() + db.refresh(db_user) + return db_user + + +def delete_by_mail(db: Session, email: str) -> None: + """deletes a user from database by unique email""" + db_user = get_by_mail(db=db, email=email) + db.delete(db_user) + db.commit() diff --git a/git b/app/internal/user/__init__.py similarity index 100% rename from git rename to app/internal/user/__init__.py diff --git a/app/internal/user/availability.py b/app/internal/user/availability.py index a94d5a8b..f855ea75 100644 --- a/app/internal/user/availability.py +++ b/app/internal/user/availability.py @@ -3,6 +3,7 @@ from sqlalchemy.orm import Session from app.database.models import Event, User + # from app.internal.utils import get_current_user @@ -11,8 +12,11 @@ def disable(session: Session, user_id: int) -> bool: returns: True if function worked properly False if it didn't.""" - future_events_user_owns = session.query(Event).filter( - Event.start > datetime.now(), Event.owner_id == user_id).all() + future_events_user_owns = ( + session.query(Event) + .filter(Event.start > datetime.now(), Event.owner_id == user_id) + .all() + ) if future_events_user_owns: return False diff --git a/app/internal/utils.py b/app/internal/utils.py index 6b96590f..a7e208f5 100644 --- a/app/internal/utils.py +++ b/app/internal/utils.py @@ -1,6 +1,9 @@ -from typing import Any, List, Optional +from datetime import date, datetime, time +from typing import Any, List, Optional, Union from sqlalchemy.orm import Session +from starlette.responses import RedirectResponse +from starlette.status import HTTP_302_FOUND from app.database.models import Base, User @@ -18,6 +21,7 @@ def save(session: Session, instance: Base) -> bool: def create_model(session: Session, model_class: Base, **kwargs: Any) -> Base: """Creates and saves a db model.""" instance = model_class(**kwargs) + save(session, instance) return instance @@ -41,7 +45,7 @@ def get_current_user(session: Session) -> User: def get_available_users(session: Session) -> List[User]: - """this function return all availible users.""" + """this function return all available users.""" return session.query(User).filter(not User.disabled).all() @@ -58,6 +62,26 @@ def get_user(session: Session, user_id: int) -> Optional[User]: return session.query(User).filter_by(id=user_id).first() +def get_time_from_string(string: str) -> Optional[Union[date, time]]: + """Converts time string to a date or time object. + + Args: + string (str): Time string. + + Returns: + datetime.time | datetime.date | None: Date or Time object if valid, + None otherwise. + """ + formats = {"%Y-%m-%d": "date", "%H:%M": "time", "%H:%M:%S": "time"} + for time_format, method in formats.items(): + try: + time_obj = getattr(datetime.strptime(string, time_format), method) + except ValueError: + pass + else: + return time_obj() + + def get_placeholder_user() -> User: """Creates a mock user. @@ -68,10 +92,31 @@ def get_placeholder_user() -> User: A User object. """ return User( - username='new_user', - email='my@email.po', - password='1a2s3d4f5g6', - full_name='My Name', + username="new_user", + email="my@email.po", + password="1a2s3d4f5g6", + full_name="My Name", language_id=1, - telegram_id='', + telegram_id="", ) + + +def safe_redirect_response( + url: str, + default: str = "/", + status_code: int = HTTP_302_FOUND, +): + """Returns a safe redirect response. + + Args: + url: the url to redirect to. + default: where to redirect if url isn't safe. + status_code: the response status code. + + Returns: + The Notifications HTML page. + """ + if not url.startswith("/"): + url = default + + return RedirectResponse(url=url, status_code=status_code) diff --git a/app/locales/en/LC_MESSAGES/base.po b/app/locales/en/LC_MESSAGES/base.po index 87e32799..948c8761 100644 --- a/app/locales/en/LC_MESSAGES/base.po +++ b/app/locales/en/LC_MESSAGES/base.po @@ -130,4 +130,3 @@ msgstr "" #~ msgid "Agenda" #~ msgstr "" - diff --git a/app/locales/he/LC_MESSAGES/base.po b/app/locales/he/LC_MESSAGES/base.po index c5e559a2..959b1f6d 100644 --- a/app/locales/he/LC_MESSAGES/base.po +++ b/app/locales/he/LC_MESSAGES/base.po @@ -130,4 +130,3 @@ msgstr "בדיקת תרגום בפייתון" #~ msgid "Agenda" #~ msgstr "" - diff --git a/app/main.py b/app/main.py index d39f0c28..e30d5770 100644 --- a/app/main.py +++ b/app/main.py @@ -10,14 +10,14 @@ from app import config from app.database import engine, models from app.dependencies import ( - get_db, - logger, MEDIA_PATH, SOUNDS_PATH, STATIC_PATH, + UPLOAD_PATH, + get_db, + logger, templates, ) - from app.internal import daily_quotes, json_data_loader from app.internal.languages import set_ui_language from app.internal.restore_events import delete_events_after_optionals_num_days @@ -42,6 +42,11 @@ def create_tables(engine, psql_environment): app = FastAPI(title="Pylander", docs_url=None) app.mount("/static", StaticFiles(directory=STATIC_PATH), name="static") app.mount("/media", StaticFiles(directory=MEDIA_PATH), name="media") +app.mount( + "/event_images", + StaticFiles(directory=UPLOAD_PATH), + name="event_images", +) app.mount("/static/tracks", StaticFiles(directory=SOUNDS_PATH), name="sounds") app.logger = logger @@ -66,13 +71,16 @@ def create_tables(engine, psql_environment): four_o_four, friendview, google_connect, - invitation, joke, login, logout, + meds, + notification, profile, register, + reset_password, search, + settings, telegram, user, weekview, @@ -115,14 +123,17 @@ async def swagger_ui_redirect(): four_o_four.router, friendview.router, google_connect.router, - invitation.router, joke.router, login.router, logout.router, + meds.router, + notification.router, profile.router, register.router, + reset_password.router, salary.router, search.router, + settings.router, telegram.router, user.router, weekview.router, diff --git a/app/media/arrow-left.png b/app/media/arrow-left.png new file mode 100644 index 0000000000000000000000000000000000000000..12ef74f7f3b9787280d2c8f196c003f6a09630a0 GIT binary patch literal 3245 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 GU;GzE_{JXq literal 0 HcmV?d00001 diff --git a/app/resources/international_days.json b/app/resources/international_days.json new file mode 100644 index 00000000..89e5e439 --- /dev/null +++ b/app/resources/international_days.json @@ -0,0 +1,1832 @@ +[ + { + "day": 1, + "month": 1, + "international_day": "Ring a bell day and Copyright Law day" + }, + { + "day": 2, + "month": 1, + "international_day": "Science Ficyion Day and World introvert day" + }, + { + "day": 3, + "month": 1, + "international_day": "Drinking straw day and Festival of sleep day" + }, + { + "day": 4, + "month": 1, + "international_day": "Trivia Day and Weigh-in day" + }, + { + "day": 5, + "month": 1, + "international_day": "Whipped Cream day and Bird Day" + }, + { + "day": 6, + "month": 1, + "international_day": "Cuddle up day and Bean Day" + }, + { + "day": 7, + "month": 1, + "international_day": "Tempura day and Bobblehead day" + }, + { + "day": 8, + "month": 1, + "international_day": "Babble bath day and Joy Germ day" + }, + { + "day": 9, + "month": 1, + "international_day": "Apricot Day and Balloon Ascension day" + }, + { + "day": 10, + "month": 1, + "international_day": "Peculiar people day and Bittersweet Chocolate day" + }, + { + "day": 11, + "month": 1, + "international_day": "Step in a puddle and splash your friends day and Heritage treasures day" + }, + { + "day": 12, + "month": 1, + "international_day": "Kiss a ginger day and Marzipan day" + }, + { + "day": 13, + "month": 1, + "international_day": "Sticker day and Rubber duckie day" + }, + { + "day": 14, + "month": 1, + "international_day": "Dress up your pet day and International Kite day" + }, + { + "day": 15, + "month": 1, + "international_day": "Hat day and Bagel day" + }, + { + "day": 16, + "month": 1, + "international_day": "Nothong day and Religious freedom day" + }, + { + "day": 17, + "month": 1, + "international_day": "Ditch new year's resolutions day and Kid inventor's day" + }, + { + "day": 18, + "month": 1, + "international_day": "Thesaurus day and Martin luther king day" + }, + { + "day": 19, + "month": 1, + "international_day": "Popcorn day and Tin can day" + }, + { + "day": 20, + "month": 1, + "international_day": "Disc jockey day and Cheese lovers day" + }, + { + "day": 21, + "month": 1, + "international_day": "Hugging day and Playdate day" + }, + { + "day": 22, + "month": 1, + "international_day": "Answer your cat's Questions day and Hot sauce day" + }, + { + "day": 23, + "month": 1, + "international_day": "Pie day and Visit your local quilt shop day" + }, + { + "day": 24, + "month": 1, + "international_day": "Beer can appreciation day and Peanut Butter day" + }, + { + "day": 25, + "month": 1, + "international_day": "Bubble warp appreciation day and Opposite day" + }, + { + "day": 26, + "month": 1, + "international_day": "Australia day and Peanut brittle day" + }, + { + "day": 27, + "month": 1, + "international_day": "Chocolate cake day and World breast pumping day" + }, + { + "day": 28, + "month": 1, + "international_day": "International lego day and Global community engagement day" + }, + { + "day": 29, + "month": 1, + "international_day": "Fun at work day and Puzzle day" + }, + { + "day": 30, + "month": 1, + "international_day": "Inane answering message day and Seed swap day" + }, + { + "day": 31, + "month": 1, + "international_day": "Backward day and Gorilla suit day" + }, + { + "day": 1, + "month": 2, + "international_day": "Baked alaska day and World read aloud day" + }, + { + "day": 2, + "month": 2, + "international_day": "World play your ukulele day and Tater tot day" + }, + { + "day": 3, + "month": 2, + "international_day": "Carrot cake day and Golden retriver day" + }, + { + "day": 4, + "month": 2, + "international_day": "Thank a letter carrier day and World cancer day" + }, + { + "day": 5, + "month": 2, + "international_day": "World nutella day and Weatherperson's day" + }, + { + "day": 6, + "month": 2, + "international_day": "Take your child to the libray day and Frozen yogurt day" + }, + { + "day": 7, + "month": 2, + "international_day": "Yorkshire pudding day and Wava all your fingers at your neighbors day" + }, + { + "day": 8, + "month": 2, + "international_day": "Clean out your computer day and Molasses bar day" + }, + { + "day": 9, + "month": 2, + "international_day": "Pizza day and Safer internet day" + }, + { + "day": 10, + "month": 2, + "international_day": "Umbrella day and Plimsoll day" + }, + { + "day": 11, + "month": 2, + "international_day": "Fat food day and Peppermint patty day" + }, + { + "day": 12, + "month": 2, + "international_day": "Darwin day and No one eats alone day" + }, + { + "day": 13, + "month": 2, + "international_day": "Radio day and Tortellini day" + }, + { + "day": 14, + "month": 2, + "international_day": "Marriage day and Ferris Wheel day" + }, + { + "day": 15, + "month": 2, + "international_day": "Hippo day and Annoy squidward day" + }, + { + "day": 16, + "month": 2, + "international_day": "Innovation day and Tim Tam day" + }, + { + "day": 17, + "month": 2, + "international_day": "Random acts of kindness day and World human spirit day" + }, + { + "day": 18, + "month": 2, + "international_day": "Drink wine day and Pluto day" + }, + { + "day": 19, + "month": 2, + "international_day": "International Tug-of-War day and Chocolate mint day" + }, + { + "day": 20, + "month": 2, + "international_day": "Love your pet day and Pangolin day" + }, + { + "day": 21, + "month": 2, + "international_day": "Sticky bun day and World whale day" + }, + { + "day": 22, + "month": 2, + "international_day": "World thinking day and Single tasking day" + }, + { + "day": 23, + "month": 2, + "international_day": "Curling is cool day and Play tennis day" + }, + { + "day": 24, + "month": 2, + "international_day": "Pink day and Tortilla chip day" + }, + { + "day": 25, + "month": 2, + "international_day": "Toast day and Chilli day" + }, + { + "day": 26, + "month": 2, + "international_day": "Levi Strauss day and Personal chef day" + }, + { + "day": 27, + "month": 2, + "international_day": "Pokemon day and World NGO day" + }, + { + "day": 28, + "month": 2, + "international_day": "Tooth fairy day and Floral Design day" + }, + { + "day": 29, + "month": 2, + "international_day": "Extra day in leap year" + }, + { + "day": 1, + "month": 3, + "international_day": "Barista day and Fun facts about names day" + }, + { + "day": 2, + "month": 3, + "international_day": "Read across america day and Old stuff day" + }, + { + "day": 3, + "month": 3, + "international_day": "World wildlife day and What if cats and dogs had opposable Thumbs day" + }, + { + "day": 4, + "month": 3, + "international_day": "Grammar day and Marching band day" + }, + { + "day": 5, + "month": 3, + "international_day": "Day of unplugging and World book day" + }, + { + "day": 6, + "month": 3, + "international_day": "White chocolate cheesecake day and dentist's day" + }, + { + "day": 7, + "month": 3, + "international_day": "Be heard day and Plant power day" + }, + { + "day": 8, + "month": 3, + "international_day": "International women's day and Peanut cluster day" + }, + { + "day": 9, + "month": 3, + "international_day": "Meatball day and Barbie day" + }, + { + "day": 10, + "month": 3, + "international_day": "Pack your lunch day and International wig day" + }, + { + "day": 11, + "month": 3, + "international_day": "World plumbing day and Oatmeal nut waffles day" + }, + { + "day": 12, + "month": 3, + "international_day": "Girls scout day and International fanny pack day" + }, + { + "day": 13, + "month": 3, + "international_day": "Jewel day and Ken day" + }, + { + "day": 14, + "month": 3, + "international_day": "Learn about butterflies day and Pi day" + }, + { + "day": 15, + "month": 3, + "international_day": "World speech day and World consumer rights day" + }, + { + "day": 16, + "month": 3, + "international_day": "Lips appreciation day and St.urho's day" + }, + { + "day": 17, + "month": 3, + "international_day": "Saint Patrick's day" + }, + { + "day": 18, + "month": 3, + "international_day": "Awkward moments day and Companies that care day" + }, + { + "day": 19, + "month": 3, + "international_day": "World sleep day and Poultry day" + }, + { + "day": 20, + "month": 3, + "international_day": "International day of happines and Quilting day" + }, + { + "day": 21, + "month": 3, + "international_day": "World poetry day and Vermouth day" + }, + { + "day": 22, + "month": 3, + "international_day": "World water day and Gryffindor pride day" + }, + { + "day": 23, + "month": 3, + "international_day": "Melba toast day and Puppy day" + }, + { + "day": 24, + "month": 3, + "international_day": "Chocolate covered raisins day and Flatmates day" + }, + { + "day": 25, + "month": 3, + "international_day": "Waffle day and Tolkien Reading day" + }, + { + "day": 26, + "month": 3, + "international_day": "Good hair day and Purple day" + }, + { + "day": 27, + "month": 3, + "international_day": "Earth hour and International whiskey day" + }, + { + "day": 28, + "month": 3, + "international_day": "Neighbor day and Black forest cake day" + }, + { + "day": 29, + "month": 3, + "international_day": "Lemon chiffon cake day and Niagara falls runs dry day" + }, + { + "day": 30, + "month": 3, + "international_day": "Doctor's day and Take a walk in the park day" + }, + { + "day": 31, + "month": 3, + "international_day": "Crayola Crayon day and World backup day" + }, + { + "day": 1, + "month": 4, + "international_day": "Fun day and Tell a lie day" + }, + { + "day": 2, + "month": 4, + "international_day": "Ferret day and Walk to work day" + }, + { + "day": 3, + "month": 4, + "international_day": "DIY day and Chocolate mousse day" + }, + { + "day": 4, + "month": 4, + "international_day": "Vitamin C day and Geologist's day" + }, + { + "day": 5, + "month": 4, + "international_day": "Star trek first contact day and Read a road map day" + }, + { + "day": 6, + "month": 4, + "international_day": "World table tennis day and New beer's eve" + }, + { + "day": 7, + "month": 4, + "international_day": "Beer day and No housework day" + }, + { + "day": 8, + "month": 4, + "international_day": "Zoo lovers day and Pygmy hippo day" + }, + { + "day": 9, + "month": 4, + "international_day": "Unicorn day and ASMR day" + }, + { + "day": 10, + "month": 4, + "international_day": "Golfer's day and International safety pin day" + }, + { + "day": 11, + "month": 4, + "international_day": "Pet day and Cheese fondue day" + }, + { + "day": 12, + "month": 4, + "international_day": "Deskfast day and Hamster day" + }, + { + "day": 13, + "month": 4, + "international_day": "Scrabble day and Internatinal FND Awareness day" + }, + { + "day": 14, + "month": 4, + "international_day": "Dolphin day and Day of pink" + }, + { + "day": 15, + "month": 4, + "international_day": "Husband Appriciations day and High five day" + }, + { + "day": 16, + "month": 4, + "international_day": "Wear your pajamas to work day and Save the elephant day" + }, + { + "day": 17, + "month": 4, + "international_day": "Haiku poetry day and Blah blah blah day" + }, + { + "day": 18, + "month": 4, + "international_day": "Pinata day and Columnists day" + }, + { + "day": 19, + "month": 4, + "international_day": "Bicycle day and Hanging out day" + }, + { + "day": 20, + "month": 4, + "international_day": "Volunteer recognition day and Chinese language day" + }, + { + "day": 21, + "month": 4, + "international_day": "World creativity and innovation day and World stationery day" + }, + { + "day": 22, + "month": 4, + "international_day": "Teach your children to save day and Earth day" + }, + { + "day": 23, + "month": 4, + "international_day": "Talk like Shakespeare day and Asparagus day" + }, + { + "day": 24, + "month": 4, + "international_day": "Scream day and Pig in a blanket day" + }, + { + "day": 25, + "month": 4, + "international_day": "Pinhole photography day and Hug a plumber day" + }, + { + "day": 26, + "month": 4, + "international_day": "Hug an australian day and Burlesque day" + }, + { + "day": 27, + "month": 4, + "international_day": "Morse code day and Tell a story day" + }, + { + "day": 28, + "month": 4, + "international_day": "Superhero day and Stop food waste day" + }, + { + "day": 29, + "month": 4, + "international_day": "International dance day and We jump the world day" + }, + { + "day": 30, + "month": 4, + "international_day": "Hairball awareness day and honesty day" + }, + { + "day": 1, + "month": 5, + "international_day": "Tuba day and Therapeutic massage awareness day" + }, + { + "day": 2, + "month": 5, + "international_day": "World laughter day and Baby day" + }, + { + "day": 3, + "month": 5, + "international_day": "Lemonade day and Garden meditation day" + }, + { + "day": 4, + "month": 5, + "international_day": "Star wars day and 45 day" + }, + { + "day": 5, + "month": 5, + "international_day": "Nail day and Internatinal midwive's day" + }, + { + "day": 6, + "month": 5, + "international_day": "No diet day and Password day" + }, + { + "day": 7, + "month": 5, + "international_day": "Roast leg of lamb day and Public gardens day" + }, + { + "day": 8, + "month": 5, + "international_day": "Windmill day and No socks day" + }, + { + "day": 9, + "month": 5, + "international_day": "Moscato day and Lost sock memorial day" + }, + { + "day": 10, + "month": 5, + "international_day": "Mother ocean day and Stay up all night night" + }, + { + "day": 11, + "month": 5, + "international_day": "Eat what you want day and World ego awareness day" + }, + { + "day": 12, + "month": 5, + "international_day": "Receptionist's day and Limerick day" + }, + { + "day": 13, + "month": 5, + "international_day": "Numeracy day and Top gun day" + }, + { + "day": 14, + "month": 5, + "international_day": "Shades day and Chicken dance day" + }, + { + "day": 15, + "month": 5, + "international_day": "World whisky day and Chocolate chip day" + }, + { + "day": 16, + "month": 5, + "international_day": "Drawing day and Sea monkey day" + }, + { + "day": 17, + "month": 5, + "international_day": "Work from home day and International Day Against Homophobia and Transphobia and Biphobia" + }, + { + "day": 18, + "month": 5, + "international_day": "No dirty dishes day and Museum day" + }, + { + "day": 19, + "month": 5, + "international_day": "May ray day" + }, + { + "day": 20, + "month": 5, + "international_day": "Pick strawberries day and World bee day" + }, + { + "day": 21, + "month": 5, + "international_day": "World meditation day and I need a patch for that day" + }, + { + "day": 22, + "month": 5, + "international_day": "Sherlock Holmes day and Goth day" + }, + { + "day": 23, + "month": 5, + "international_day": "Turtle day and Lucky penny day" + }, + { + "day": 24, + "month": 5, + "international_day": "Tiara day and Escargot day" + }, + { + "day": 25, + "month": 5, + "international_day": "Tap dance day and Towel day" + }, + { + "day": 26, + "month": 5, + "international_day": "Senior health and fitness day and Paper airplane day" + }, + { + "day": 27, + "month": 5, + "international_day": "Sun screen day and World product day" + }, + { + "day": 28, + "month": 5, + "international_day": "Amnesty international day and Hamburger day" + }, + { + "day": 29, + "month": 5, + "international_day": "Biscuit day and Paper clip day" + }, + { + "day": 30, + "month": 5, + "international_day": "Mint julep day and Water a flower day" + }, + { + "day": 31, + "month": 5, + "international_day": "No tabbaco day and Save your hearing day" + }, + { + "day": 1, + "month": 6, + "international_day": "Say something nice day" + }, + { + "day": 2, + "month": 6, + "international_day": "Rocky road day and Running day" + }, + { + "day": 3, + "month": 6, + "international_day": "World bicycle day and Chocolate maccaroon day" + }, + { + "day": 4, + "month": 6, + "international_day": "Hug your cat day and Doughnut day" + }, + { + "day": 5, + "month": 6, + "international_day": "Sausage roll day and Coworking day" + }, + { + "day": 6, + "month": 6, + "international_day": "Gardening exercise day and Cancer survivors day" + }, + { + "day": 7, + "month": 6, + "international_day": "Chocolate ice cream day and VCR day" + }, + { + "day": 8, + "month": 6, + "international_day": "Best friends day and World oceans day" + }, + { + "day": 9, + "month": 6, + "international_day": "Rosé day and Donald duck day" + }, + { + "day": 10, + "month": 6, + "international_day": "Iced tea day and Jerky day" + }, + { + "day": 11, + "month": 6, + "international_day": "Yarn Bombing day and Corn of the cob day" + }, + { + "day": 12, + "month": 6, + "international_day": "Superman day and World gin day" + }, + { + "day": 13, + "month": 6, + "international_day": "Sewing machine day and World softball day" + }, + { + "day": 14, + "month": 6, + "international_day": "World blood donor day and Flag day" + }, + { + "day": 15, + "month": 6, + "international_day": "Nature photography day and Beer day Britain" + }, + { + "day": 16, + "month": 6, + "international_day": "World tapas day and Fresh Veggies day" + }, + { + "day": 17, + "month": 6, + "international_day": "Eat your vegetables day and Garbage man day" + }, + { + "day": 18, + "month": 6, + "international_day": "International picnic day and Go fishing day" + }, + { + "day": 19, + "month": 6, + "international_day": "Martini day and Juggling day" + }, + { + "day": 20, + "month": 6, + "international_day": "Ice cream soda day and World refugee day" + }, + { + "day": 21, + "month": 6, + "international_day": "World music day and International yoga day" + }, + { + "day": 22, + "month": 6, + "international_day": "World rainforest day and Onion rings day" + }, + { + "day": 23, + "month": 6, + "international_day": "Let it go day and International widows day" + }, + { + "day": 24, + "month": 6, + "international_day": "Bomb pop day and Swim in lap day" + }, + { + "day": 25, + "month": 6, + "international_day": "Global beatles day and Take your dog to work day" + }, + { + "day": 26, + "month": 6, + "international_day": "Blueberry cheesecake day and Beautician's day and World refrigeration day" + }, + { + "day": 27, + "month": 6, + "international_day": "Pineapple day and Sunglasses day" + }, + { + "day": 28, + "month": 6, + "international_day": "International body piercing day and Logistics day" + }, + { + "day": 29, + "month": 6, + "international_day": "Waffle iron day and Camera day" + }, + { + "day": 30, + "month": 6, + "international_day": "Seocial media day and Metheor watch day" + }, + { + "day": 1, + "month": 7, + "international_day": "Joke day and Gingersnap day" + }, + { + "day": 2, + "month": 7, + "international_day": "Anisette day and World UFO day" + }, + { + "day": 3, + "month": 7, + "international_day": "Air conditioning appreciation day and Eat beans day" + }, + { + "day": 4, + "month": 7, + "international_day": "Independence from meat day and Jackfruit day" + }, + { + "day": 5, + "month": 7, + "international_day": "Apple turnover day and Bikini day" + }, + { + "day": 6, + "month": 7, + "international_day": "International kissing day and Fried chicken day" + }, + { + "day": 7, + "month": 7, + "international_day": "Chocolate day and Macaroni day" + }, + { + "day": 8, + "month": 7, + "international_day": "Math 2.0 day and Chocolate with almonds day" + }, + { + "day": 9, + "month": 7, + "international_day": "Kebab day and Sugar cookie day" + }, + { + "day": 10, + "month": 7, + "international_day": "Pina colada day and Teddy bear picnic day" + }, + { + "day": 11, + "month": 7, + "international_day": "Blueberry muffin day and World population day" + }, + { + "day": 12, + "month": 7, + "international_day": "Etch a sketch day and New conversations day" + }, + { + "day": 13, + "month": 7, + "international_day": "French fries day and Cow appreciation day" + }, + { + "day": 14, + "month": 7, + "international_day": "Shark awareness day and Mac & cheese day" + }, + { + "day": 15, + "month": 7, + "international_day": "Gummi worm day and Hot dog day" + }, + { + "day": 16, + "month": 7, + "international_day": "Guinea pig appreciation day and Corn fritters day" + }, + { + "day": 17, + "month": 7, + "international_day": "World emoji day and Peach ice cream day" + }, + { + "day": 18, + "month": 7, + "international_day": "Caviar day and Insurance nerd day" + }, + { + "day": 19, + "month": 7, + "international_day": "Daiquiri day and Get out of the doghouse day" + }, + { + "day": 20, + "month": 7, + "international_day": "Moon day and International chess day" + }, + { + "day": 21, + "month": 7, + "international_day": "Junk food day and Lamington day" + }, + { + "day": 22, + "month": 7, + "international_day": "Hammock day and Crème brulee day" + }, + { + "day": 23, + "month": 7, + "international_day": "Peanut butter and chocolate day and Sprinkle day" + }, + { + "day": 24, + "month": 7, + "international_day": "Drive-thru day and Tequila day" + }, + { + "day": 25, + "month": 7, + "international_day": "Wine and cheese day and Parent's day" + }, + { + "day": 26, + "month": 7, + "international_day": "Aunt and uncle day and Coffee milk shake day" + }, + { + "day": 27, + "month": 7, + "international_day": "Walk on stilts day and Norfolk day" + }, + { + "day": 28, + "month": 7, + "international_day": "Milk chocolate day and World hepatitis day" + }, + { + "day": 29, + "month": 7, + "international_day": "Chili dog day and International tiger day" + }, + { + "day": 30, + "month": 7, + "international_day": "Cheesecake day and Friendship day" + }, + { + "day": 31, + "month": 7, + "international_day": "Raspberry cake day and Uncommon Instrument awareness day" + }, + { + "day": 1, + "month": 8, + "international_day": "Sisters day and Planner day" + }, + { + "day": 2, + "month": 8, + "international_day": "Ice cream sandwich day and Coloring book day" + }, + { + "day": 3, + "month": 8, + "international_day": "Watermelon day and White wine day" + }, + { + "day": 4, + "month": 8, + "international_day": "Coast guard day and International clouded leopard day" + }, + { + "day": 5, + "month": 8, + "international_day": "Work like a dog day and Blogger day" + }, + { + "day": 6, + "month": 8, + "international_day": "Fresh breath day and International beer day" + }, + { + "day": 7, + "month": 8, + "international_day": "Particularly preposterous packing day and Aged care employee day" + }, + { + "day": 8, + "month": 8, + "international_day": "International cat day and Happiness happens day" + }, + { + "day": 9, + "month": 8, + "international_day": "Melon day and Rice pudding day" + }, + { + "day": 10, + "month": 8, + "international_day": "Lazy day and World lion day" + }, + { + "day": 11, + "month": 8, + "international_day": "World calligraphy day and Son and daughter day" + }, + { + "day": 12, + "month": 8, + "international_day": "World Elephant day and Vinyl record day" + }, + { + "day": 13, + "month": 8, + "international_day": "Blame someone else day and International lefthanders day" + }, + { + "day": 14, + "month": 8, + "international_day": "Creamsicle day and Tattoo removal day" + }, + { + "day": 15, + "month": 8, + "international_day": "Check the chip day and Relaxation day" + }, + { + "day": 16, + "month": 8, + "international_day": "Rollercoaster day and Rum day" + }, + { + "day": 17, + "month": 8, + "international_day": "Thrift shop day and Vanilla custard day" + }, + { + "day": 18, + "month": 8, + "international_day": "Bad poetry day and Never give up day" + }, + { + "day": 19, + "month": 8, + "international_day": "International orangutan day and Photography day" + }, + { + "day": 20, + "month": 8, + "international_day": "Men's grooming day and International day of medical transporters" + }, + { + "day": 21, + "month": 8, + "international_day": "Senior citizen day and World honey bee day" + }, + { + "day": 22, + "month": 8, + "international_day": "Be an angel day and Eat a peach day" + }, + { + "day": 23, + "month": 8, + "international_day": "Cuban sandwich day and Ride the wind day" + }, + { + "day": 24, + "month": 8, + "international_day": "International strange music day and Knife day" + }, + { + "day": 25, + "month": 8, + "international_day": "Kiss and make up day and Banana split day" + }, + { + "day": 26, + "month": 8, + "international_day": "Burger day and Dog day" + }, + { + "day": 27, + "month": 8, + "international_day": "International bat night and Banana lovers day" + }, + { + "day": 28, + "month": 8, + "international_day": "Bow tie day and Franchise appreciation day" + }, + { + "day": 29, + "month": 8, + "international_day": "More herbs less salt day and Potteries bottle oven day" + }, + { + "day": 30, + "month": 8, + "international_day": "Slinky day and Amagwinya day" + }, + { + "day": 31, + "month": 8, + "international_day": "Trail mix day and Overdose awareness day" + }, + { + "day": 1, + "month": 9, + "international_day": "Building and code staff appreciation day and Tofu day" + }, + { + "day": 2, + "month": 9, + "international_day": "Calendar adjustment day and V-J day" + }, + { + "day": 3, + "month": 9, + "international_day": "Skyscraper day and Bring your manners to work day" + }, + { + "day": 4, + "month": 9, + "international_day": "Wildlife day and Macadamia nut day" + }, + { + "day": 5, + "month": 9, + "international_day": "Be late for something day and World samosa day" + }, + { + "day": 6, + "month": 9, + "international_day": "Read a book day and Mouthguard day" + }, + { + "day": 7, + "month": 9, + "international_day": "World duchenne awareness day and Beer lover's day" + }, + { + "day": 8, + "month": 9, + "international_day": "Star terk day and Pardon day" + }, + { + "day": 9, + "month": 9, + "international_day": "Teddy bear day and Wienerschnitzel day" + }, + { + "day": 10, + "month": 9, + "international_day": "World suicide prevention day and TV dinner day" + }, + { + "day": 11, + "month": 9, + "international_day": "Make your bed day and Drive your studebaker day" + }, + { + "day": 12, + "month": 9, + "international_day": "Video games day and Hug your hound day" + }, + { + "day": 13, + "month": 9, + "international_day": "Fortune cookie day and Boss/Employee exchange day" + }, + { + "day": 14, + "month": 9, + "international_day": "Eat a hoagie day and Cream filled doughnut day" + }, + { + "day": 15, + "month": 9, + "international_day": "World afro day and Cheese toast day" + }, + { + "day": 16, + "month": 9, + "international_day": "Guacamole day and Play doh day" + }, + { + "day": 17, + "month": 9, + "international_day": "Tradesmen day and International country music day" + }, + { + "day": 18, + "month": 9, + "international_day": "International red panda day and Cheeseburger day" + }, + { + "day": 19, + "month": 9, + "international_day": "Talk like a priate day and Butterscotch pudding day" + }, + { + "day": 20, + "month": 9, + "international_day": "Punch day and Pepperoni pizza day" + }, + { + "day": 21, + "month": 9, + "international_day": "World alzheimer's day and Escapology day" + }, + { + "day": 22, + "month": 9, + "international_day": "Business women's day and World car free day" + }, + { + "day": 23, + "month": 9, + "international_day": "Restless legs awareness day and Za'atar day and Fitness day" + }, + { + "day": 24, + "month": 9, + "international_day": "Hug a vegetarian day and Lash stylist's day" + }, + { + "day": 25, + "month": 9, + "international_day": "World dream day" + }, + { + "day": 26, + "month": 9, + "international_day": "Lumberjack day and Rivers day" + }, + { + "day": 27, + "month": 9, + "international_day": "Tourism day and Corned beef hash day" + }, + { + "day": 28, + "month": 9, + "international_day": "Drink beer day and International poke day" + }, + { + "day": 29, + "month": 9, + "international_day": "World heart day and Biscotti day" + }, + { + "day": 30, + "month": 9, + "international_day": "Ask a stupid question day and International podcast day" + }, + { + "day": 1, + "month": 10, + "international_day": "World smile day and International coffee day" + }, + { + "day": 2, + "month": 10, + "international_day": "Name your car day and World farm animals day" + }, + { + "day": 3, + "month": 10, + "international_day": "Techies day and Boyfriend's day" + }, + { + "day": 4, + "month": 10, + "international_day": "Vodka day and World habitat day" + }, + { + "day": 5, + "month": 10, + "international_day": "World teachers day and Chic spy day" + }, + { + "day": 6, + "month": 10, + "international_day": "Canadian beer day and Mad hatter day" + }, + { + "day": 7, + "month": 10, + "international_day": "Bathtub day and Frappe day" + }, + { + "day": 8, + "month": 10, + "international_day": "World Octopus day and Egg day" + }, + { + "day": 9, + "month": 10, + "international_day": "Scrubs day and Beer and pizza day" + }, + { + "day": 10, + "month": 10, + "international_day": "Hug a drummer day and SHIFT10 day" + }, + { + "day": 11, + "month": 10, + "international_day": "Coming out day and Canadian thanksgiving" + }, + { + "day": 12, + "month": 10, + "international_day": "Old farmers day and Own business day" + }, + { + "day": 13, + "month": 10, + "international_day": "No bra day and Train your brain day" + }, + { + "day": 14, + "month": 10, + "international_day": "Dessert day and International top spinning day" + }, + { + "day": 15, + "month": 10, + "international_day": "World student's day and Chicken cacciatore day" + }, + { + "day": 16, + "month": 10, + "international_day": "World food day and Dictionary day" + }, + { + "day": 17, + "month": 10, + "international_day": "Toy camera day and Spreadsheet day" + }, + { + "day": 18, + "month": 10, + "international_day": "Chocolate cupcake day and Developmental language disorder awareness day" + }, + { + "day": 19, + "month": 10, + "international_day": "Evaluate your life day and International gin and tonic day" + }, + { + "day": 20, + "month": 10, + "international_day": "International chef day and International sloth day" + }, + { + "day": 21, + "month": 10, + "international_day": "Apple day and Get smart about cerdit day" + }, + { + "day": 22, + "month": 10, + "international_day": "Caps lock day and International stuttering awareness day" + }, + { + "day": 23, + "month": 10, + "international_day": "Make a difference day and Ipod day" + }, + { + "day": 24, + "month": 10, + "international_day": "Unites nation day and Mother in law day" + }, + { + "day": 25, + "month": 10, + "international_day": "International artist day and Accounting day" + }, + { + "day": 26, + "month": 10, + "international_day": "Howl at the moon day and Pumpkin day" + }, + { + "day": 27, + "month": 10, + "international_day": "Black cat day and Cranky co-workers day" + }, + { + "day": 28, + "month": 10, + "international_day": "Plush animal lover's day" + }, + { + "day": 29, + "month": 10, + "international_day": "Animation day and Internet day and Cat day" + }, + { + "day": 30, + "month": 10, + "international_day": "Checklist day and Hug a sheep day" + }, + { + "day": 31, + "month": 10, + "international_day": "Magic day and Caramel apple day" + }, + { + "day": 1, + "month": 11, + "international_day": "World vegan day and Go cook for your pets day" + }, + { + "day": 2, + "month": 11, + "international_day": "Deviled egg day and Dynamic harmlessness day" + }, + { + "day": 3, + "month": 11, + "international_day": "Stress awareness day and Sandwich day" + }, + { + "day": 4, + "month": 11, + "international_day": "Use your common sense day and Men make dinner day" + }, + { + "day": 5, + "month": 11, + "international_day": "Love your red hair day and Love your lawyer day" + }, + { + "day": 6, + "month": 11, + "international_day": "Nachos day and Numbat day" + }, + { + "day": 7, + "month": 11, + "international_day": "Bittersweet chocolate with almonds day and Zero tasking day" + }, + { + "day": 8, + "month": 11, + "international_day": "World orphans day and World quality day" + }, + { + "day": 9, + "month": 11, + "international_day": "World freedom day and Chaos never dies day" + }, + { + "day": 10, + "month": 11, + "international_day": "Sesame street day and Top up day" + }, + { + "day": 11, + "month": 11, + "international_day": "Origami day and Sundae day" + }, + { + "day": 12, + "month": 11, + "international_day": "Happy hour day and Chicken soup for the soul day" + }, + { + "day": 13, + "month": 11, + "international_day": "World kindness day and Indian pudding day" + }, + { + "day": 14, + "month": 11, + "international_day": "Operating room nurse day and Tongue twister day" + }, + { + "day": 15, + "month": 11, + "international_day": "Clean out your refigerator day and Bundt cake day" + }, + { + "day": 16, + "month": 11, + "international_day": "Have a party with your bear day and Clarinet day" + }, + { + "day": 17, + "month": 11, + "international_day": "Homemade bread day and Unfriend day" + }, + { + "day": 18, + "month": 11, + "international_day": "Housing day and Social enterprise day" + }, + { + "day": 19, + "month": 11, + "international_day": "International men's day and World toilet day" + }, + { + "day": 20, + "month": 11, + "international_day": "Name your PC day and Universal children's day" + }, + { + "day": 21, + "month": 11, + "international_day": "World television day and Red mitten day" + }, + { + "day": 22, + "month": 11, + "international_day": "Go for a ride day and Cranberry relish day" + }, + { + "day": 23, + "month": 11, + "international_day": "Espresso day and Fibonacci day" + }, + { + "day": 24, + "month": 11, + "international_day": "Sardines day and Jukebox day" + }, + { + "day": 25, + "month": 11, + "international_day": "Shopping reminder day and Parfait day" + }, + { + "day": 26, + "month": 11, + "international_day": "Buy nothing day and Flossing day" + }, + { + "day": 27, + "month": 11, + "international_day": "Bavarian cream pie day and Pins and Needles day" + }, + { + "day": 28, + "month": 11, + "international_day": "French toast day and Aura awareness day" + }, + { + "day": 29, + "month": 11, + "international_day": "Chocolates day and Lemon cream pie day" + }, + { + "day": 30, + "month": 11, + "international_day": "Computer security day and Mousse day" + }, + { + "day": 1, + "month": 12, + "international_day": "Eat a red apple day and Day without art day" + }, + { + "day": 2, + "month": 12, + "international_day": "Fritters day" + }, + { + "day": 3, + "month": 12, + "international_day": "Bartender appreciation day and Make a gift day" + }, + { + "day": 4, + "month": 12, + "international_day": "Cookie day and International cheetah day" + }, + { + "day": 5, + "month": 12, + "international_day": "International ninja day and Repeal day" + }, + { + "day": 6, + "month": 12, + "international_day": "Miner's day and Walt disney day" + }, + { + "day": 7, + "month": 12, + "international_day": "Cotton candy day and Pearl harbor remembrance day" + }, + { + "day": 8, + "month": 12, + "international_day": "Brownie day and Lard day" + }, + { + "day": 9, + "month": 12, + "international_day": "Pastry day and Techno day" + }, + { + "day": 10, + "month": 12, + "international_day": "Human rights day and Lager day" + }, + { + "day": 11, + "month": 12, + "international_day": "Have a bagel day and Noodle ring day" + }, + { + "day": 12, + "month": 12, + "international_day": "Poinsettia day and Gingerbread house day" + }, + { + "day": 13, + "month": 12, + "international_day": "Violin day and Day of the horse" + }, + { + "day": 14, + "month": 12, + "international_day": "Monkey day and Roast chestnuts day" + }, + { + "day": 15, + "month": 12, + "international_day": "Cat herders day and Lemon cupcake day" + }, + { + "day": 16, + "month": 12, + "international_day": "Re-gifting day and Chocolate covered anything day" + }, + { + "day": 17, + "month": 12, + "international_day": "Ugly christmas sweater day and Maple syrup day" + }, + { + "day": 18, + "month": 12, + "international_day": "Bake cookies day and Roast suckling pig day" + }, + { + "day": 19, + "month": 12, + "international_day": "Look for an evergreen day and Oatmuffin day" + }, + { + "day": 20, + "month": 12, + "international_day": "Go caroling day and Games day" + }, + { + "day": 21, + "month": 12, + "international_day": "Humbug day and Flashlight day" + }, + { + "day": 22, + "month": 12, + "international_day": "Date nut bread day and Forefather's day" + }, + { + "day": 23, + "month": 12, + "international_day": "Roots day and Festivus day" + }, + { + "day": 24, + "month": 12, + "international_day": "Eggnog day" + }, + { + "day": 25, + "month": 12, + "international_day": "Pumpkin pie day" + }, + { + "day": 26, + "month": 12, + "international_day": "Thank you note day and Candy cane day" + }, + { + "day": 27, + "month": 12, + "international_day": "Make cut-out snowflakes day and Fruitcake day" + }, + { + "day": 28, + "month": 12, + "international_day": "Card playing day" + }, + { + "day": 29, + "month": 12, + "international_day": "Tick Tock day and Pepper pot day" + }, + { + "day": 30, + "month": 12, + "international_day": "Bacon day and Bicarbonate of soda day" + }, + { + "day": 31, + "month": 12, + "international_day": "Make up your mind day and Champagne day" + } +] diff --git a/app/routers/about_us.py b/app/routers/about_us.py index e7aa7f98..e9d03681 100644 --- a/app/routers/about_us.py +++ b/app/routers/about_us.py @@ -2,12 +2,14 @@ from app.dependencies import templates - router = APIRouter() @router.get("/about") def about(request: Request): - return templates.TemplateResponse("about_us.html", { - "request": request, - }) + return templates.TemplateResponse( + "about_us.html", + { + "request": request, + }, + ) diff --git a/app/routers/agenda.py b/app/routers/agenda.py index 6cc5a7af..51d304ce 100644 --- a/app/routers/agenda.py +++ b/app/routers/agenda.py @@ -1,9 +1,10 @@ +import json from collections import defaultdict from datetime import date, timedelta -import json from typing import Optional, Tuple from fastapi import APIRouter, Depends, Request +from fastapi.encoders import jsonable_encoder from sqlalchemy.orm import Session from starlette.templating import _TemplateResponse @@ -14,9 +15,9 @@ def calc_dates_range_for_agenda( - start: Optional[date], - end: Optional[date], - days: Optional[int], + start: Optional[date], + end: Optional[date], + days: Optional[int], ) -> Tuple[date, date]: """Create start and end dates according to the parameters in the page.""" if days is not None: @@ -30,37 +31,51 @@ def calc_dates_range_for_agenda( @router.get("/agenda", include_in_schema=False) def agenda( - request: Request, - db: Session = Depends(get_db), - start_date: Optional[date] = None, - end_date: Optional[date] = None, - days: Optional[int] = None, + request: Request, + db: Session = Depends(get_db), + start_date: Optional[date] = None, + end_date: Optional[date] = None, + days: Optional[int] = None, ) -> _TemplateResponse: """Route for the agenda page, using dates range or exact amount of days.""" user_id = 1 # there is no user session yet, so I use user id- 1. start_date, end_date = calc_dates_range_for_agenda( - start_date, end_date, days + start_date, + end_date, + days, ) events_objects = agenda_events.get_events_per_dates( - db, user_id, start_date, end_date + db, + user_id, + start_date, + end_date, ) events = defaultdict(list) for event_obj in events_objects: event_duration = agenda_events.get_time_delta_string( - event_obj.start, event_obj.end + event_obj.start, + event_obj.end, ) - events[event_obj.start.date()].append((event_obj, event_duration)) + json_event_data = jsonable_encoder(event_obj) + json_event_data["duration"] = event_duration + json_event_data["start"] = event_obj.start.time().strftime("%H:%M") + event_key = event_obj.start.date().strftime("%d/%m/%Y") + events[event_key].append(json_event_data) + events_for_graph = json.dumps( - agenda_events.make_dict_for_graph_data(db, user_id) + agenda_events.make_dict_for_graph_data(db, user_id), + ) + return templates.TemplateResponse( + "agenda.html", + { + "request": request, + "events": events, + "start_date": start_date, + "end_date": end_date, + "events_for_graph": events_for_graph, + }, ) - return templates.TemplateResponse("agenda.html", { - "request": request, - "events": events, - "start_date": start_date, - "end_date": end_date, - "events_for_graph": events_for_graph, - }) diff --git a/app/routers/audio.py b/app/routers/audio.py index 827f67ca..c53ed9ca 100644 --- a/app/routers/audio.py +++ b/app/routers/audio.py @@ -2,26 +2,26 @@ from pathlib import Path from typing import List, Optional +from fastapi import APIRouter, Depends, Form, Request +from sqlalchemy.orm.session import Session +from starlette.responses import RedirectResponse +from starlette.status import HTTP_302_FOUND + from app.database.models import User +from app.dependencies import SOUNDS_PATH, get_db, templates from app.internal.audio import ( - get_audio_settings, - handle_vol, - SoundKind, - Sound, - init_audio_tracks, - save_audio_settings, DEFAULT_MUSIC, DEFAULT_MUSIC_VOL, DEFAULT_SFX, DEFAULT_SFX_VOL, + Sound, + SoundKind, + get_audio_settings, + handle_vol, + init_audio_tracks, + save_audio_settings, ) -from app.dependencies import SOUNDS_PATH, get_db, templates -from app.internal.security.dependancies import current_user -from fastapi import APIRouter, Depends, Form, Request -from sqlalchemy.orm.session import Session -from starlette.responses import RedirectResponse -from starlette.status import HTTP_302_FOUND - +from app.internal.security.dependencies import current_user router = APIRouter( prefix="/audio", @@ -37,11 +37,9 @@ def audio_settings( user: User = Depends(current_user), ) -> templates.TemplateResponse: """A route to the audio settings. - Args: request (Request): the http request session (Session): the database. - Returns: templates.TemplateResponse: renders the audio.html page with the relevant information. @@ -75,7 +73,6 @@ async def get_choices( user: User = Depends(current_user), ) -> RedirectResponse: """This function saves users' choices in the db. - Args: request (Request): the http request session (Session): the database. @@ -92,7 +89,6 @@ async def get_choices( sfx_vol (Optional[int], optional): a number in the range (0, 1) indicating the desired sfx volume, or None if disabled. user (User): current user. - Returns: RedirectResponse: redirect the user to home.html. """ @@ -113,10 +109,8 @@ async def start_audio( user: User = Depends(current_user), ) -> RedirectResponse: """Starts audio according to audio settings. - Args: session (Session): the database. - Returns: RedirectResponse: redirect the user to home.html. """ diff --git a/app/routers/calendar_grid.py b/app/routers/calendar_grid.py index b8b0878f..98e94016 100644 --- a/app/routers/calendar_grid.py +++ b/app/routers/calendar_grid.py @@ -1,7 +1,7 @@ import calendar -from datetime import date, datetime, timedelta import itertools import locale +from datetime import date, datetime, timedelta from typing import Dict, Iterator, List, Tuple import pytz @@ -32,21 +32,16 @@ def __init__(self, date: datetime): self.dailyevents: List[Tuple] = [] self.events: List[Tuple] = [] self.css: Dict[str, str] = { - 'day_container': 'day', - 'date': 'day-number', - 'daily_event': 'month-event', - 'daily_event_front': ' '.join([ - 'daily', - 'front', - 'background-warmyellow' - ]), - 'daily_event_back': ' '.join([ - 'daily', - 'back', - 'text-darkblue', - 'background-lightgray' - ]), - 'event': 'event', + "day_container": "day", + "date": "day-number", + "daily_event": "month-event", + "daily_event_front": " ".join( + ["daily", "front", "background-warmyellow"], + ), + "daily_event_back": " ".join( + ["daily", "back", "text-darkblue", "background-lightgray"], + ), + "event": "event", } def __str__(self) -> str: @@ -62,12 +57,12 @@ def set_id(self) -> str: @classmethod def get_user_local_time(cls) -> datetime: - greenwich = pytz.timezone('GB') + greenwich = pytz.timezone("GB") return greenwich.localize(datetime.now()) @classmethod def convert_str_to_date(cls, date_string: str) -> datetime: - return datetime.strptime(date_string, '%d-%B-%Y') + return datetime.strptime(date_string, "%d-%B-%Y") @classmethod def is_weekend(cls, date: date) -> bool: @@ -79,21 +74,16 @@ class DayWeekend(Day): def __init__(self, date: datetime): super().__init__(date) self.css = { - 'day_container': 'day ', - 'date': ' '.join(['day-number', 'text-gray']), - 'daily_event': 'month-event', - 'daily_event_front': ' '.join([ - 'daily', - 'front', - 'background-warmyellow' - ]), - 'daily_event_back': ' '.join([ - 'daily', - 'back', - 'text-darkblue', - 'background-lightgray' - ]), - 'event': 'event', + "day_container": "day ", + "date": " ".join(["day-number", "text-gray"]), + "daily_event": "month-event", + "daily_event_front": " ".join( + ["daily", "front", "background-warmyellow"], + ), + "daily_event_back": " ".join( + ["daily", "back", "text-darkblue", "background-lightgray"], + ), + "event": "event", } @@ -101,26 +91,18 @@ class Today(Day): def __init__(self, date: datetime): super().__init__(date) self.css = { - 'day_container': ' '.join([ - 'day', - 'text-darkblue', - 'background-yellow' - ]), - 'date': 'day-number', - 'daily_event': 'month-event', - 'daily_event_front': ' '.join([ - 'daily', - 'front', - 'text-lightgray', - 'background-darkblue' - ]), - 'daily_event_back': ' '.join([ - 'daily', - 'back', - 'text-darkblue', - 'background-lightgray' - ]), - 'event': 'event', + "day_container": " ".join( + ["day", "text-darkblue", "background-yellow"], + ), + "date": "day-number", + "daily_event": "month-event", + "daily_event_front": " ".join( + ["daily", "front", "text-lightgray", "background-darkblue"], + ), + "daily_event_back": " ".join( + ["daily", "back", "text-darkblue", "background-lightgray"], + ), + "event": "event", } @@ -128,25 +110,18 @@ class FirstDayMonth(Day): def __init__(self, date: datetime): super().__init__(date) self.css = { - 'day_container': ' '.join([ - 'day', - 'text-darkblue', - 'background-lightgray' - ]), - 'date': 'day-number', - 'daily_event': 'month-event', - 'daily_event_front': ' '.join([ - 'daily front', - 'text-lightgray', - 'background-red' - ]), - 'daily_event_back': ' '.join([ - 'daily', - 'back', - 'text-darkblue', - 'background-lightgray' - ]), - 'event': 'event', + "day_container": " ".join( + ["day", "text-darkblue", "background-lightgray"], + ), + "date": "day-number", + "daily_event": "month-event", + "daily_event_front": " ".join( + ["daily front", "text-lightgray", "background-red"], + ), + "daily_event_back": " ".join( + ["daily", "back", "text-darkblue", "background-lightgray"], + ), + "event": "event", } def __str__(self) -> str: @@ -175,8 +150,7 @@ def create_day(day: datetime) -> Day: def get_next_date(date: datetime) -> Iterator[Day]: """Generate date objects from a starting given date.""" yield from ( - create_day(date + timedelta(days=i)) - for i in itertools.count(start=1) + create_day(date + timedelta(days=i)) for i in itertools.count(start=1) ) @@ -197,13 +171,13 @@ def get_n_days(date: datetime, n: int) -> Iterator[Day]: def create_weeks( - days: Iterator[Day], - length: int = Week.WEEK_DAYS + days: Iterator[Day], + length: int = Week.WEEK_DAYS, ) -> List[Week]: """Return lists of Weeks objects.""" ndays: List[Day] = list(days) num_days: int = len(ndays) - return [Week(ndays[i:i + length]) for i in range(0, num_days, length)] + return [Week(ndays[i : i + length]) for i in range(0, num_days, length)] def get_month_block(day: Day, n: int = MONTH_BLOCK) -> List[Week]: diff --git a/app/routers/categories.py b/app/routers/categories.py index 525350ea..c48334d3 100644 --- a/app/routers/categories.py +++ b/app/routers/categories.py @@ -9,10 +9,8 @@ from starlette.datastructures import ImmutableMultiDict from starlette.templating import _TemplateResponse - from app.database.models import Category -from app.dependencies import get_db -from app.dependencies import templates +from app.dependencies import get_db, templates HEX_COLOR_FORMAT = r"^(?:[0-9a-fA-F]{3}){1,2}$" @@ -33,55 +31,62 @@ class Config: "name": "Guitar lessons", "color": "aabbcc", "user_id": 1, - } + }, } # TODO(issue#29): get current user_id from session @router.get("/user", include_in_schema=False) -def get_categories(request: Request, - db_session: Session = Depends(get_db)) -> List[Category]: +def get_categories( + request: Request, + db_session: Session = Depends(get_db), +) -> List[Category]: if validate_request_params(request.query_params): return get_user_categories(db_session, **request.query_params) else: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Request {request.query_params} contains " - f"unallowed params.") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Request {request.query_params} contains " + f"unallowed params.", + ) @router.get("/") def category_color_insert(request: Request) -> _TemplateResponse: - return templates.TemplateResponse("categories.html", { - "request": request - }) + return templates.TemplateResponse("categories.html", {"request": request}) # TODO(issue#29): get current user_id from session @router.post("/") -async def set_category(request: Request, - name: str = Form(None), - color: str = Form(None), - db_sess: Session = Depends(get_db)): +async def set_category( + request: Request, + name: str = Form(None), + color: str = Form(None), + db_sess: Session = Depends(get_db), +): message = "" - user_id = 1 # until issue#29 will get current user_id from session - color = color.replace('#', '') + user_id = 1 # until issue#29 will get current user_id from session + color = color.replace("#", "") if not validate_color_format(color): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Color {color} if not from " - f"expected format.") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Color {color} if not from " f"expected format.", + ) try: Category.create(db_sess, name=name, color=color, user_id=user_id) except IntegrityError: db_sess.rollback() message = "Category already exists" - return templates.TemplateResponse("categories.html", - dictionary_req(request, message, - name, color)) + return templates.TemplateResponse( + "categories.html", + dictionary_req(request, message, name, color), + ) message = f"Congratulation! You have created a new category: {name}" - return templates.TemplateResponse("categories.html", - dictionary_req(request, message, - name, color)) + return templates.TemplateResponse( + "categories.html", + dictionary_req(request, message, name, color), + ) def validate_request_params(query_params: ImmutableMultiDict) -> bool: @@ -98,8 +103,11 @@ def validate_request_params(query_params: ImmutableMultiDict) -> bool: intersection_set = request_params.intersection(all_fields) if "color" in intersection_set: is_valid_color = validate_color_format(query_params["color"]) - return union_set == all_fields and "user_id" in intersection_set and ( - is_valid_color) + return ( + union_set == all_fields + and "user_id" in intersection_set + and (is_valid_color) + ) def validate_color_format(color: str) -> bool: @@ -111,14 +119,19 @@ def validate_color_format(color: str) -> bool: return False -def get_user_categories(db_session: Session, - user_id: int, **params) -> List[Category]: +def get_user_categories( + db_session: Session, user_id: int, **params +) -> List[Category]: """ Returns user's categories, filtered by params. """ try: - categories = db_session.query(Category).filter_by( - user_id=user_id).filter_by(**params).all() + categories = ( + db_session.query(Category) + .filter_by(user_id=user_id) + .filter_by(**params) + .all() + ) except SQLAlchemyError: return [] else: @@ -127,9 +140,9 @@ def get_user_categories(db_session: Session, def dictionary_req(request, message, name, color) -> Dict: dictionary_tamplates = { - "request": request, - "message": message, - "name": name, - "color": color, - } + "request": request, + "message": message, + "name": name, + "color": color, + } return dictionary_tamplates diff --git a/app/routers/credits.py b/app/routers/credits.py index 1a35fd4b..59f8d7f0 100644 --- a/app/routers/credits.py +++ b/app/routers/credits.py @@ -1,7 +1,7 @@ -from fastapi import APIRouter, Request import json from typing import List +from fastapi import APIRouter, Request from loguru import logger from starlette.templating import _TemplateResponse @@ -14,11 +14,10 @@ def credits_from_json() -> List: path = RESOURCES_DIR / "credits.json" try: - with open(path, 'r') as json_file: + with open(path, "r") as json_file: json_list = json.load(json_file) except (IOError, ValueError): - logger.exception( - "An error occurred during reading of json file") + logger.exception("An error occurred during reading of json file") return [] return json_list @@ -26,7 +25,7 @@ def credits_from_json() -> List: @router.get("/credits") def credits(request: Request) -> _TemplateResponse: credit_list = credits_from_json() - return templates.TemplateResponse("credits.html", { - "request": request, - "credit_list": credit_list - }) + return templates.TemplateResponse( + "credits.html", + {"request": request, "credit_list": credit_list}, + ) diff --git a/app/routers/dayview.py b/app/routers/dayview.py index 6b4f887e..6c47c1c5 100644 --- a/app/routers/dayview.py +++ b/app/routers/dayview.py @@ -1,12 +1,15 @@ from bisect import bisect_left from datetime import datetime, timedelta -from typing import Iterator, Optional, Tuple, Union +from typing import Dict, Iterator, Optional, Tuple, Union -from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi import APIRouter, Depends, HTTPException, Request from app.database.models import Event, User from app.dependencies import get_db, templates -from app.internal import zodiac +from app.internal import international_days, zodiac +from app.internal.security.dependencies import current_user + +# from app.internal.security.schema import CurrentUser from app.routers.user import get_all_user_events router = APIRouter() @@ -26,6 +29,50 @@ class DivAttributes: CLASS_SIZES = ("title-size-tiny", "title-size-xsmall", "title-size-small") LENGTH_SIZE_STEP = (30, 45, 90) + def _minutes_position(self, minutes: int) -> Dict[str, int]: + """ + Provides info about the minutes value. + Returns a Dict that contains- + 'minutes position': calculates the number of grid bar quarters + that the minutes value covers (from 1 to 4). + 'min_deviation': calculates the 'spare' minutes left out + of a grid bar quarter. + (used to indicate the accurate current time) + """ + min_minutes = self.MIN_MINUTES + max_minutes = self.MAX_MINUTES + for i in range(self.GRID_BAR_QUARTER, self.FULL_GRID_BAR + 1): + if min_minutes < minutes <= max_minutes: + minute_deviation = minutes - (i - 1) * self.MAX_MINUTES + return {"min_position": i, "min_deviation": minute_deviation} + min_minutes = max_minutes + max_minutes += self.MAX_MINUTES + + def _get_position(self, time: datetime) -> int: + grid_hour_position = time.hour * self.FULL_GRID_BAR + grid_minutes_modifier = self._minutes_position(time.minute) + if grid_minutes_modifier is None: + grid_minutes_modifier = 0 + else: + grid_minutes_modifier = grid_minutes_modifier["min_position"] + return grid_hour_position + grid_minutes_modifier + self.BASE_GRID_BAR + + +class CurrentTimeAttributes(DivAttributes): + def __init__(self, date: datetime) -> None: + current = datetime.now() + self.dayview_date = date.date() + self.is_viewed = self._date_is_today() + self.grid_position = self._get_position(current) - 1 + self.sub_grid_position = self._minutes_position(current.minute) + self.sub_grid_position = self.sub_grid_position["min_deviation"] + + def _date_is_today(self) -> bool: + today = datetime.today().date() + return today == self.dayview_date + + +class EventsAttributes(DivAttributes): def __init__( self, event: Event, @@ -46,23 +93,6 @@ def _check_color(self, color: str) -> str: return self.DEFAULT_COLOR return color - def _minutes_position(self, minutes: int) -> Union[int, None]: - min_minutes = self.MIN_MINUTES - max_minutes = self.MAX_MINUTES - for i in range(self.GRID_BAR_QUARTER, self.FULL_GRID_BAR + 1): - if min_minutes < minutes <= max_minutes: - return i - min_minutes = max_minutes - max_minutes += 15 - return None - - def _get_position(self, time: datetime) -> int: - grid_hour_position = time.hour * self.FULL_GRID_BAR - grid_minutes_modifier = self._minutes_position(time.minute) - if grid_minutes_modifier is None: - grid_minutes_modifier = 0 - return grid_hour_position + grid_minutes_modifier + self.BASE_GRID_BAR - def _set_grid_position(self) -> str: if self.start_multiday: start = self.FIRST_GRID_BAR @@ -137,12 +167,12 @@ def get_events_and_attributes( day: datetime, session, user_id: int, -) -> Iterator[Tuple[Event, DivAttributes]]: +) -> Iterator[Tuple[Event, EventsAttributes]]: events = get_all_user_events(session, user_id) day_end = day + timedelta(hours=24) for event in events: if is_specific_time_event_in_day(event, day, day_end): - yield event, DivAttributes(event, day) + yield event, EventsAttributes(event, day) def get_all_day_events( @@ -154,49 +184,46 @@ def get_all_day_events( day_end = day + timedelta(hours=24) for event in events: if is_all_day_event_in_day(event=event, day=day, day_end=day_end): - yield (event) + yield event @router.get("/day/{date}", include_in_schema=False) async def dayview( request: Request, date: str, - session=Depends(get_db), view="day", + session=Depends(get_db), + user: User = Depends(current_user), ): - # TODO: add a login session - user = session.query(User).filter_by(username="test_username").first() - if not user: - error_message = "User not found." - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=error_message, - ) try: day = datetime.strptime(date, "%Y-%m-%d") except ValueError as err: raise HTTPException(status_code=404, detail=f"{err}") zodiac_obj = zodiac.get_zodiac_of_day(session, day) - events_n_attrs = get_events_and_attributes( + events_with_attrs = get_events_and_attributes( day=day, session=session, - user_id=user.id, + user_id=user.user_id, ) all_day_events = get_all_day_events( day=day, session=session, - user_id=user.id, + user_id=user.user_id, ) + current_time_with_attrs = CurrentTimeAttributes(date=day) + inter_day = international_days.get_international_day_per_day(session, day) month = day.strftime("%B").upper() return templates.TemplateResponse( "calendar_day_view.html", { "request": request, - "events": events_n_attrs, + "events_and_attrs": events_with_attrs, "all_day_events": all_day_events, "month": month, "day": day.day, + "international_day": inter_day, "zodiac": zodiac_obj, "view": view, + "current_time": current_time_with_attrs, }, ) diff --git a/app/routers/event.py b/app/routers/event.py index 66a44b71..5eb3843d 100644 --- a/app/routers/event.py +++ b/app/routers/event.py @@ -1,36 +1,50 @@ -from datetime import datetime as dt +import io import json -from operator import attrgetter -from typing import Any, Dict, List, Optional, Tuple import urllib +from datetime import datetime as dt +from operator import attrgetter +from typing import Any, Dict, List, NamedTuple, Optional, Tuple -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, File, HTTPException, Request +from PIL import Image from pydantic import BaseModel from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound from sqlalchemy.sql.elements import Null from starlette import status +from starlette.datastructures import ImmutableMultiDict from starlette.responses import RedirectResponse, Response from starlette.templating import _TemplateResponse -from app.database.models import Comment, Event, User, UserEvent -from app.dependencies import get_db, logger, templates +from app.config import PICTURE_EXTENSION +from app.database.models import ( + Comment, + Event, + SharedList, + SharedListItem, + User, + UserEvent, +) +from app.dependencies import UPLOAD_PATH, 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 +IMAGE_HEIGHT = 200 EVENT_DATA = Tuple[Event, List[Dict[str, str]], str] TIME_FORMAT = "%Y-%m-%d %H:%M" START_FORMAT = "%A, %d/%m/%Y %H:%M" + UPDATE_EVENTS_FIELDS = { "title": str, "start": dt, @@ -51,6 +65,12 @@ ) +class SharedItem(NamedTuple): + name: str + amount: float + participant: str + + class EventModel(BaseModel): title: str start: dt @@ -102,6 +122,7 @@ async def eventedit( @router.post("/edit", include_in_schema=False) async def create_new_event( request: Request, + event_img: bytes = File(None), session=Depends(get_db), ) -> Response: data = await request.form() @@ -116,7 +137,6 @@ async def create_new_event( availability = data.get("availability", "True") == "True" location = data["location"] all_day = data["event_type"] and data["event_type"] == "on" - vc_link = data.get("vc_link") category_id = data.get("category_id") privacy = data["privacy"] @@ -131,9 +151,17 @@ async def create_new_event( title, invited_emails, ) + shared_list = extract_shared_list_from_data(event_info=data, db=session) + 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, @@ -144,14 +172,22 @@ 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, availability=availability, is_google_event=is_google_event, + shared_list=shared_list, privacy=privacy, ) + if event_img: + image = process_image(event_img, event.id) + event.image = image + session.commit() + messages = get_messages(session, event, uninvited_contacts) return RedirectResponse( router.url_path_for("eventview", event_id=event.id) @@ -160,6 +196,31 @@ async def create_new_event( ) +def process_image( + img: bytes, + event_id: int, + img_height: int = IMAGE_HEIGHT, +) -> str: + """Resized and saves picture without exif (to avoid malicious date)) + according to required height and keep aspect ratio""" + try: + image = Image.open(io.BytesIO(img)) + except IOError: + error_message = "The uploaded file is not a valid image" + logger.exception(error_message) + return + width, height = image.size + height_to_req_height = img_height / float(height) + new_width = int(float(width) * float(height_to_req_height)) + resized = image.resize((new_width, img_height), Image.ANTIALIAS) + file_name = f"{event_id}{PICTURE_EXTENSION}" + image_data = list(resized.getdata()) + image_without_exif = Image.new(resized.mode, resized.size) + image_without_exif.putdata(image_data) + image_without_exif.save(f"{UPLOAD_PATH}/{file_name}") + return file_name + + def get_waze_link(event: Event) -> str: """Get a waze navigation link to the event location. @@ -410,12 +471,16 @@ 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, availability: bool = True, is_google_event: bool = False, + shared_list: Optional[SharedList] = None, privacy: str = PrivacyKinds.Public.name, + image: Optional[str] = None, ): """Creates an event and an association.""" @@ -431,14 +496,18 @@ 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), invitees=invitees_concatenated, all_day=all_day, category_id=category_id, + shared_list=shared_list, availability=availability, is_google_event=is_google_event, + image=image, ) create_model(db, UserEvent, user_id=owner_id, event_id=event.id) return event @@ -536,6 +605,60 @@ def add_new_event(values: dict, db: Session) -> Optional[Event]: return None +def extract_shared_list_from_data( + event_info: ImmutableMultiDict, + db: Session, +) -> Optional[SharedList]: + """Extract shared list items from POST data. + Return: + SharedList: SharedList object stored in the database. + """ + raw_items = zip( + event_info.getlist("item-name"), + event_info.getlist("item-amount"), + event_info.getlist("item-participant"), + ) + items = [] + title = event_info.get("shared-list-title") + for name, amount, participant in raw_items: + item = SharedItem(name, amount, participant) + if _check_item_is_valid(item): + item_dict = item._asdict() + item_dict["amount"] = float(item_dict["amount"]) + items.append(item_dict) + return _create_shared_list({title: items}, db) + + +def _check_item_is_valid(item: SharedItem) -> bool: + return ( + item is not None + and item.amount.isnumeric() + and item.participant is not None + ) + + +def _create_shared_list( + raw_shared_list: Dict[str, Dict[str, Any]], + db: Session, +) -> Optional[SharedList]: + try: + title = list(raw_shared_list.keys())[0] or "Shared List" + except IndexError as e: + logger.exception(e) + return None + shared_list = create_model(db, SharedList, title=title) + try: + items = list(raw_shared_list.values())[0] + for item in items: + item = create_model(db, SharedListItem, **item) + shared_list.items.append(item) + except (IndexError, KeyError) as e: + logger.exception(e) + return None + else: + return shared_list + + def get_template_to_share_event( event_id: int, user_name: str, diff --git a/app/routers/event_images.py b/app/routers/event_images.py index 9a1ae495..bfe56cd3 100644 --- a/app/routers/event_images.py +++ b/app/routers/event_images.py @@ -1,5 +1,5 @@ -from functools import lru_cache import re +from functools import lru_cache from typing import Optional from nltk.tokenize import word_tokenize @@ -7,135 +7,135 @@ from app import config -FLAIRS_EXTENSION = '.jpg' -FLAIRS_REL_PATH = f'{config.STATIC_ABS_PATH}\\event_flairs' +FLAIRS_EXTENSION = ".jpg" +FLAIRS_REL_PATH = f"{config.STATIC_ABS_PATH}\\event_flairs" IMAGES_RELATED_WORDS_MAP = { - 'birthday': 'birthday', - 'coffee': 'coffee', - 'coffees': 'coffee', - 'concert': 'concert', - 'gig': 'concert', - 'concerts': 'concert', - 'gigs': 'concert', - 'bicycle': 'cycle', - 'cycling': 'cycle', - 'bike': 'cycle', - 'bicycles': 'cycle', - 'bikes': 'cycle', - 'biking': 'cycle', - 'dentist': 'dentist', - 'dentistry': 'dentist', - 'dental': 'dentist', - 'dinner': 'food', - 'dinners': 'food', - 'restaurant': 'food', - 'restaurants': 'food', - 'family meal': 'food', - 'lunch': 'food', - 'lunches': 'food', - 'luncheon': 'food', - 'cocktail': 'drank', - 'drinks': 'drank', - 'cocktails': 'drank', - 'golf': 'golf', - 'graduation': 'graduate', - 'gym': 'gym', - 'workout': 'gym', - 'workouts': 'gym', - 'haircut': 'haircut', - 'hair': 'haircut', - 'halloween': 'halloween', - 'helloween': 'halloween', - "hallowe'en": 'halloween', - 'allhalloween': 'halloween', - "all hallows' eve": 'halloween', - "all saints' Eve": 'halloween', - 'hiking': 'hike', - 'hike': 'hike', - 'hikes': 'hike', - 'kayaking': 'kayak', - 'piano': 'music', - 'singing': 'music', - 'music class': 'music', - 'choir practice': 'music', - 'flute': 'music', - 'orchestra': 'music', - 'oboe': 'music', - 'clarinet': 'music', - 'saxophone': 'music', - 'cornett': 'music', - 'trumpet': 'music', - 'contrabass': 'music', - 'cello': 'music', - 'trombone': 'music', - 'tuba': 'music', - 'music ensemble': 'music', - 'string quartett': 'music', - 'guitar lesson': 'music', - 'classical music': 'music', - 'choir': 'music', - 'manicure': 'manicure', - 'pedicure': 'manicure', - 'manicures': 'manicure', - 'pedicures': 'manicure', - 'massage': 'massage', - 'back rub': 'massage', - 'backrub': 'massage', - 'massages': 'massage', - 'pills': 'pill', - 'medicines': 'pill', - 'medicine': 'pill', - 'drug': 'pill', - 'drugs': 'pill', - 'ping pong': 'pingpong', - 'table tennis': 'pingpong', - 'ping-pong': 'pingpong', - 'pingpong': 'pingpong', - 'plan week': 'plan', - 'plan quarter': 'plan', - 'plan day': 'plan', - 'plan vacation': 'plan', - 'week planning': 'plan', - 'vacation planning': 'plan', - 'pokemon': 'pokemon', - 'reading': 'read', - 'newspaper': 'read', - 'fridge repair': 'repair', - 'handyman': 'repair', - 'electrician': 'repair', - 'diy': 'repair', - 'jog': 'ran', - 'jogging': 'ran', - 'running': 'ran', - 'jogs': 'ran', - 'runs': 'ran', - 'sail': 'sail', - 'sailing': 'sail', - 'boat cruise': 'sail', - 'sailboat': 'sail', - 'santa claus': 'santa', - 'father christmas': 'santa', - 'skiing': 'ski', - 'ski': 'ski', - 'skis': 'ski', - 'snowboarding': 'ski', - 'snowshoeing': 'ski', - 'snow shoe': 'ski', - 'snow boarding': 'ski', - 'soccer': 'soccer', - 'swim': 'swam', - 'swimming': 'swam', - 'swims': 'swam', - 'tennis': 'tennis', - 'thanksgiving': 'thanksgiving', - 'wedding': 'wed', - 'wedding eve': 'wed', - 'wedding-eve party': 'wed', - 'weddings': 'wed', - 'christmas': 'christmas', - 'xmas': 'christmas', - 'x-mas': 'christmas', - 'yoga': 'yoga', + "birthday": "birthday", + "coffee": "coffee", + "coffees": "coffee", + "concert": "concert", + "gig": "concert", + "concerts": "concert", + "gigs": "concert", + "bicycle": "cycle", + "cycling": "cycle", + "bike": "cycle", + "bicycles": "cycle", + "bikes": "cycle", + "biking": "cycle", + "dentist": "dentist", + "dentistry": "dentist", + "dental": "dentist", + "dinner": "food", + "dinners": "food", + "restaurant": "food", + "restaurants": "food", + "family meal": "food", + "lunch": "food", + "lunches": "food", + "luncheon": "food", + "cocktail": "drank", + "drinks": "drank", + "cocktails": "drank", + "golf": "golf", + "graduation": "graduate", + "gym": "gym", + "workout": "gym", + "workouts": "gym", + "haircut": "haircut", + "hair": "haircut", + "halloween": "halloween", + "helloween": "halloween", + "hallowe'en": "halloween", + "allhalloween": "halloween", + "all hallows' eve": "halloween", + "all saints' Eve": "halloween", + "hiking": "hike", + "hike": "hike", + "hikes": "hike", + "kayaking": "kayak", + "piano": "music", + "singing": "music", + "music class": "music", + "choir practice": "music", + "flute": "music", + "orchestra": "music", + "oboe": "music", + "clarinet": "music", + "saxophone": "music", + "cornett": "music", + "trumpet": "music", + "contrabass": "music", + "cello": "music", + "trombone": "music", + "tuba": "music", + "music ensemble": "music", + "string quartett": "music", + "guitar lesson": "music", + "classical music": "music", + "choir": "music", + "manicure": "manicure", + "pedicure": "manicure", + "manicures": "manicure", + "pedicures": "manicure", + "massage": "massage", + "back rub": "massage", + "backrub": "massage", + "massages": "massage", + "pills": "pill", + "medicines": "pill", + "medicine": "pill", + "drug": "pill", + "drugs": "pill", + "ping pong": "pingpong", + "table tennis": "pingpong", + "ping-pong": "pingpong", + "pingpong": "pingpong", + "plan week": "plan", + "plan quarter": "plan", + "plan day": "plan", + "plan vacation": "plan", + "week planning": "plan", + "vacation planning": "plan", + "pokemon": "pokemon", + "reading": "read", + "newspaper": "read", + "fridge repair": "repair", + "handyman": "repair", + "electrician": "repair", + "diy": "repair", + "jog": "ran", + "jogging": "ran", + "running": "ran", + "jogs": "ran", + "runs": "ran", + "sail": "sail", + "sailing": "sail", + "boat cruise": "sail", + "sailboat": "sail", + "santa claus": "santa", + "father christmas": "santa", + "skiing": "ski", + "ski": "ski", + "skis": "ski", + "snowboarding": "ski", + "snowshoeing": "ski", + "snow shoe": "ski", + "snow boarding": "ski", + "soccer": "soccer", + "swim": "swam", + "swimming": "swam", + "swims": "swam", + "tennis": "tennis", + "thanksgiving": "thanksgiving", + "wedding": "wed", + "wedding eve": "wed", + "wedding-eve party": "wed", + "weddings": "wed", + "christmas": "christmas", + "xmas": "christmas", + "x-mas": "christmas", + "yoga": "yoga", } @@ -148,7 +148,7 @@ def generate_flare_link_from_lemmatized_word(lemmatized_word: str) -> str: Returns: str: The suitable link. """ - return f'{FLAIRS_REL_PATH}\\{lemmatized_word}{FLAIRS_EXTENSION}' + return f"{FLAIRS_REL_PATH}\\{lemmatized_word}{FLAIRS_EXTENSION}" def remove_non_alphabet_chars(text: str) -> str: @@ -160,8 +160,8 @@ def remove_non_alphabet_chars(text: str) -> str: Returns: str: The string after the removal. """ - regex = re.compile('[^a-zA-Z]') - return regex.sub('', text) + regex = re.compile("[^a-zA-Z]") + return regex.sub("", text) def get_image_name(related_word: str) -> Optional[str]: @@ -213,5 +213,5 @@ def attach_image_to_event(event_content: str) -> str: link = search_token_in_related_words(token) if link: return link - link = '#' + link = "#" return link diff --git a/app/routers/export.py b/app/routers/export.py index a5fd4229..0fa5b279 100644 --- a/app/routers/export.py +++ b/app/routers/export.py @@ -9,7 +9,8 @@ from app.dependencies import get_db from app.internal.agenda_events import get_events_in_time_frame from app.internal.export import get_icalendar_with_multiple_events -from app.internal.utils import get_current_user +from app.internal.security.schema import CurrentUser +from tests.security_testing_routes import current_user router = APIRouter( prefix="/export", @@ -20,9 +21,10 @@ @router.get("/") def export( - start_date: Union[date, str], - end_date: Union[date, str], - db: Session = Depends(get_db), + start_date: Union[date, str], + end_date: Union[date, str], + db: Session = Depends(get_db), + user: CurrentUser = Depends(current_user), ) -> StreamingResponse: """Returns the Export page route. @@ -30,19 +32,18 @@ def export( start_date: A date or an empty string. end_date: A date or an empty string. db: Optional; The database connection. + user: user schema object. Returns: - # TODO add description + A StreamingResponse that contains an .ics file. """ - # TODO: connect to real user - user = get_current_user(db) - events = get_events_in_time_frame(start_date, end_date, user.id, db) + events = get_events_in_time_frame(start_date, end_date, user.user_id, db) file = BytesIO(get_icalendar_with_multiple_events(db, list(events))) return StreamingResponse( content=file, media_type="text/calendar", headers={ - # Change filename to "pylandar.ics". - "Content-Disposition": "attachment;filename=pylandar.ics", + # Change filename to "PyLendar.ics". + "Content-Disposition": "attachment;filename=PyLendar.ics", }, ) diff --git a/app/routers/four_o_four.py b/app/routers/four_o_four.py index 0e989677..5dd2fe47 100644 --- a/app/routers/four_o_four.py +++ b/app/routers/four_o_four.py @@ -1,7 +1,8 @@ -from app.dependencies import templates from fastapi import APIRouter from starlette.requests import Request +from app.dependencies import templates + router = APIRouter( prefix="/404", tags=["404"], @@ -11,5 +12,4 @@ @router.get("/") async def not_implemented(request: Request): - return templates.TemplateResponse("four_o_four.j2", - {"request": request}) + return templates.TemplateResponse("four_o_four.j2", {"request": request}) diff --git a/app/routers/friendview.py b/app/routers/friendview.py index 1ed86f8c..40c9fcfd 100644 --- a/app/routers/friendview.py +++ b/app/routers/friendview.py @@ -1,28 +1,31 @@ +from typing import Union + from fastapi import APIRouter, Depends, Request from sqlalchemy.orm import Session from starlette.templating import _TemplateResponse -from typing import Union from app.dependencies import get_db, templates from app.internal import friend_view - router = APIRouter(tags=["friendview"]) @router.get("/friendview") def friendview( - request: Request, - db: Session = Depends(get_db), - my_friend: Union[str, None] = None, + request: Request, + db: Session = Depends(get_db), + my_friend: Union[str, None] = None, ) -> _TemplateResponse: # TODO: Waiting for user registration user_id = 1 events_list = friend_view.get_events_per_friend(db, user_id, my_friend) - return templates.TemplateResponse("friendview.html", { - "request": request, - "events": events_list, - "my_friend": my_friend, - }) + return templates.TemplateResponse( + "friendview.html", + { + "request": request, + "events": events_list, + "my_friend": my_friend, + }, + ) diff --git a/app/routers/google_connect.py b/app/routers/google_connect.py index cbf79e18..ccbd8d3f 100644 --- a/app/routers/google_connect.py +++ b/app/routers/google_connect.py @@ -1,10 +1,10 @@ -from fastapi import Depends, APIRouter, Request -from starlette.responses import RedirectResponse +from fastapi import APIRouter, Depends, Request from loguru import logger +from starlette.responses import RedirectResponse -from app.internal.utils import get_current_user from app.dependencies import get_db -from app.internal.google_connect import get_credentials, fetch_save_events +from app.internal.google_connect import fetch_save_events, get_credentials +from app.internal.utils import get_current_user from app.routers.profile import router as profile router = APIRouter( @@ -15,11 +15,13 @@ @router.get("/sync") -async def google_sync(request: Request, - session=Depends(get_db)) -> RedirectResponse: - '''Sync with Google - if user never synced with google this funcion will take +async def google_sync( + request: Request, + session=Depends(get_db), +) -> RedirectResponse: + """Sync with Google - if user never synced with google this funcion will take the user to a consent screen to use his google calendar data with the app. - ''' + """ user = get_current_user(session) # getting active user @@ -33,5 +35,5 @@ async def google_sync(request: Request, # fetch and save the events com from Google Calendar fetch_save_events(credentials=credentials, user=user, session=session) - url = profile.url_path_for('profile') + url = profile.url_path_for("profile") return RedirectResponse(url=url) diff --git a/app/routers/invitation.py b/app/routers/invitation.py deleted file mode 100644 index da2ba209..00000000 --- a/app/routers/invitation.py +++ /dev/null @@ -1,110 +0,0 @@ -from typing import Any, List, Optional - -from fastapi import APIRouter, Depends, Request, status -from fastapi.responses import RedirectResponse, Response -from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import Session - -from app.database.models import Invitation -from app.dependencies import get_db, templates -from app.routers.share import accept - -router = APIRouter( - prefix="/invitations", - tags=["invitation"], - dependencies=[Depends(get_db)], -) - - -@router.get("/", include_in_schema=False) -def view_invitations( - request: Request, db: Session = Depends(get_db) -) -> Response: - """Returns the Invitations page route. - - Args: - request: The HTTP request. - db: Optional; The database connection. - - Returns: - The Invitations HTML page. - """ - return templates.TemplateResponse("invitations.html", { - "request": request, - # TODO: Connect to current user. - # recipient_id should be the current user - # but because we don't have one yet, - # "get_all_invitations" returns all invitations - "invitations": get_all_invitations(db), - }) - - -@router.post("/", include_in_schema=False) -async def accept_invitations( - request: Request, db: Session = Depends(get_db) -) -> RedirectResponse: - """Creates a new connection between the User and the Event in the database. - - See Also: - share.accept for more information. - - Args: - request: The HTTP request. - db: Optional; The database connection. - - Returns: - An updated Invitations HTML page. - """ - data = await request.form() - invite_id = list(data.values())[0] - - invitation = get_invitation_by_id(invite_id, db) - if invitation: - accept(invitation, db) - - url = router.url_path_for("view_invitations") - return RedirectResponse(url=url, status_code=status.HTTP_302_FOUND) - - -# TODO: should be a get request with the path of: -# @router.get("/all") -@router.get("/get_all_invitations") -def get_all_invitations( - db: Session = Depends(get_db), **param: Any -) -> List[Invitation]: - """Returns all Invitations filtered by the requested parameters. - - Args: - db: Optional; The database connection. - **param: A list of parameters to filter by. - - Returns: - A list of all Invitations. - """ - try: - invitations = list(db.query(Invitation).filter_by(**param)) - except SQLAlchemyError: - return [] - else: - return invitations - - -# TODO: should be a get request with the path of: -# @router.get("/{id}") -@router.post("/get_invitation_by_id") -def get_invitation_by_id( - invitation_id: int, db: Session = Depends(get_db) -) -> Optional[Invitation]: - """Returns an Invitation by an ID. - - Args: - invitation_id: The Invitation ID. - db: Optional; The database connection. - - Returns: - An Invitation object if found, otherwise returns None. - """ - return (db.query(Invitation) - .filter_by(id=invitation_id) - .first() - ) diff --git a/app/routers/joke.py b/app/routers/joke.py index 07b7b453..f35dfae9 100644 --- a/app/routers/joke.py +++ b/app/routers/joke.py @@ -1,8 +1,8 @@ from fastapi import APIRouter, Depends, Request -from app.internal import jokes from sqlalchemy.orm import Session -from app.dependencies import get_db +from app.dependencies import get_db +from app.internal import jokes router = APIRouter() diff --git a/app/routers/login.py b/app/routers/login.py index 99fd5b5c..d4d11400 100644 --- a/app/routers/login.py +++ b/app/routers/login.py @@ -3,13 +3,11 @@ from fastapi import APIRouter, Depends, Request from sqlalchemy.orm import Session from starlette.responses import RedirectResponse -from starlette.status import HTTP_302_FOUND from app.dependencies import get_db, templates -from app.internal.security.ouath2 import ( - authenticate_user, create_jwt_token) from app.internal.security import schema - +from app.internal.security.ouath2 import authenticate_user, create_jwt_token +from app.internal.utils import safe_redirect_response router = APIRouter( prefix="", @@ -20,21 +18,23 @@ @router.get("/login") async def login_user_form( - request: Request, message: Optional[str] = "") -> templates: + request: Request, + message: Optional[str] = "", +) -> templates: """rendering login route get method""" - return templates.TemplateResponse("login.html", { - "request": request, - "message": message, - 'current_user': "logged in" - }) + return templates.TemplateResponse( + "login.html", + {"request": request, "message": message, "current_user": "logged in"}, + ) -@router.post('/login') +@router.post("/login") async def login( - request: Request, - next: Optional[str] = "/", - db: Session = Depends(get_db), - existing_jwt: Union[str, bool] = False) -> RedirectResponse: + request: Request, + next: Optional[str] = "/", + db: Session = Depends(get_db), + existing_jwt: Union[str, bool] = False, +) -> RedirectResponse: """rendering login route post method.""" form = await request.form() form_dict = dict(form) @@ -49,19 +49,17 @@ async def login( if user: user = await authenticate_user(db, user) if not user: - return templates.TemplateResponse("login.html", { - "request": request, - "message": 'Please check your credentials' - }) + return templates.TemplateResponse( + "login.html", + {"request": request, "message": "Please check your credentials"}, + ) # creating HTTPONLY cookie with jwt-token out of user unique data # for testing if not existing_jwt: jwt_token = create_jwt_token(user) else: jwt_token = existing_jwt - if not next.startswith("/"): - next = "/" - response = RedirectResponse(next, status_code=HTTP_302_FOUND) + response = safe_redirect_response(next) response.set_cookie( "Authorization", value=jwt_token, diff --git a/app/routers/logout.py b/app/routers/logout.py index 009de760..4f52825f 100644 --- a/app/routers/logout.py +++ b/app/routers/logout.py @@ -2,7 +2,6 @@ from starlette.responses import RedirectResponse from starlette.status import HTTP_302_FOUND - router = APIRouter( prefix="", tags=["/logout"], @@ -10,7 +9,7 @@ ) -@router.get('/logout') +@router.get("/logout") async def logout(request: Request): response = RedirectResponse(url="/login", status_code=HTTP_302_FOUND) response.delete_cookie("Authorization") diff --git a/app/routers/meds.py b/app/routers/meds.py new file mode 100644 index 00000000..d9449706 --- /dev/null +++ b/app/routers/meds.py @@ -0,0 +1,65 @@ +from datetime import date, time, timedelta + +from fastapi import APIRouter +from fastapi.param_functions import Depends +from sqlalchemy.orm.session import Session +from starlette.requests import Request +from starlette.responses import RedirectResponse, Response +from starlette.status import HTTP_303_SEE_OTHER + +from app.dependencies import get_db, templates +from app.internal import meds +from app.internal.utils import get_current_user +from app.main import app + +router = APIRouter( + prefix="/meds", + tags=["meds"], + dependencies=[Depends(get_db)], +) + + +@router.get("/") +@router.post("/") +async def medications( + request: Request, + session: Session = Depends(get_db), +) -> Response: + """Renders medication reminders creation form page. Creates reminders in DB + and redirects to home page upon submition if valid.""" + form = await request.form() + errors = [] + + form_data = { + "name": "", + "start": date.today(), + "first": None, + "end": date.today() + timedelta(days=7), + "amount": 1, + "early": time(8), + "late": time(22), + "min": time(0, 1), + "max": time(23, 59), + "note": "", + } + + if form: + form, form_data = meds.trans_form(form) + user = get_current_user(session) + errors = meds.validate_form(form) + if not errors: + meds.create_events(session, user.id, form) + return RedirectResponse( + app.url_path_for("home"), + status_code=HTTP_303_SEE_OTHER, + ) + + return templates.TemplateResponse( + "meds.j2", + { + "request": request, + "errors": errors, + "data": form_data, + "quantity": meds.MAX_EVENT_QUANTITY, + }, + ) diff --git a/app/routers/notification.py b/app/routers/notification.py new file mode 100644 index 00000000..540016a5 --- /dev/null +++ b/app/routers/notification.py @@ -0,0 +1,191 @@ +from fastapi import APIRouter, Depends, Form, Request +from sqlalchemy.orm import Session + +from app.database.models import MessageStatusEnum +from app.dependencies import get_db, templates +from app.internal.notification import ( + get_all_messages, + get_archived_notifications, + get_invitation_by_id, + get_message_by_id, + get_unread_notifications, + is_owner, + raise_wrong_id_error, +) +from app.internal.security.dependencies import current_user, is_logged_in +from app.internal.security.schema import CurrentUser +from app.internal.utils import safe_redirect_response + +router = APIRouter( + prefix="/notification", + tags=["notification"], + dependencies=[ + Depends(get_db), + Depends(is_logged_in), + ], +) + + +@router.get("/", include_in_schema=False) +async def view_notifications( + request: Request, + user: CurrentUser = Depends(current_user), + db: Session = Depends(get_db), +): + """Returns the Notifications page. + + Args: + request: The HTTP request. + db: Optional; The database connection. + user: user schema object. + + Returns: + The Notifications HTML page. + """ + return templates.TemplateResponse( + "notifications.html", + { + "request": request, + "new_messages": bool(get_all_messages), + "notifications": list( + get_unread_notifications( + session=db, + user_id=user.user_id, + ), + ), + }, + ) + + +@router.get("/archive", include_in_schema=False) +async def view_archive( + request: Request, + user: CurrentUser = Depends(current_user), + db: Session = Depends(get_db), +): + """Returns the Archived Notifications page. + + Args: + request: The HTTP request. + db: Optional; The database connection. + user: user schema object. + + Returns: + The Archived Notifications HTML page. + """ + return templates.TemplateResponse( + "archive.html", + { + "request": request, + "notifications": list( + get_archived_notifications( + session=db, + user_id=user.user_id, + ), + ), + }, + ) + + +@router.post("/invitation/accept") +async def accept_invitations( + invite_id: int = Form(...), + next_url: str = Form(...), + db: Session = Depends(get_db), + user: CurrentUser = Depends(current_user), +): + """Creates a new connection between the User and the Event in the database. + + See Also: + models.Invitation.accept for more information. + + Args: + invite_id: the id of the invitation. + next_url: url to redirect to. + db: Optional; The database connection. + user: user schema object. + + Returns: + A redirect to where the user called the route from. + """ + invitation = get_invitation_by_id(invite_id, session=db) + if invitation and is_owner(user, invitation): + invitation.accept(db) + return safe_redirect_response(next_url) + + raise_wrong_id_error() + + +@router.post("/invitation/decline") +async def decline_invitations( + invite_id: int = Form(...), + next_url: str = Form(...), + db: Session = Depends(get_db), + user: CurrentUser = Depends(current_user), +): + """Declines an invitations. + + Args: + invite_id: the id of the invitation. + db: Optional; The database connection. + next_url: url to redirect to. + user: user schema object. + + Returns: + A redirect to where the user called the route from. + """ + invitation = get_invitation_by_id(invite_id, session=db) + if invitation and is_owner(user, invitation): + invitation.decline(db) + return safe_redirect_response(next_url) + + raise_wrong_id_error() + + +@router.post("/message/read") +async def mark_message_as_read( + message_id: int = Form(...), + next_url: str = Form(...), + db: Session = Depends(get_db), + user: CurrentUser = Depends(current_user), +): + """Marks a message as read. + + Args: + message_id: the id of the message. + db: Optional; The database connection. + next_url: url to redirect to. + user: user schema object. + + Returns: + A redirect to where the user called the route from. + """ + message = await get_message_by_id(message_id, session=db) + if message and is_owner(user, message): + message.mark_as_read(db) + return safe_redirect_response(next_url) + + raise_wrong_id_error() + + +@router.post("/message/read/all") +async def mark_all_as_read( + next_url: str = Form(...), + db: Session = Depends(get_db), + user: CurrentUser = Depends(current_user), +): + """Marks all messages as read. + + Args: + next_url: url to redirect to. + user: user schema object. + db: Optional; The database connection. + + Returns: + A redirect to where the user called the route from. + """ + for message in get_all_messages(db, user.user_id): + if message.status == MessageStatusEnum.UNREAD: + message.mark_as_read(db) + + return safe_redirect_response(next_url) diff --git a/app/routers/profile.py b/app/routers/profile.py index 08eee5c1..18af1f66 100644 --- a/app/routers/profile.py +++ b/app/routers/profile.py @@ -1,26 +1,30 @@ import io -from loguru import logger from fastapi import APIRouter, Depends, File, Request, UploadFile +from loguru import logger from PIL import Image +from sqlalchemy.exc import SQLAlchemyError from starlette.responses import RedirectResponse from starlette.status import HTTP_302_FOUND -from sqlalchemy.exc import SQLAlchemyError from app import config -from app.database.models import User, Event -from app.dependencies import get_db, MEDIA_PATH, templates, GOOGLE_ERROR -from app.internal.security.dependancies import current_user, schema -from app.internal.on_this_day_events import get_on_this_day_events +from app.database.models import Event, User +from app.dependencies import GOOGLE_ERROR, MEDIA_PATH, get_db, templates +from app.internal.corona_stats import get_corona_stats from app.internal.import_holidays import ( get_holidays_from_file, save_holidays_to_db, ) -from app.internal.restore_events import get_event_ids +from app.internal.on_this_day_events import get_on_this_day_events from app.internal.privacy import PrivacyKinds +from app.internal.restore_events import get_event_ids +from app.internal.security.dependencies import current_user, schema +from app.internal.showevent import get_upcoming_events PICTURE_EXTENSION = config.PICTURE_EXTENSION PICTURE_SIZE = config.AVATAR_SIZE +FIVE_EVENTS = 5 +# We are presenting up to five upcoming events on the profile page router = APIRouter( prefix="/profile", @@ -46,13 +50,12 @@ async def profile( session=Depends(get_db), new_user=Depends(get_placeholder_user), ): - # Get relevant data from database - upcoming_events = range(5) user = session.query(User).filter_by(id=1).first() if not user: session.add(new_user) session.commit() user = session.query(User).filter_by(id=1).first() + upcoming_events = get_upcoming_events(session, user.id)[:FIVE_EVENTS] signs = [ "Aries", @@ -68,7 +71,9 @@ async def profile( "Aquarius", "Pisces", ] + on_this_day_data = get_on_this_day_events(session) + corona_stats_data = await get_corona_stats(session) return templates.TemplateResponse( "profile.html", @@ -79,6 +84,7 @@ async def profile( "signs": signs, "google_error": GOOGLE_ERROR, "on_this_day_data": on_this_day_data, + "corona_stats_data": corona_stats_data, "privacy": PrivacyKinds, }, ) diff --git a/app/routers/register.py b/app/routers/register.py index 57f77165..11927359 100644 --- a/app/routers/register.py +++ b/app/routers/register.py @@ -7,11 +7,10 @@ from starlette.status import HTTP_302_FOUND from starlette.templating import _TemplateResponse -from app.internal.security.ouath2 import get_hashed_password -from app.database import schemas -from app.database import models +from app.database import models, schemas from app.dependencies import get_db, templates - +from app.internal.security.ouath2 import get_hashed_password +from app.internal.utils import save router = APIRouter( prefix="", @@ -20,6 +19,13 @@ ) +def _create_user(session, **kw) -> models.User: + """Creates and saves a new user.""" + user = models.User(**kw) + save(session, user) + return user + + async def create_user(db: Session, user: schemas.UserCreate) -> models.User: """ creating a new User object in the database, with hashed password @@ -32,12 +38,10 @@ async def create_user(db: Session, user: schemas.UserCreate) -> models.User: "email": user.email, "password": hashed_password, "description": user.description, + "language_id": user.language_id, + "target_weight": user.target_weight, } - db_user = models.User(**user_details) - db.add(db_user) - db.commit() - db.refresh(db_user) - return db_user + return _create_user(**user_details, session=db) async def check_unique_fields( diff --git a/app/routers/reset_password.py b/app/routers/reset_password.py new file mode 100644 index 00000000..9a8d3444 --- /dev/null +++ b/app/routers/reset_password.py @@ -0,0 +1,137 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, Request +from pydantic import ValidationError +from sqlalchemy.orm import Session +from starlette.responses import RedirectResponse +from starlette.status import HTTP_302_FOUND + +from app.dependencies import get_db, templates +from app.internal.email import BackgroundTasks, send_reset_password_mail +from app.internal.security.ouath2 import ( + create_jwt_token, + get_jwt_token, + is_email_compatible_to_username, + update_password, +) +from app.internal.security.schema import ForgotPassword, ResetPassword +from app.routers.login import router as login_router + +router = APIRouter( + prefix="", + tags=["/reset_password"], + responses={404: {"description": "Not found"}}, +) + + +@router.get("/forgot-password") +async def forgot_password_form(request: Request) -> templates: + """rendering forgot password form get method""" + return templates.TemplateResponse( + "forgot_password.html", + { + "request": request, + }, + ) + + +@router.post("/forgot-password") +async def forgot_password( + request: Request, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), +) -> templates: + """ + Validaiting form data fields. + Validaiting form data against database records. + If all validations succeed, creating jwt token, + then sending email to the user with a reset password route link. + The contains the verafiction jwt token. + """ + form = await request.form() + form_dict = dict(form) + form_dict["username"] = "@" + form_dict["username"] + try: + # validating form data by creating pydantic schema object + user = ForgotPassword(**form_dict) + except ValidationError: + return templates.TemplateResponse( + "forgot_password.html", + {"request": request, "message": "Please check your credentials"}, + ) + user = await is_email_compatible_to_username(db, user) + if not user: + return templates.TemplateResponse( + "forgot_password.html", + {"request": request, "message": "Please check your credentials"}, + ) + user.email_verification_token = create_jwt_token(user, jwt_min_exp=15) + await send_reset_password_mail(user, background_tasks) + return templates.TemplateResponse( + "forgot_password.html", + { + "request": request, + "message": "Email for reseting password was sent", + }, + ) + + +@router.get("/reset-password") +async def reset_password_form( + request: Request, + email_verification_token: Optional[str] = "", +) -> templates: + """ + Rendering reset password form get method. + Validating jwt token is supplied with request. + """ + if email_verification_token: + return templates.TemplateResponse( + "reset_password.html", + { + "request": request, + }, + ) + message = "?message=Verification token is missing" + return RedirectResponse( + login_router.url_path_for("login_user_form") + f"{message}", + status_code=HTTP_302_FOUND, + ) + + +@router.post("/reset-password") +async def reset_password( + request: Request, + email_verification_token: str = "", + db: Session = Depends(get_db), +) -> RedirectResponse: + """ + Receives email verification jwt token. + Receives form data, and validates all fields are correct. + Validating token. + validatting form data against token details. + If all validations succeed, hashing new password and updating database. + """ + jwt_payload = get_jwt_token(email_verification_token) + jwt_username = jwt_payload.get("sub").strip("@") + form = await request.form() + form_dict = dict(form) + validated = True + if not form_dict["username"] == jwt_username: + validated = False + try: + # validating form data by creating pydantic schema object + user = ResetPassword(**form_dict) + except ValueError: + validated = False + if not validated: + return templates.TemplateResponse( + "reset_password.html", + {"request": request, "message": "Please check your credentials"}, + ) + await update_password(db, jwt_username, user.password) + message = "?message=Success reset password" + return RedirectResponse( + login_router.url_path_for("login_user_form") + str(message), + status_code=HTTP_302_FOUND, + ) diff --git a/app/routers/salary/config.py b/app/routers/salary/config.py index e7587207..490e9156 100644 --- a/app/routers/salary/config.py +++ b/app/routers/salary/config.py @@ -20,5 +20,3 @@ HOURS_SECONDS_RATIO = 3600 NUMERIC = Union[float, int] -HOUR_FORMAT = '%H:%M:%S' -ALT_HOUR_FORMAT = '%H:%M' diff --git a/app/routers/salary/routes.py b/app/routers/salary/routes.py index 72a8d262..f452f0b6 100644 --- a/app/routers/salary/routes.py +++ b/app/routers/salary/routes.py @@ -8,12 +8,16 @@ from app.database.models import SalarySettings from app.dependencies import get_db, templates -from app.internal.utils import create_model, get_current_user +from app.internal.utils import ( + create_model, + get_current_user, + get_time_from_string, +) from app.routers.salary import utils router = APIRouter( - prefix='/salary', - tags=['salary'], + prefix="/salary", + tags=["salary"], dependencies=[Depends(get_db)], ) @@ -22,10 +26,10 @@ def get_user_categories() -> Dict[int, str]: """Mock function for user relevant category search.""" # Code revision required after categories feature is added return { - 1: 'Workout', - 17: 'Flight', - 42: 'Going to the Movies', - 666: 'Lucy\'s Inferno', + 1: "Workout", + 17: "Flight", + 42: "Going to the Movies", + 666: "Lucy's Inferno", } @@ -33,15 +37,18 @@ def get_holiday_categories() -> Dict[int, str]: """Mock function for user relevant holiday category search.""" # Code revision required after holiday times feature is added return { - 1: 'Israel - Jewish', - 3: 'Iraq - Muslim', - 17: 'Cuba - Santeria', - 666: 'Hell - Satanist', + 1: "Israel - Jewish", + 3: "Iraq - Muslim", + 17: "Cuba - Santeria", + 666: "Hell - Satanist", } -def get_salary_categories(session: Session, user_id: int, - existing: bool = True) -> Dict[int, str]: +def get_salary_categories( + session: Session, + user_id: int, + existing: bool = True, +) -> Dict[int, str]: """Returns a dict of all categories the user has created salary settings for. If `existing` is False, a dict with all the categories the user has defined but yet to create a salary setting for is returned. @@ -72,21 +79,23 @@ def get_salary_categories(session: Session, user_id: int, return categories -@router.get('/') +@router.get("/") def salary_home(session: Session = Depends(get_db)) -> Response: """Redirects user to salary view page if any salary settings exist, and to settings creation page otherwise.""" user = get_current_user(session) if get_salary_categories(session, user.id): - return RedirectResponse(router.url_path_for('pick_category')) + return RedirectResponse(router.url_path_for("pick_category")) - return RedirectResponse(router.url_path_for('create_settings')) + return RedirectResponse(router.url_path_for("create_settings")) -@router.post('/new') -@router.get('/new') -async def create_settings(request: Request, - session: Session = Depends(get_db)) -> Response: +@router.post("/new") +@router.get("/new") +async def create_settings( + request: Request, + session: Session = Depends(get_db), +) -> Response: """Renders a salary settings creation page with all available user related categories and default settings. Creates salary settings according to form and redirects to salary view page upon submition.""" @@ -101,43 +110,49 @@ async def create_settings(request: Request, form = await request.form() if form: - category_id = int(form['category_id']) + category_id = int(form["category_id"]) settings = { - 'user_id': user.id, - 'category_id': category_id, - 'wage': form['wage'], - 'off_day': form['off_day'], - 'holiday_category_id': form['holiday_category_id'], - 'regular_hour_basis': form['regular_hour_basis'], - 'night_hour_basis': form['night_hour_basis'], - 'night_start': utils.get_time_from_string(form['night_start']), - 'night_end': utils.get_time_from_string(form['night_end']), - 'night_min_len': utils.get_time_from_string(form['night_min_len']), - 'first_overtime_amount': form['first_overtime_amount'], - 'first_overtime_pay': form['first_overtime_pay'], - 'second_overtime_pay': form['second_overtime_pay'], - 'week_working_hours': form['week_working_hours'], - 'daily_transport': form['daily_transport'], + "user_id": user.id, + "category_id": category_id, + "wage": form["wage"], + "off_day": form["off_day"], + "holiday_category_id": form["holiday_category_id"], + "regular_hour_basis": form["regular_hour_basis"], + "night_hour_basis": form["night_hour_basis"], + "night_start": get_time_from_string(form["night_start"]), + "night_end": get_time_from_string(form["night_end"]), + "night_min_len": get_time_from_string(form["night_min_len"]), + "first_overtime_amount": form["first_overtime_amount"], + "first_overtime_pay": form["first_overtime_pay"], + "second_overtime_pay": form["second_overtime_pay"], + "week_working_hours": form["week_working_hours"], + "daily_transport": form["daily_transport"], } create_model(session, SalarySettings, **settings) - return RedirectResponse(router.url_path_for( - 'view_salary', category_id=str(category_id))) - - return templates.TemplateResponse('salary/settings.j2', { - 'request': request, - 'wage': wage, - 'categories': categories, - 'holidays': holidays - }) - + return RedirectResponse( + router.url_path_for("view_salary", category_id=str(category_id)), + ) -@router.post('/edit') -@router.get('/edit') -async def pick_settings(request: Request, - session: Session = Depends(get_db)) -> Response: + return templates.TemplateResponse( + "salary/settings.j2", + { + "request": request, + "wage": wage, + "categories": categories, + "holidays": holidays, + }, + ) + + +@router.post("/edit") +@router.get("/edit") +async def pick_settings( + request: Request, + session: Session = Depends(get_db), +) -> Response: """Renders a category salary settings edit choice page, redirects to the relevant salary settings edit page upon submition, or to settings creation page if none exist.""" @@ -148,24 +163,31 @@ async def pick_settings(request: Request, categories = get_salary_categories(session, user.id) if form: - category = form['category_id'] - return RedirectResponse(router.url_path_for('edit_settings', - category_id=category)) + category = form["category_id"] + return RedirectResponse( + router.url_path_for("edit_settings", category_id=category), + ) if categories: - return templates.TemplateResponse('salary/pick.j2', { - 'request': request, - 'categories': categories, - 'edit': True, - }) + return templates.TemplateResponse( + "salary/pick.j2", + { + "request": request, + "categories": categories, + "edit": True, + }, + ) - return RedirectResponse(router.url_path_for('create_settings')) + return RedirectResponse(router.url_path_for("create_settings")) -@router.post('/edit/{category_id}') -@router.get('/edit/{category_id}') -async def edit_settings(request: Request, category_id: int, - session: Session = Depends(get_db)) -> Response: +@router.post("/edit/{category_id}") +@router.get("/edit/{category_id}") +async def edit_settings( + request: Request, + category_id: int, + session: Session = Depends(get_db), +) -> Response: """Renders a salary settings edit page for setting corresponding to logged-in user and `category_id`. Edits the salary settings according to form and redirects to month choice pre calculation display page upon @@ -184,29 +206,35 @@ async def edit_settings(request: Request, category_id: int, category = get_user_categories()[category_id] except (AttributeError, KeyError): - return RedirectResponse(router.url_path_for('pick_settings')) + return RedirectResponse(router.url_path_for("pick_settings")) if utils.update_settings(session, wage, form): - return RedirectResponse(router.url_path_for( - 'view_salary', category_id=str(category_id))) + return RedirectResponse( + router.url_path_for("view_salary", category_id=str(category_id)), + ) else: if wage: - return templates.TemplateResponse('salary/settings.j2', { - 'request': request, - 'wage': wage, - 'category': category, - 'category_id': category_id, - 'holidays': holidays - }) - - return RedirectResponse(router.url_path_for('pick_settings')) - - -@router.post('/view') -@router.get('/view') -async def pick_category(request: Request, - session: Session = Depends(get_db)) -> Response: + return templates.TemplateResponse( + "salary/settings.j2", + { + "request": request, + "wage": wage, + "category": category, + "category_id": category_id, + "holidays": holidays, + }, + ) + + return RedirectResponse(router.url_path_for("pick_settings")) + + +@router.post("/view") +@router.get("/view") +async def pick_category( + request: Request, + session: Session = Depends(get_db), +) -> Response: """Renders a category salary calculation view choice page, redirects to the relevant salary calculation view page upon submition, or to settings creation page if no salary settings exist.""" @@ -217,23 +245,30 @@ async def pick_category(request: Request, categories = get_salary_categories(session, user.id) if form: - category = form['category_id'] - return RedirectResponse(router.url_path_for('view_salary', - category_id=category)) + category = form["category_id"] + return RedirectResponse( + router.url_path_for("view_salary", category_id=category), + ) if categories: - return templates.TemplateResponse('salary/pick.j2', { - 'request': request, - 'categories': categories, - }) + return templates.TemplateResponse( + "salary/pick.j2", + { + "request": request, + "categories": categories, + }, + ) - return RedirectResponse(router.url_path_for('create_settings')) + return RedirectResponse(router.url_path_for("create_settings")) -@router.post('/view/{category_id}') -@router.get('/view/{category_id}') -async def view_salary(request: Request, category_id: int, - session: Session = Depends(get_db)) -> Response: +@router.post("/view/{category_id}") +@router.get("/view/{category_id}") +async def view_salary( + request: Request, + category_id: int, + session: Session = Depends(get_db), +) -> Response: """Renders month choice pre calculation display page. Overtime, additions & deductions to be calculated can be provided. Displays calculation details upon submition. Redirects to category salary calculation view choice page @@ -248,45 +283,55 @@ async def view_salary(request: Request, category_id: int, category = get_user_categories()[category_id] except KeyError: - return RedirectResponse(router.url_path_for('pick_category')) + return RedirectResponse(router.url_path_for("pick_category")) try: # try block prevents crashing upon redirection to the page. try: - overtime = form['overtime'] + overtime = form["overtime"] except KeyError: - overtime = '' + overtime = "" - year, month = map(int, form['month'].split('-')) - month_name = date(1, month, 1).strftime('%b') + year, month = map(int, form["month"].split("-")) + month_name = date(1, month, 1).strftime("%b") salary = utils.calc_salary( - year=year, month=month, wage=wage, overtime=bool(overtime), - deduction=int(form['deduction']), bonus=int(form['bonus']), + year=year, + month=month, + wage=wage, + overtime=bool(overtime), + deduction=int(form["deduction"]), + bonus=int(form["bonus"]), ) - return templates.TemplateResponse('salary/view.j2', { - 'request': request, - 'category': category, - 'category_id': category_id, - 'month': month_name, - 'salary': salary, - 'wage': wage - }) + return templates.TemplateResponse( + "salary/view.j2", + { + "request": request, + "category": category, + "category_id": category_id, + "month": month_name, + "salary": salary, + "wage": wage, + }, + ) except KeyError: if wage: shifts = utils.get_event_by_category(category_id=category_id) start_date = shifts[0].start end_date = shifts[-1].start - start = f'{start_date.year}-{str(start_date.month).zfill(2)}' - end = f'{end_date.year}-{str(end_date.month).zfill(2)}' - - return templates.TemplateResponse('salary/month.j2', { - 'request': request, - 'category': category, - 'category_id': category_id, - 'start': start, - 'end': end, - }) - - return RedirectResponse(router.url_path_for('pick_category')) + start = f"{start_date.year}-{str(start_date.month).zfill(2)}" + end = f"{end_date.year}-{str(end_date.month).zfill(2)}" + + return templates.TemplateResponse( + "salary/month.j2", + { + "request": request, + "category": category, + "category_id": category_id, + "start": start, + "end": end, + }, + ) + + return RedirectResponse(router.url_path_for("pick_category")) diff --git a/app/routers/salary/utils.py b/app/routers/salary/utils.py index aa922ed2..88d00f8b 100644 --- a/app/routers/salary/utils.py +++ b/app/routers/salary/utils.py @@ -4,7 +4,7 @@ from sqlalchemy.orm.session import Session from app.database.models import Event, SalarySettings -from app.internal.utils import save +from app.internal.utils import get_time_from_string, save from app.routers.salary import config DEFAULT_SETTINGS = SalarySettings( @@ -39,8 +39,11 @@ def get_shift_len(start: datetime, end: datetime) -> float: return (end - start).seconds / config.HOURS_SECONDS_RATIO -def get_night_times(date: datetime, wage: SalarySettings, - prev_day: bool = False) -> Tuple[datetime, datetime]: +def get_night_times( + date: datetime, + wage: SalarySettings, + prev_day: bool = False, +) -> Tuple[datetime, datetime]: """Returns the start and end times of night for the given date. Args: @@ -57,12 +60,17 @@ def get_night_times(date: datetime, wage: SalarySettings, None """ sub = timedelta(1 if prev_day else 0) - return (datetime.combine(date - sub, wage.night_start), - datetime.combine(date + timedelta(1) - sub, wage.night_end)) + return ( + datetime.combine(date - sub, wage.night_start), + datetime.combine(date + timedelta(days=1) - sub, wage.night_end), + ) -def is_night_shift(start: datetime, end: datetime, - wage: SalarySettings) -> bool: +def is_night_shift( + start: datetime, + end: datetime, + wage: SalarySettings, +) -> bool: """Returns True if shift is a night shift, False otherwise. Args: @@ -79,14 +87,18 @@ def is_night_shift(start: datetime, end: datetime, return False for boolean in (False, True): night_start, night_end = get_night_times(start, wage, boolean) - if (get_total_synchronous_hours(start, end, night_start, night_end) - >= wage.first_overtime_amount): + if ( + get_total_synchronous_hours(start, end, night_start, night_end) + >= wage.first_overtime_amount + ): return True return False def get_relevant_holiday_times( - start: datetime, end: datetime, wage: SalarySettings + start: datetime, + end: datetime, + wage: SalarySettings, ) -> Tuple[datetime, datetime]: """Returns start and end of holiday times that synchronize with the given times, based on the the supplied salary settings. @@ -119,16 +131,19 @@ def get_relevant_holiday_times( elif end.weekday() == wage.off_day: date = end.date() try: - return (datetime.combine(date, time(0)), - datetime.combine(date + timedelta(1), - time(0))) + return ( + datetime.combine(date, time(0)), + datetime.combine(date + timedelta(days=1), time(0)), + ) except NameError: return datetime.min, datetime.min def get_total_synchronous_hours( - event_1_start: datetime, event_1_end: datetime, - event_2_start: datetime, event_2_end: datetime + event_1_start: datetime, + event_1_end: datetime, + event_2_start: datetime, + event_2_end: datetime, ) -> float: """Returns the total amount of hours that are shared between both events. @@ -151,8 +166,11 @@ def get_total_synchronous_hours( return (earliest_end - latest_start).seconds / config.HOURS_SECONDS_RATIO -def get_hour_basis(start: datetime, end: datetime, - wage: SalarySettings) -> float: +def get_hour_basis( + start: datetime, + end: datetime, + wage: SalarySettings, +) -> float: """Returns the shift's base hours, not qualifying for overtime. Args: @@ -171,8 +189,11 @@ def get_hour_basis(start: datetime, end: datetime, return wage.regular_hour_basis -def calc_overtime_hours(start: datetime, end: datetime, - wage: SalarySettings) -> Tuple[float, float]: +def calc_overtime_hours( + start: datetime, + end: datetime, + wage: SalarySettings, +) -> Tuple[float, float]: """Returns a tuple of the total hours of the shift adjusted for overtime, and the total overtime hours. @@ -196,14 +217,21 @@ def calc_overtime_hours(start: datetime, end: datetime, temp = hour_basis if overtime <= wage.first_overtime_amount: return temp + overtime * wage.first_overtime_pay, overtime - return temp + ((overtime - wage.first_overtime_amount) - * wage.second_overtime_pay - + wage.first_overtime_amount - * wage.first_overtime_pay), overtime - - -def get_hours_during_holiday(start: datetime, end: datetime, - wage: SalarySettings) -> float: + return ( + temp + + ( + (overtime - wage.first_overtime_amount) * wage.second_overtime_pay + + wage.first_overtime_amount * wage.first_overtime_pay + ), + overtime, + ) + + +def get_hours_during_holiday( + start: datetime, + end: datetime, + wage: SalarySettings, +) -> float: """Returns the total amount of hours of the shifts that are synchronous with an holiday. @@ -219,13 +247,15 @@ def get_hours_during_holiday(start: datetime, end: datetime, Raises: None """ - holiday_start, holiday_end = get_relevant_holiday_times( - start, end, wage) + holiday_start, holiday_end = get_relevant_holiday_times(start, end, wage) return get_total_synchronous_hours(start, end, holiday_start, holiday_end) -def adjust_overtime(start: datetime, end: datetime, - wage: SalarySettings) -> Tuple[float, float]: +def adjust_overtime( + start: datetime, + end: datetime, + wage: SalarySettings, +) -> Tuple[float, float]: """Returns a tuple of the total hours of the shift adjusted for overtime and holidays, and the total overtime hours. @@ -247,8 +277,11 @@ def adjust_overtime(start: datetime, end: datetime, return (total_hours, overtime) -def calc_shift_salary(start: datetime, end: datetime, - wage: SalarySettings) -> float: +def calc_shift_salary( + start: datetime, + end: datetime, + wage: SalarySettings, +) -> float: """Returns the total salary for the given shift, including overtime. Args: @@ -265,8 +298,10 @@ def calc_shift_salary(start: datetime, end: datetime, return round(adjust_overtime(start, end, wage)[0] * wage.wage, 2) -def calc_weekly_overtime(shifts: Tuple[Event, ...], - wage: SalarySettings) -> float: +def calc_weekly_overtime( + shifts: Tuple[Event, ...], + wage: SalarySettings, +) -> float: """Returns the weekly overtime amount for the supplied shifts. Weekly overtime is calculated only for hours exceeding the standard week @@ -284,15 +319,20 @@ def calc_weekly_overtime(shifts: Tuple[Event, ...], Raises: None """ - total_week_hours = sum(get_shift_len(shift.start, shift.end) - for shift in shifts) + total_week_hours = sum( + get_shift_len(shift.start, shift.end) for shift in shifts + ) if total_week_hours <= wage.week_working_hours: return 0.0 - total_daily_overtime = sum(map(lambda shift: adjust_overtime( - shift.start, shift.end, wage)[1], shifts)) - overtime = (total_week_hours - - wage.week_working_hours - - total_daily_overtime) + total_daily_overtime = sum( + map( + lambda shift: adjust_overtime(shift.start, shift.end, wage)[1], + shifts, + ), + ) + overtime = ( + total_week_hours - wage.week_working_hours - total_daily_overtime + ) if overtime > 0: return round(overtime * wage.wage, 2) return 0.0 @@ -301,18 +341,30 @@ def calc_weekly_overtime(shifts: Tuple[Event, ...], def get_event_by_category(*args, **kwargs): """Mock function for event by category search.""" # Code revision required after categories feature is added - day_1 = Event(start=datetime(2021, 1, 10, 9), - end=datetime(2021, 1, 10, 19)) - day_2 = Event(start=datetime(2021, 1, 11, 9), - end=datetime(2021, 1, 11, 17)) - day_3 = Event(start=datetime(2021, 1, 12, 9), - end=datetime(2021, 1, 12, 17)) - day_4 = Event(start=datetime(2021, 1, 13, 9), - end=datetime(2021, 1, 13, 18)) - day_5 = Event(start=datetime(2021, 1, 14, 9), - end=datetime(2021, 1, 14, 17)) - day_6 = Event(start=datetime(2021, 1, 15, 9), - end=datetime(2021, 1, 15, 14, 58)) + day_1 = Event( + start=datetime(2021, 1, 10, 9), + end=datetime(2021, 1, 10, 19), + ) + day_2 = Event( + start=datetime(2021, 1, 11, 9), + end=datetime(2021, 1, 11, 17), + ) + day_3 = Event( + start=datetime(2021, 1, 12, 9), + end=datetime(2021, 1, 12, 17), + ) + day_4 = Event( + start=datetime(2021, 1, 13, 9), + end=datetime(2021, 1, 13, 18), + ) + day_5 = Event( + start=datetime(2021, 1, 14, 9), + end=datetime(2021, 1, 14, 17), + ) + day_6 = Event( + start=datetime(2021, 1, 15, 9), + end=datetime(2021, 1, 15, 14, 58), + ) return (day_1, day_2, day_3, day_4, day_5, day_6) @@ -339,8 +391,10 @@ def get_month_times(year: int, month: int) -> Tuple[datetime, datetime]: return month_start, month_end -def get_relevant_weeks(year: int, - month: int) -> Iterator[Tuple[datetime, datetime]]: +def get_relevant_weeks( + year: int, + month: int, +) -> Iterator[Tuple[datetime, datetime]]: """Yields start and end times of each relevant week for the given year and month. @@ -358,17 +412,17 @@ def get_relevant_weeks(year: int, """ month_start, month_end = get_month_times(year, month) week_start = month_start - timedelta(month_start.weekday() + 1) - week_end = week_start + timedelta(7) + week_end = week_start + timedelta(days=7) while week_end <= month_end: yield week_start, week_end week_start = week_end - week_end += timedelta(7) + week_end += timedelta(days=7) def get_monthly_overtime( - shifts: Tuple[Event, ...], - weeks: Iterator[Tuple[datetime, datetime]], - wage: SalarySettings + shifts: Tuple[Event, ...], + weeks: Iterator[Tuple[datetime, datetime]], + wage: SalarySettings, ) -> float: """Returns the sum of all weekly overtime for the supplied shifts based on the provided weeks. @@ -389,8 +443,9 @@ def get_monthly_overtime( """ monthly_overtime = [] for week_start, week_end in weeks: - weekly_shifts = tuple(shift for shift in shifts - if week_start <= shift.start <= week_end) + weekly_shifts = tuple( + shift for shift in shifts if week_start <= shift.start <= week_end + ) monthly_overtime.append(calc_weekly_overtime(weekly_shifts, wage)) return sum(monthly_overtime) @@ -412,8 +467,12 @@ def calc_transport(shifts_amount: int, daily_transport: float) -> float: def calc_salary( - year: int, month: int, wage: SalarySettings, overtime: bool, - bonus: config.NUMERIC = 0, deduction: config.NUMERIC = 0, + year: int, + month: int, + wage: SalarySettings, + overtime: bool, + bonus: config.NUMERIC = 0, + deduction: config.NUMERIC = 0, ) -> Dict[str, config.NUMERIC]: """Returns all details and calculation for the given year and month based on the provided settings, including specified additions or deductions. @@ -439,36 +498,46 @@ def calc_salary( """ # Code revision required after categories feature is added month_start, month_end = get_month_times(year, month) - shifts = get_event_by_category(month_start, month_end, wage.user_id, - wage.category_id) + shifts = get_event_by_category( + month_start, + month_end, + wage.user_id, + wage.category_id, + ) weeks = get_relevant_weeks(year, month) - base_salary = sum(calc_shift_salary(shift.start, shift.end, wage) - for shift in shifts) + base_salary = sum( + calc_shift_salary(shift.start, shift.end, wage) for shift in shifts + ) if overtime: month_weekly_overtime = get_monthly_overtime(shifts, weeks, wage) else: month_weekly_overtime = 0 transport = calc_transport(len(shifts), wage.daily_transport) - salary = round(sum((base_salary, bonus, - month_weekly_overtime, transport)), 2) + salary = round( + sum((base_salary, bonus, month_weekly_overtime, transport)), + 2, + ) if deduction > salary: deduction = salary salary -= deduction return { - 'year': year, - 'month': month, - 'num_of_shifts': len(shifts), - 'base_salary': base_salary, - 'month_weekly_overtime': month_weekly_overtime, - 'transport': transport, - 'bonus': bonus, - 'deduction': deduction, - 'salary': round(salary, 2), + "year": year, + "month": month, + "num_of_shifts": len(shifts), + "base_salary": base_salary, + "month_weekly_overtime": month_weekly_overtime, + "transport": transport, + "bonus": bonus, + "deduction": deduction, + "salary": round(salary, 2), } -def get_settings(session: Session, user_id: int, - category_id: int) -> Optional[SalarySettings]: +def get_settings( + session: Session, + user_id: int, + category_id: int, +) -> Optional[SalarySettings]: """Returns settings for `user_id` and `category_id` if exists, None otherwise. @@ -481,33 +550,20 @@ def get_settings(session: Session, user_id: int, SalarySettings | None: Settings for the provided user_id and category_id if exists, None otherwise. """ - settings = session.query(SalarySettings).filter_by( - user_id=user_id, category_id=category_id).first() + settings = ( + session.query(SalarySettings) + .filter_by(user_id=user_id, category_id=category_id) + .first() + ) session.close() return settings -def get_time_from_string(string: str) -> time: - """Converts time string to a time object. - - Args: - string (str): Time string. - - Returns: - datetime.time: Time object. - - raises: - ValueError: If string is not of format %H:%M:%S' or '%H:%M', - or if string is an invalid time. - """ - try: - return datetime.strptime(string, config.HOUR_FORMAT).time() - except ValueError: - return datetime.strptime(string, config.ALT_HOUR_FORMAT).time() - - -def update_settings(session: Session, wage: SalarySettings, - form: Dict[str, str]) -> bool: +def update_settings( + session: Session, + wage: SalarySettings, + form: Dict[str, str], +) -> bool: """Update salary settings instance according to info in `form`. Args: @@ -522,19 +578,19 @@ def update_settings(session: Session, wage: SalarySettings, None """ try: - wage.wage = form['wage'] - wage.off_day = form['off_day'] - wage.holiday_category_id = form['holiday_category_id'] - wage.regular_hour_basis = form['regular_hour_basis'] - wage.night_hour_basis = form['night_hour_basis'] - wage.night_start = get_time_from_string(form['night_start']) - wage.night_end = get_time_from_string(form['night_end']) - wage.night_min_len = get_time_from_string(form['night_min_len']) - wage.first_overtime_amount = form['first_overtime_amount'] - wage.first_overtime_pay = form['first_overtime_pay'] - wage.second_overtime_pay = form['second_overtime_pay'] - wage.week_working_hours = form['week_working_hours'] - wage.daily_transport = form['daily_transport'] + wage.wage = form["wage"] + wage.off_day = form["off_day"] + wage.holiday_category_id = form["holiday_category_id"] + wage.regular_hour_basis = form["regular_hour_basis"] + wage.night_hour_basis = form["night_hour_basis"] + wage.night_start = get_time_from_string(form["night_start"]) + wage.night_end = get_time_from_string(form["night_end"]) + wage.night_min_len = get_time_from_string(form["night_min_len"]) + wage.first_overtime_amount = form["first_overtime_amount"] + wage.first_overtime_pay = form["first_overtime_pay"] + wage.second_overtime_pay = form["second_overtime_pay"] + wage.week_working_hours = form["week_working_hours"] + wage.daily_transport = form["daily_transport"] except KeyError: return False diff --git a/app/routers/settings.py b/app/routers/settings.py new file mode 100644 index 00000000..9720526a --- /dev/null +++ b/app/routers/settings.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, Request + +from app.dependencies import templates + +router = APIRouter( + prefix="/settings", + tags=["settings"], + responses={404: {"description": "Not found"}}, +) + + +@router.get("/") +async def settings(request: Request) -> templates.TemplateResponse: + return templates.TemplateResponse( + "settings.html", + { + "request": request, + }, + ) diff --git a/app/routers/share.py b/app/routers/share.py index a33f44fd..08804e8e 100644 --- a/app/routers/share.py +++ b/app/routers/share.py @@ -2,25 +2,24 @@ from sqlalchemy.orm import Session -from app.database.models import Event, Invitation, UserEvent -from app.internal.utils import save +from app.database.models import Event, Invitation from app.internal.export import get_icalendar from app.routers.user import does_user_exist, get_users def sort_emails( - participants: List[str], - session: Session, + participants: List[str], + session: Session, ) -> Dict[str, List[str]]: """Sorts emails to registered and unregistered users.""" - emails = {'registered': [], 'unregistered': []} # type: ignore + emails = {"registered": [], "unregistered": []} # type: ignore for participant in participants: if does_user_exist(email=participant, session=session): - temp: list = emails['registered'] + temp: list = emails["registered"] else: - temp: list = emails['unregistered'] + temp: list = emails["unregistered"] temp.append(participant) @@ -28,29 +27,28 @@ def sort_emails( def send_email_invitation( - participants: List[str], - event: Event, + participants: List[str], + event: Event, ) -> bool: """Sends an email with an invitation.""" - - ical_invitation = get_icalendar(event, participants) # noqa: F841 - for _ in participants: - # TODO: send email - pass + if participants: + ical_invitation = get_icalendar(event, participants) # noqa: F841 + for _ in participants: + # TODO: send email + pass return True def send_in_app_invitation( - participants: List[str], - event: Event, - session: Session + participants: List[str], + event: Event, + session: Session, ) -> bool: """Sends an in-app invitation for registered users.""" for participant in participants: # email is unique recipient = get_users(email=participant, session=session)[0] - if recipient.id != event.owner.id: session.add(Invitation(recipient=recipient, event=event)) @@ -62,26 +60,13 @@ def send_in_app_invitation( return True -def accept(invitation: Invitation, session: Session) -> None: - """Accepts an invitation by creating an - UserEvent association that represents - participantship at the event.""" - - association = UserEvent( - user_id=invitation.recipient.id, - event_id=invitation.event.id - ) - invitation.status = 'accepted' - save(session, invitation) - save(session, association) - - def share(event: Event, participants: List[str], session: Session) -> bool: """Sends invitations to all event participants.""" - registered, unregistered = ( - sort_emails(participants, session=session).values() - ) + registered, unregistered = sort_emails( + participants, + session=session, + ).values() if send_email_invitation(unregistered, event): if send_in_app_invitation(registered, event, session): return True diff --git a/app/routers/user.py b/app/routers/user.py index 05206c8f..8b8a0403 100644 --- a/app/routers/user.py +++ b/app/routers/user.py @@ -10,8 +10,7 @@ from app.database.models import Event, User, UserEvent from app.dependencies import get_db from app.internal.user.availability import disable, enable -from app.internal.utils import get_current_user, save - +from app.internal.utils import get_current_user router = APIRouter( prefix="/user", @@ -23,7 +22,7 @@ class UserModel(BaseModel): username: str password: str - email: str = Field(regex='^\\S+@\\S+\\.\\S+$') + email: str = Field(regex="^\\S+@\\S+\\.\\S+$") language: str language_id: int @@ -38,32 +37,8 @@ async def get_user(id: int, session=Depends(get_db)): return session.query(User).filter_by(id=id).first() -@router.post("/") -def manually_create_user(user: UserModel, session=Depends(get_db)): - create_user(**user.dict(), session=session) - return f'User {user.username} successfully created' - - -def create_user(username: str, - password: str, - email: str, - language_id: int, - session: Session) -> User: - """Creates and saves a new user.""" - - user = User( - username=username, - password=password, - email=email, - language_id=language_id - ) - save(session, user) - return user - - def get_users(session: Session, **param): """Returns all users filtered by param.""" - try: users = list(session.query(User).filter_by(**param)) except SQLAlchemyError: @@ -73,13 +48,10 @@ def get_users(session: Session, **param): def does_user_exist( - session: Session, - *, user_id=None, - username=None, email=None + session: Session, *, user_id=None, username=None, email=None ): """Returns True if user exists, False otherwise. - function can receive one of the there parameters""" - + function can receive one of the there parameters""" if user_id: return len(get_users(session=session, id=user_id)) == 1 if username: @@ -91,16 +63,16 @@ def does_user_exist( def get_all_user_events(session: Session, user_id: int) -> List[Event]: """Returns all events that the user participants in.""" - return ( - session.query(Event).join(UserEvent) - .filter(UserEvent.user_id == user_id).all() + session.query(Event) + .join(UserEvent) + .filter(UserEvent.user_id == user_id) + .all() ) @router.post("/disable") -def disable_logged_user( - request: Request, session: Session = Depends(get_db)): +def disable_logged_user(request: Request, session: Session = Depends(get_db)): """route that sends request to disable the user. after successful disable it will be directed to main page. if the disable fails user will stay at settings page @@ -113,8 +85,7 @@ def disable_logged_user( @router.post("/enable") -def enable_logged_user( - request: Request, session: Session = Depends(get_db)): +def enable_logged_user(request: Request, session: Session = Depends(get_db)): """router that sends a request to enable the user. if enable successful it will be directed to main page. if it fails user will stay at settings page diff --git a/app/routers/weekview.py b/app/routers/weekview.py index efac161a..8995f69a 100644 --- a/app/routers/weekview.py +++ b/app/routers/weekview.py @@ -7,12 +7,16 @@ from sqlalchemy.orm.session import Session from app.database.models import Event, User -from app.dependencies import get_db, TEMPLATES_PATH +from app.dependencies import TEMPLATES_PATH, get_db +from app.internal.security.dependencies import current_user from app.routers.dayview import ( - DivAttributes, dayview, get_events_and_attributes + CurrentTimeAttributes, + EventsAttributes, + dayview, + get_all_day_events, + get_events_and_attributes, ) - templates = Jinja2Templates(directory=TEMPLATES_PATH) @@ -22,40 +26,67 @@ class DayEventsAndAttrs(NamedTuple): day: datetime template: Jinja2Templates.TemplateResponse - events_and_attrs: Tuple[Event, DivAttributes] + events_and_attrs: Tuple[Event, EventsAttributes] + current_time_and_attrs: CurrentTimeAttributes + all_day_events: Event -def get_week_dates(firstday: datetime) -> Iterator[datetime]: +def get_week_dates(first_day: datetime) -> Iterator[datetime]: rest_of_days = [timedelta(days=1) for _ in range(6)] - rest_of_days.insert(0, firstday) + rest_of_days.insert(0, first_day) return accumulate(rest_of_days) async def get_day_events_and_attributes( - request: Request, day: datetime, session: Session, user: User, - ) -> DayEventsAndAttrs: + request: Request, + day: datetime, + session: Session, + user: User, +) -> DayEventsAndAttrs: template = await dayview( request=request, - date=day.strftime('%Y-%m-%d'), - view='week', - session=session + date=day.strftime("%Y-%m-%d"), + view="week", + session=session, + user=user, ) events_and_attrs = get_events_and_attributes( - day=day, session=session, user_id=user.id) - return DayEventsAndAttrs(day, template, events_and_attrs) + day=day, + session=session, + user_id=user.user_id, + ) + current_time_and_attrs = CurrentTimeAttributes(date=day) + all_day_events = get_all_day_events( + day=day, + session=session, + user_id=user.user_id, + ) + return DayEventsAndAttrs( + day, + template, + events_and_attrs, + current_time_and_attrs, + all_day_events, + ) -@router.get('/week/{firstday}') +@router.get("/week/{first_day}") async def weekview( - request: Request, firstday: str, session=Depends(get_db) - ): - user = session.query(User).filter_by(username='test_username').first() - firstday = datetime.strptime(firstday, '%Y-%m-%d') - week_days = get_week_dates(firstday) - week = [await get_day_events_and_attributes( - request, day, session, user - ) for day in week_days] - return templates.TemplateResponse("weekview.html", { - "request": request, - "week": week, - }) + request: Request, + first_day: str, + session=Depends(get_db), + user: User = Depends(current_user), +): + first_day = datetime.strptime(first_day, "%Y-%m-%d") + week_days = get_week_dates(first_day) + week = [ + await get_day_events_and_attributes(request, day, session, user) + for day in week_days + ] + return templates.TemplateResponse( + "weekview.html", + { + "request": request, + "week": week, + }, + ) diff --git a/app/routers/weight.py b/app/routers/weight.py index 058f72d6..9f35a5dc 100644 --- a/app/routers/weight.py +++ b/app/routers/weight.py @@ -5,44 +5,44 @@ from starlette.responses import RedirectResponse from app.database.models import User -from app.dependencies import get_db -from app.dependencies import templates +from app.dependencies import get_db, templates - -router = APIRouter(tags=["weight"],) +router = APIRouter( + tags=["weight"], +) @router.get("/weight") async def get_weight( - request: Request, - session: Session = Depends(get_db), - target: Union[float, None] = None, - current_weight: Union[float, None] = None, - ): + request: Request, + session: Session = Depends(get_db), + target: Union[float, None] = None, + current_weight: Union[float, None] = None, +): # TODO Waiting for user registration user_id = 1 user = session.query(User).filter_by(id=user_id).first() target = user.target_weight if current_weight: - return RedirectResponse(url='/') - return templates.TemplateResponse("weight.html", { - "request": request, - "target": target, - "current_weight": current_weight, - } + return RedirectResponse(url="/") + return templates.TemplateResponse( + "weight.html", + { + "request": request, + "target": target, + "current_weight": current_weight, + }, ) @router.post("/weight") -async def weight( - request: Request, - session: Session = Depends(get_db)): +async def weight(request: Request, session: Session = Depends(get_db)): user_id = 1 user = session.query(User).filter_by(id=user_id).first() data = await request.form() - target = data['target'] - current_weight = data['current_weight'] + target = data["target"] + current_weight = data["current_weight"] if target: user.target_weight = target session.commit() @@ -60,10 +60,12 @@ async def weight( else: way_message = f"Great! You have reached your goal: {target} Kg" - return templates.TemplateResponse("weight.html", { - "request": request, - "target": target, - "current_weight": current_weight, - "way_message": way_message - } + return templates.TemplateResponse( + "weight.html", + { + "request": request, + "target": target, + "current_weight": current_weight, + "way_message": way_message, + }, ) diff --git a/app/routers/whatsapp.py b/app/routers/whatsapp.py index cbd1e254..1d7a625c 100644 --- a/app/routers/whatsapp.py +++ b/app/routers/whatsapp.py @@ -1,7 +1,7 @@ from typing import Optional +from urllib.parse import urlencode from fastapi import APIRouter -from urllib.parse import urlencode router = APIRouter(tags=["utils"]) @@ -19,7 +19,7 @@ def make_link(phone_number: Optional[str], message: Optional[str]) -> str: Returns: A WhatsApp message URL. """ - api = 'https://api.whatsapp.com/send?' - url_query = {'phone': phone_number, 'text': message} + api = "https://api.whatsapp.com/send?" + url_query = {"phone": phone_number, "text": message} link = api + urlencode(url_query) return link diff --git a/app/static/agenda_style.css b/app/static/agenda_style.css index fb357f7f..950cb546 100644 --- a/app/static/agenda_style.css +++ b/app/static/agenda_style.css @@ -6,6 +6,23 @@ text-align: center; } +.agenda_filter_grid { + grid-area: header; +} + +.agenda_grid { + display: grid; + grid-template-areas: + "header filter" + "sidebar sidebar"; + grid-template-columns: 5fr 2fr; + grid-gap: 0 1.25em; +} + +.category_filter { + grid-area: filter; +} + .event_line { width: 80%; margin-left: 2em; @@ -18,6 +35,22 @@ grid-gap: 0.6em; } +.event_line[data-value="hidden"], +.wrapper[data-value="hidden"] +{ + display: none; +} + +.event_line[data-value="visible"] +{ + display: grid; +} + +.wrapper[data-value="visible"] +{ + display: block; +} + .duration { font-size: small; } diff --git a/app/static/credits_style.css b/app/static/credits_style.css index 3234e564..398db3c9 100644 --- a/app/static/credits_style.css +++ b/app/static/credits_style.css @@ -1,6 +1,7 @@ body { margin-left: 6.25em; margin-right: 6.25em; + background-color: var(--backgroundcol); } div.gallery { diff --git a/app/static/dayview.css b/app/static/dayview.css index 3aed2af5..e0e61ff2 100644 --- a/app/static/dayview.css +++ b/app/static/dayview.css @@ -1,122 +1,134 @@ body { - height: 100%; - display: flex; - flex-direction: column; - overflow: hidden; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; } - #day-view { - display: flex; - flex: 1; - flex-direction: column; - position: relative; - overflow-y: hidden; + display: flex; + flex: 1; + flex-direction: column; + position: relative; + overflow-y: hidden; } #top-tab { - background-color: var(--primary); + background-color: var(--primary); } .schedule { - display: grid; - grid-template-rows: 1; - flex: 1; - margin-top: var(--space_xs); - margin-bottom: var(--space_xs); - line-height: 1; - overflow-y: scroll; + display: grid; + grid-template-rows: 1; + flex: 1; + margin-top: var(--space_xs); + margin-bottom: var(--space_xs); + line-height: 1; + overflow-y: scroll; } .schedule::-webkit-scrollbar { - display: none; + display: none; } .times { - grid-row: 1 / -1; - grid-column: 1 / -1; - display: grid; - grid-template-rows: repeat(25, minmax(2rem, 1fr)); + grid-row: 1 / -1; + grid-column: 1 / -1; + display: grid; + grid-template-rows: repeat(25, minmax(2rem, 1fr)); } .baselines { - grid-row: 1 / -1; - grid-column: 1 / -1; + grid-row: 1 / -1; + grid-column: 1 / -1; } .event-grid { - grid-row: 1 / -1; - grid-column: 1 / -1; - display: grid; - grid-template-rows: repeat(100, 1fr); + grid-row: 1 / -1; + grid-column: 1 / -1; + display: grid; + grid-template-rows: repeat(100, auto); +} + +.timegrid { + grid-row: 1 / -1; + grid-column: 1 / -1; + display: grid; + grid-template-rows: repeat(100, auto); + z-index: 43; +} + +.sub-timegrid { + display: grid; + grid-template-rows: repeat(15, auto); } .hour-block { - display: flex; - align-items: center; + display: flex; + align-items: center; } .hour-mark { - z-index: 60; - line-height: 1; + z-index: 60; + line-height: 1; } .hour-block:last-child .hour-mark { - visibility: hidden; + visibility: hidden; } .hour-bar { - flex: 1; - z-index: 40; + flex: 1; + z-index: 40; } .event { - font-size: var(--text_m); - padding: 0; - z-index: 50; - text-align: center; + font-size: var(--text_m); + padding: 0; + z-index: 50; + text-align: center; } .event:hover { - border-top: 1px dashed var(--surface); - border-bottom: 1px dashed var(--surface); - transition: 0.3; + border-top: 1px dashed var(--surface); + border-bottom: 1px dashed var(--surface); + transition: 0.3; } .event-details { - flex: 1; + flex: 1; } .total-time { - font-size: var(--text_s); + font-size: var(--text_s); } .title-size-small { - font-size: var(--text_m); + font-size: var(--text_m); } .title-size-xsmall { - font-size: var(--text_s); + font-size: var(--text_s); } .title-size-tiny { - font-size: var(--text_xs); - line-height: 1; + font-size: var(--text_xs); + line-height: 1; } .action-icon { - visibility: hidden; + visibility: hidden; } .action-container { - z-index: 70; + z-index: 70; } .event:hover .action-container .action-icon { - visibility: visible; + visibility: visible; } .week-view-title { - font-size: var(--text_xxl); + font-size: var(--text_xxl); } .zodiac-sign { @@ -139,31 +151,40 @@ body { } .event-btn { - text-align: center; - color: #fff; - background-color: var(--primary); - border-radius: 0.75rem; - border: none; - width: 3rem; - height: 3rem; - line-height: 1; - font-size: var(--text_xl); - display: flex; - align-items: center; - justify-content: center; - position: absolute; - right: 0.5rem; - bottom: 0.5rem; + text-align: center; + color: #fff; + background-color:var(--primary); + border-radius: 0.75rem; + border: none; + width: 3rem; + height: 3rem; + line-height: 1; + font-size: var(--text_xl); + display: flex; + align-items: center; + justify-content: center; + position: absolute; + right: 0.5rem; + bottom: 0.5rem; } .plus_image { - width: 3rem; - height: 3rem; + width: 3rem; + height: 3rem; } .pb-1 { - width: 1.2rem; - height: 1.2rem; + width: 1.2rem; + height: 1.2rem; +} + +#current_time_cursor { + border-bottom: 2.5px dotted rgba(255, 0, 0, 0.808); +} + +#all-day-events { + background-color: var(--primary); + word-spacing: 0.25em; } .deleted-event { diff --git a/app/static/event/eventedit.css b/app/static/event/eventedit.css index 9c5d3fda..0193595b 100644 --- a/app/static/event/eventedit.css +++ b/app/static/event/eventedit.css @@ -64,4 +64,12 @@ textarea, input[type="submit"] { width: 100%; -} \ No newline at end of file +} + +.shared-list-item-off { + display: none; +} + +.shared-list-item-on { + display: flex; +} diff --git a/app/static/event/eventview.css b/app/static/event/eventview.css index 3a420e0e..f3900a04 100644 --- a/app/static/event/eventview.css +++ b/app/static/event/eventview.css @@ -12,13 +12,13 @@ body { flex-direction: column; } -.event_view_wrapper { +.event-view-wrapper { display: flex; flex-direction: column; height: 100%; } -#event_view_tabs { +#event-view-tabs { flex: 1; } @@ -28,32 +28,28 @@ body { flex-direction: column; } -.event_info_row, -.event_info_row_start, -.event_info_row_end { - display: flex +.event-info-row, +.event-info-row-start, +.event-info-row-end { + display: flex; } -.event_info_row_start, -.event_info_row_end { +.event-info-row-start, +.event-info-row-end { flex: 1; } -.event_info_row_end { +.event-info-row-end { justify-content: flex-end; } -div.event_info_row, -.event_info_buttons_row { +div.event-info-row, +.event-info-buttons-row { align-items: center; margin-block-start: 0.2em; margin-block-end: 0.2em; } -.title { - border-bottom: 4px solid blue; -} - .title h1 { white-space: nowrap; margin-block-start: 0.2em; @@ -65,11 +61,15 @@ div.event_info_row, padding-right: 1em; } -.event_info_buttons_row { +.event-info-buttons-row { min-height: 2.25em; max-height: 3.25em; } button { height: 100%; -} \ No newline at end of file +} + +.google-maps-object { + width: 100%; +} diff --git a/app/static/global.css b/app/static/global.css index c7e2deb4..66e4de36 100644 --- a/app/static/global.css +++ b/app/static/global.css @@ -49,17 +49,17 @@ body { } body { - background-color: #F7F7F7; - color: #222831; - font-family: "Assistant", "Ariel", sans-serif; - font-weight: 400; - line-height: 1.7; - text-rendering: optimizeLegibility; - scroll-behavior: smooth; - width: 100%; + background-color: var(--backgroundcol); + color: var(--textcolor); + font-family: "Assistant", "Ariel", sans-serif; + font-weight: 400; + line-height: 1.7; + text-rendering: optimizeLegibility; + scroll-behavior: smooth; + width: 100%; } a { text-decoration: none; color: inherit; -} \ No newline at end of file +} diff --git a/app/static/grid_style.css b/app/static/grid_style.css index d824c1b0..1afdca10 100644 --- a/app/static/grid_style.css +++ b/app/static/grid_style.css @@ -1,3 +1,22 @@ +:root[data-color-mode="regular"] { + --backgroundcol: #F7F7F7; + --textcolor: #222831; + --start-of-month: #E9ECEf; + --primary-variant: #FFDE4D; + --secondary: #EF5454; + --borders: #E7E7E7; + --borders-variant: #F7F7F7; +} + +:root[data-color-mode="dark"] { + --backgroundcol: #000000; + --textcolor: #EEEEEE; + --start-of-month: #8C28BF; + --secondary: #EF5454; + --borders: #E7E7E7; + --borders-variant: #F7F7F7; +} + * { margin: 0; padding: 0; @@ -24,7 +43,7 @@ nav { position: sticky; display: flex; flex-direction: column; - top:var(--space_s); + top: var(--space_s); } .fixed-features, @@ -37,6 +56,7 @@ nav { flex: 1; display: flex; flex-direction: column; + background: var(--backgroundcol); } .user-features { @@ -84,19 +104,21 @@ nav { } .settings-open { - width: 20rem; + width: 20rem; } -img {fill: var(--background);} +img { + fill: var(--background); +} header { z-index: 5; position: sticky; top: 0; display: flex; - grid-flow: row wrap; margin: 0 var(--space_s); - background-color: var(--background); + margin: 0 1rem 0 1rem; + background-color: var(--backgroundcol); } header div { @@ -135,7 +157,8 @@ main { display: grid; grid-template-columns: repeat(7, 1fr); margin: var(--space_s) var(--space_s) 0 var(--space_s); - background-color: var(--background); + margin: 1rem 1rem 0 1rem; + background-color: var(--backgroundcol); align-self: stretch; } @@ -192,9 +215,13 @@ main { font-weight: 400; } -.day:hover {border: 0.1rem solid var(--primary);} +.day:hover { + border: 0.1rem solid var(--primary); +} -.day:hover .day-number{color: var(--negative);} +.day:hover .day-number { + color: var(--negative); +} .day:hover .add-small { display: block; @@ -288,7 +315,7 @@ main { height: 1.5rem; } -.month-event div{ +.month-event div { height: 1.5rem; width: 100%; transition: all 0.3s ease; @@ -334,31 +361,59 @@ main { } /* Text Colors */ -.text-yellow {color: var(--secondary);} +.text-yellow { + color: var(--secondary); +} -.text-gray {color: var(--on-surface);} +.text-gray { + color: var(--on-surface); +} -.text-lightgray {color: var(--background);} +.text-lightgray { + color: var(--background); +} -.text-darkblue {color: var(--primary);} +.text-darkblue { + color: var(--primary); +} /* Borders */ -.border-dash-darkblue {border: 0.125rem dashed var(--primary);} +.border-dash-darkblue { + border: 0.125rem dashed var(--primary); +} -.border-darkblue {border: 0.125rem solid var(--primary);} +.border-darkblue { + border: 0.125rem solid var(--primary); +} -.underline-yellow {border-bottom: 0.25rem solid var(--secondary);} +.underline-yellow { + border-bottom: 0.25rem solid var(--secondary); +} /* Background Color */ -.background-darkblue {background-color: var(--primary-variant);} +.background-darkblue { + background-color: var(--primary-variant); +} -.background-red {background-color: var(--negative);} +.background-red { + background-color: var(--negative); +} -.background-lightgray {background-color: var(--surface);} +.background-yellow { + background-color: var(--secondary); +} -.background-yellow {background-color: var(--secondary);} +.background-green { + background-color: var(--positive); +} -.background-green {background-color: var(--positive);} +.background-lightgray { + background-color: var(--start-of-month); +} + +.background-green { + background-color: var(--bold_tertiary); +} /* Buttons */ @@ -371,4 +426,13 @@ main { .button:hover { font-weight: 700; -} \ No newline at end of file +} + +.dates-calc { + background-color: #222831; + color: white; +} + +#darkmode { + cursor: pointer; +} diff --git a/app/static/images/calendar.png b/app/static/images/calendar.png new file mode 100644 index 0000000000000000000000000000000000000000..bde6774c6fead7c86bd67c03e2d087e6ffa25de2 GIT binary patch literal 203066 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 literal 0 HcmV?d00001 diff --git a/app/static/images/icons/israel.svg b/app/static/images/icons/israel.svg new file mode 100644 index 00000000..7a355c35 --- /dev/null +++ b/app/static/images/icons/israel.svg @@ -0,0 +1,47 @@ +<?xml version="1.0" encoding="iso-8859-1"?> +<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" + viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve"> +<circle style="fill:#F0F0F0;" cx="256" cy="256" r="256"/> +<g> + <path style="fill:#0052B4;" d="M352.393,200.348H288.13L256,144.696l-32.129,55.652h-64.264L191.741,256l-32.134,55.652h64.264 + L256,367.304l32.13-55.652h64.263L320.259,256L352.393,200.348z M295.475,256l-19.736,34.188h-39.475L216.525,256l19.738-34.188 + h39.475L295.475,256z M256,187.623l7.346,12.724h-14.69L256,187.623z M196.786,221.812h14.692l-7.346,12.724L196.786,221.812z + M196.786,290.188l7.347-12.724l7.346,12.724H196.786z M256,324.376l-7.345-12.724h14.691L256,324.376z M315.214,290.188h-14.692 + l7.347-12.724L315.214,290.188z M300.522,221.812h14.692l-7.346,12.724L300.522,221.812z"/> + <path style="fill:#0052B4;" d="M415.357,55.652H96.643c-23.363,18.608-43.399,41.21-59.069,66.783h436.852 + C458.755,96.863,438.719,74.26,415.357,55.652z"/> + <path style="fill:#0052B4;" d="M96.643,456.348h318.713c23.363-18.608,43.399-41.21,59.069-66.783H37.574 + C53.245,415.137,73.281,437.74,96.643,456.348z"/> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +<g> +</g> +</svg> diff --git a/app/static/js/categories_filter.js b/app/static/js/categories_filter.js new file mode 100644 index 00000000..76b6bb53 --- /dev/null +++ b/app/static/js/categories_filter.js @@ -0,0 +1,32 @@ +document.addEventListener("DOMContentLoaded", function () { + document.getElementById("category-button").addEventListener("click", function () { + filterByCategory(); + }); +}); + +function filterByCategory(){ + // TODO(issue#67): Allow filter by category name + const category = document.getElementById("category").value; + + const allEvents = document.getElementsByClassName("event_line"); + for (event of allEvents) { + if (event.dataset.name == category) + { + event.dataset.value = "visible"; + } + else { + event.dataset.value = "hidden"; + } + if (!Number.isInteger(+category) || !category || 0 === category.length) { + event.dataset.value = "visible"; + } + event.parentNode.dataset.value = "hidden"; + } + + // Set wrapper div to display "visible" if at least one child is visible. + for (event of allEvents) { + if (event.dataset.value === "visible") { + event.parentNode.dataset.value = "visible"; + } + } +} diff --git a/app/static/js/darkmode.js b/app/static/js/darkmode.js new file mode 100644 index 00000000..0e4eeb7b --- /dev/null +++ b/app/static/js/darkmode.js @@ -0,0 +1,29 @@ +const ROOT = document.documentElement; + +window.addEventListener("DOMContentLoaded", (event) => { + const button = document.getElementById("darkmode"); + let isDarkMode = localStorage.getItem("isDarkMode") == "true"; + setThemeMode(isDarkMode, button, ROOT); + button.addEventListener("click", (event) => { + isDarkMode = !isDarkMode; + localStorage.setItem("isDarkMode", isDarkMode); + setThemeMode(isDarkMode, button, ROOT); + }); +}); + +function changeIcon(mode) { + const modeButton = document.getElementById("darkmode"); + modeButton.name = mode; +} + +function setThemeMode(isDarkMode, button, root) { + if (isDarkMode) { + root.dataset['colorMode'] = "dark"; + button.name = "moon"; + changeIcon("moon"); + } else { + root.dataset['colorMode'] = "regular"; + button.name = "moon-outline"; + changeIcon("moon-outline"); + } +} diff --git a/app/static/js/dates_calculator.js b/app/static/js/dates_calculator.js new file mode 100644 index 00000000..12754fd4 --- /dev/null +++ b/app/static/js/dates_calculator.js @@ -0,0 +1,21 @@ +window.addEventListener('DOMContentLoaded', (event) => { + document.getElementById("CalcBtn").addEventListener("click", hiddenDifference); +}); + +function hiddenDifference() { + if (document.getElementById("endDate").value == '') { + swal("Expected end date"); + return; + } + let date1 = document.getElementById("startDate").value; + const date2 = new Date(document.getElementById("endDate").value); + if (date1 != '') { + date1 = new Date(date1); + } + else { + date1 = Date.now(); + } + const diffDates = Math.abs(date2 - date1); + const diffInDays = Math.ceil(diffDates / (1000 * 60 * 60 * 24)); + document.getElementById("demo").innerText = "The difference: " + (diffInDays) + " days"; +} diff --git a/app/static/js/settings.js b/app/static/js/settings.js new file mode 100644 index 00000000..77ee2d13 --- /dev/null +++ b/app/static/js/settings.js @@ -0,0 +1,23 @@ +document.addEventListener('DOMContentLoaded', () => { + const tabBtn = document.getElementsByClassName("tab"); + for (let i = 0; i < tabBtn.length; i++) { + const btn = document.getElementById("tab" + i); + btn.addEventListener('click', () => { + tabClick(btn.id, tabBtn); + }); + } +}); + + +function tabClick(tab_id, tabBtn) { + let shownTab = document.querySelector(".tab-show"); + let selectedTabContent = document.querySelector(`#${tab_id}-content`); + shownTab.classList.remove("tab-show"); + shownTab.classList.add("tab-hide"); + for (btn of tabBtn) { + btn.children[0].classList.remove("active"); + } + document.getElementById(tab_id).classList.add("active"); + selectedTabContent.classList.remove("tab-hide"); + selectedTabContent.classList.add("tab-show"); +} diff --git a/app/static/js/shared_list.js b/app/static/js/shared_list.js new file mode 100644 index 00000000..eab047e1 --- /dev/null +++ b/app/static/js/shared_list.js @@ -0,0 +1,31 @@ +window.addEventListener('load', () => { + document.getElementById("btn-add-item").addEventListener('click', addItem); +}); + + +function addItem() { + const LIST_ITEMS_NUM = document.querySelectorAll("#items > div").length; + const list_items = document.getElementById("items"); + let shared_list_item = document.getElementById("shared-list-item").cloneNode(true); + + shared_list_item.className = "shared-list-item-on"; + shared_list_item.id = shared_list_item.id + LIST_ITEMS_NUM; + for (child of shared_list_item.children) { + if (child.tagName == 'INPUT') { + child.setAttribute('required', 'required'); + } + } + list_items.appendChild(shared_list_item); + document.querySelector(`#${shared_list_item.id} > .remove-btn`).addEventListener('click', () => { + removeItem(shared_list_item, list_items); + }) +} + + +function removeItem(shared_list_item, list_items) { + shared_list_item.remove(); + for (const [index, child] of list_items.childNodes.entries()) + { + child.id = "shared-list-item" + String(index); + } +} diff --git a/app/static/notification.css b/app/static/notification.css new file mode 100644 index 00000000..7dbe0a7d --- /dev/null +++ b/app/static/notification.css @@ -0,0 +1,70 @@ +/* general */ +#main { + width: 90%; + margin: 0 25% 0 5%; +} + +#link { + font-size: 1.5rem; +} + + +/* notifications */ +#notifications-box { + margin-top: 1rem; + +} + +.notification { + padding: 0.5rem 1rem; + display: flex; + justify-content: space-between; +} + +.notification:hover { + background-color: var(--surface-variant); + border-radius: 0.2rem; +} + +.action, .description { + display: inline-block; +} + +.action { + width: 4rem; +} + + +/* buttons */ +.notification-btn { + background-color: transparent; + border: none; +} + +.notification-btn:focus { + outline: 0; +} + +.btn-accept { + color: green; +} + +.btn-decline { + color: red; +} + +#mark-all-as-read { + margin: 1rem; +} + + +/* form */ +.notification-form { + display: inline-block; +} + + +/* icons */ +.icon { + font-size: 1.5rem; +} diff --git a/app/static/settings_style.css b/app/static/settings_style.css new file mode 100644 index 00000000..b2eda0b8 --- /dev/null +++ b/app/static/settings_style.css @@ -0,0 +1,136 @@ +/* Settings page */ + +.sub-title { + font-size: 1em; + padding-top: 0.2em; +} + +.settings { + color: #222831; + display: flex; + font-size: 0.9em; +} + +#settings-left { + padding: 1.5em 1.5em; + display: flex; + flex-direction: column; + background-color: rgb(230, 230, 230); +} + +.settings-layout { + display: flex; +} + +.left-options-bar { + display: flex; +} + +.settings-main article { + margin-bottom: 2em; +} + +.settings-options { + flex: 1; +} + +.settings-options ul { + list-style-type: none; + padding-left: 0; + padding-right: 1em; +} + +.settings-options ul li { + margin-bottom: 1em; + font-weight: bold; +} + +.settings-options ul > li > a:hover, +.settings-options ul li a.active { + transition: all .3s ease-in-out; + color: #5786f5; + cursor: pointer; + text-decoration: none; +} + +.tab-show { + display: block; + transition: all .5s ease-in; +} + +.tab-hide { + display: none; +} + +.settings-main { + flex: 3; + padding: 1.5em 1.5em; +} + +.settings-main h2 { + margin-bottom: 1em; +} + +.settings-main .form-select { + width: 17em; +} + +.tab-show p { + display: block; + align-self: left; +} + +.tab-show h2 { + display: block; + align-self: left; + font-weight: bold; +} + +.form-select { + font-size: 0.8em; +} + +.form-label { + margin: 1em 0em; +} + +/* For Mobile */ +@media screen and (max-width: 600px) { + .settings { + display: block; + font-size: 3vw; + } + + #settings-left { + padding: 1.5em 0em 0em 2em; + width: 100%; + } + + .settings-options { + margin-bottom: 1em; + } + + .settings-options ul { + padding-left: 0em; + } + + .settings-options ul li { + margin-bottom: 0.5em; + font-weight: bold; + } + + .settings-main { + padding: 2em 2em; + } + .settings-main .form-select { + width: 17em; + } + + .settings-main h2 { + margin-bottom: 1em; + } + + .settings-main p { + margin: 1em 0em; + } +} diff --git a/app/static/style.css b/app/static/style.css index 799245ba..53ff19bd 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -1,3 +1,19 @@ +:root[data-color-mode="regular"] { + --backgroundcol: #F7F7F7; + --textcolor: #222831; + --navcolor: rgba(0, 0, 0, 0.55); + --navhovercolor: rgba(0, 0, 0, 0.7); + --cardcolor: #FFF; +} + +:root[data-color-mode="dark"] { + --backgroundcol: #000000; + --textcolor: #EEEEEE; + --navcolor: #E9ECEF; + --navhovercolor: rgb(255 255 255); + --cardcolor: #230A88; +} + .profile-image { width: 7em; } @@ -88,7 +104,12 @@ p { margin: 0; } +.card { + background-color: var(--cardcolor); +} + .card-body { + color: var(--textcolor); overflow: auto; } @@ -142,10 +163,45 @@ p { margin-bottom: 1em; } +.forgot-password { + line-height: 0; + color: rgb(188, 7, 194); + padding-left: 8rem; +} + +.red-massage { + color: red; +} + .input-upload-file { margin-top: 1em; } +.relative.overflow-hidden { + background-color: var(--backgroundcol); + height: 100vh; +} + +.navbar-light .navbar-nav .nav-link { + color: var(--navcolor); +} + +.navbar-light .navbar-nav .nav-link:hover { + color: var(--navhovercolor); +} + +.main-text-color { + color: var(--textcolor); +} + +.cal-img { + text-align: center; +} + +#darkmode { + cursor: pointer; +} + .upload-file { margin: auto 1em auto 0em; } @@ -159,3 +215,10 @@ h2.modal-title { height: 2.5rem; margin-top: 1rem; } + +.reset-password{ + font-size: 2rem; + font-style: bold; + margin: 2rem; + color:black; +} diff --git a/app/static/weekview.css b/app/static/weekview.css index 99743ec6..d352ce4f 100644 --- a/app/static/weekview.css +++ b/app/static/weekview.css @@ -39,3 +39,7 @@ margin-left: -2; overflow: hidden; } + +#all_day_event_in_week { + color: #EF5454; +} diff --git a/app/telegram/bot.py b/app/telegram/bot.py index 3bfe15ef..83fc0517 100644 --- a/app/telegram/bot.py +++ b/app/telegram/bot.py @@ -2,6 +2,7 @@ from app import config from app.dependencies import get_settings + from .models import Bot settings: config.Settings = get_settings() diff --git a/app/telegram/handlers.py b/app/telegram/handlers.py index 8b2a6719..d6619c93 100644 --- a/app/telegram/handlers.py +++ b/app/telegram/handlers.py @@ -6,10 +6,16 @@ from app.database.models import User from app.dependencies import get_db from app.routers.event import create_event + from .bot import telegram_bot from .keyboards import ( - DATE_FORMAT, field_kb, gen_inline_keyboard, - get_this_week_buttons, new_event_kb, show_events_kb) + DATE_FORMAT, + field_kb, + gen_inline_keyboard, + get_this_week_buttons, + new_event_kb, + show_events_kb, +) from .models import Chat @@ -18,21 +24,22 @@ def __init__(self, chat: Chat, user: User): self.chat = chat self.user = user self.handlers = {} - self.handlers['/start'] = self.start_handler - self.handlers['/show_events'] = self.show_events_handler - self.handlers['/new_event'] = self.new_event_handler - self.handlers['Today'] = self.today_handler - self.handlers['This week'] = self.this_week_handler + self.handlers["/start"] = self.start_handler + self.handlers["/show_events"] = self.show_events_handler + self.handlers["/new_event"] = self.new_event_handler + self.handlers["Today"] = self.today_handler + self.handlers["This week"] = self.this_week_handler # Add next 6 days to handlers dict for row in get_this_week_buttons(): for button in row: - self.handlers[button['text']] = self.chosen_day_handler + self.handlers[button["text"]] = self.chosen_day_handler async def process_callback(self): if self.chat.user_id in telegram_bot.MEMORY: return await self.process_new_event( - telegram_bot.MEMORY[self.chat.user_id]) + telegram_bot.MEMORY[self.chat.user_id], + ) elif self.chat.message in self.handlers: return await self.handlers[self.chat.message]() return await self.default_handler() @@ -43,118 +50,121 @@ async def default_handler(self): return answer async def start_handler(self): - answer = f'''Hello, {self.chat.first_name}! -Welcome to PyLendar telegram client!''' + answer = f"""Hello, {self.chat.first_name}! +Welcome to PyLendar telegram client!""" await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer) return answer async def show_events_handler(self): - answer = 'Choose events day.' + answer = "Choose events day." await telegram_bot.send_message( chat_id=self.chat.user_id, text=answer, - reply_markup=show_events_kb) + reply_markup=show_events_kb, + ) return answer async def today_handler(self): today = datetime.datetime.today() events = [ - _.events for _ in self.user.events - if _.events.start <= today <= _.events.end] + _.events + for _ in self.user.events + if _.events.start <= today <= _.events.end + ] if not events: return await self._process_no_events_today() answer = f"{today.strftime('%A, %B %d')}:\n" - await telegram_bot.send_message( - chat_id=self.chat.user_id, text=answer) + await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer) for event in events: await self._send_event(event) return answer async def _process_no_events_today(self): answer = "There're no events today." - await telegram_bot.send_message( - chat_id=self.chat.user_id, text=answer) + await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer) return answer async def this_week_handler(self): - answer = 'Choose a day.' + answer = "Choose a day." this_week_kb = gen_inline_keyboard(get_this_week_buttons()) await telegram_bot.send_message( chat_id=self.chat.user_id, text=answer, - reply_markup=this_week_kb) + reply_markup=this_week_kb, + ) return answer async def chosen_day_handler(self): chosen_date = datetime.datetime.strptime( - self.chat.message, DATE_FORMAT) + self.chat.message, + DATE_FORMAT, + ) events = [ - _.events for _ in self.user.events - if _.events.start <= chosen_date <= _.events.end] + _.events + for _ in self.user.events + if _.events.start <= chosen_date <= _.events.end + ] if not events: return await self._process_no_events_on_date(chosen_date) answer = f"{chosen_date.strftime('%A, %B %d')}:\n" - await telegram_bot.send_message( - chat_id=self.chat.user_id, text=answer) + await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer) for event in events: await self._send_event(event) return answer async def _process_no_events_on_date(self, date): answer = f"There're no events on {date.strftime('%B %d')}." - await telegram_bot.send_message( - chat_id=self.chat.user_id, text=answer) + await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer) return answer async def _send_event(self, event): start = event.start.strftime("%d %b %Y %H:%M") end = event.end.strftime("%d %b %Y %H:%M") - text = f'Title:\n{event.title}\n\n' - text += f'Content:\n{event.content}\n\n' - text += f'Location:\n{event.location}\n\n' - text += f'Starts on:\n{start}\n\n' - text += f'Ends on:\n{end}' - await telegram_bot.send_message( - chat_id=self.chat.user_id, text=text) + text = f"Title:\n{event.title}\n\n" + text += f"Content:\n{event.content}\n\n" + text += f"Location:\n{event.location}\n\n" + text += f"Starts on:\n{start}\n\n" + text += f"Ends on:\n{end}" + await telegram_bot.send_message(chat_id=self.chat.user_id, text=text) await asyncio.sleep(1) async def process_new_event(self, memo_dict): - if self.chat.message == 'cancel': + if self.chat.message == "cancel": return await self._cancel_new_event_processing() - elif self.chat.message == 'restart': + elif self.chat.message == "restart": return await self._restart_new_event_processing() - elif 'title' not in memo_dict: + elif "title" not in memo_dict: return await self._process_title(memo_dict) - elif 'content' not in memo_dict: + elif "content" not in memo_dict: return await self._process_content(memo_dict) - elif 'location' not in memo_dict: + elif "location" not in memo_dict: return await self._process_location(memo_dict) - elif 'start' not in memo_dict: + elif "start" not in memo_dict: return await self._process_start_date(memo_dict) - elif 'end' not in memo_dict: + elif "end" not in memo_dict: return await self._process_end_date(memo_dict) - elif self.chat.message == 'create': + elif self.chat.message == "create": return await self._submit_new_event(memo_dict) async def new_event_handler(self): telegram_bot.MEMORY[self.chat.user_id] = {} - answer = 'Please, give your event a title.' + answer = "Please, give your event a title." await telegram_bot.send_message( chat_id=self.chat.user_id, text=answer, - reply_markup=field_kb) + reply_markup=field_kb, + ) return answer async def _cancel_new_event_processing(self): del telegram_bot.MEMORY[self.chat.user_id] - answer = '🚫 The process was canceled.' - await telegram_bot.send_message( - chat_id=self.chat.user_id, text=answer) + answer = "🚫 The process was canceled." + await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer) return answer async def _restart_new_event_processing(self): @@ -162,33 +172,36 @@ async def _restart_new_event_processing(self): return answer async def _process_title(self, memo_dict): - memo_dict['title'] = self.chat.message + memo_dict["title"] = self.chat.message answer = f'Title:\n{memo_dict["title"]}\n\n' - answer += 'Add a description of the event.' + answer += "Add a description of the event." await telegram_bot.send_message( chat_id=self.chat.user_id, text=answer, - reply_markup=field_kb) + reply_markup=field_kb, + ) return answer async def _process_content(self, memo_dict): - memo_dict['content'] = self.chat.message + memo_dict["content"] = self.chat.message answer = f'Content:\n{memo_dict["content"]}\n\n' - answer += 'Where the event will be held?' + answer += "Where the event will be held?" await telegram_bot.send_message( chat_id=self.chat.user_id, text=answer, - reply_markup=field_kb) + reply_markup=field_kb, + ) return answer async def _process_location(self, memo_dict): - memo_dict['location'] = self.chat.message + memo_dict["location"] = self.chat.message answer = f'Location:\n{memo_dict["location"]}\n\n' - answer += 'When does it start?' + answer += "When does it start?" await telegram_bot.send_message( chat_id=self.chat.user_id, text=answer, - reply_markup=field_kb) + reply_markup=field_kb, + ) return answer async def _process_start_date(self, memo_dict): @@ -198,21 +211,23 @@ async def _process_start_date(self, memo_dict): return await self._process_bad_date_input() async def _add_start_date(self, memo_dict, date): - memo_dict['start'] = date + memo_dict["start"] = date answer = f'Starts on:\n{date.strftime("%d %b %Y %H:%M")}\n\n' - answer += 'And when does it end?' + answer += "And when does it end?" await telegram_bot.send_message( chat_id=self.chat.user_id, text=answer, - reply_markup=field_kb) + reply_markup=field_kb, + ) return answer async def _process_bad_date_input(self): - answer = '❗️ Please, enter a valid date/time.' + answer = "❗️ Please, enter a valid date/time." await telegram_bot.send_message( chat_id=self.chat.user_id, text=answer, - reply_markup=field_kb) + reply_markup=field_kb, + ) return answer async def _process_end_date(self, memo_dict): @@ -222,32 +237,32 @@ async def _process_end_date(self, memo_dict): return await self._process_bad_date_input() async def _add_end_date(self, memo_dict, date): - memo_dict['end'] = date + memo_dict["end"] = date start_time = memo_dict["start"].strftime("%d %b %Y %H:%M") answer = f'Title:\n{memo_dict["title"]}\n\n' answer += f'Content:\n{memo_dict["content"]}\n\n' answer += f'Location:\n{memo_dict["location"]}\n\n' - answer += f'Starts on:\n{start_time}\n\n' + answer += f"Starts on:\n{start_time}\n\n" answer += f'Ends on:\n{date.strftime("%d %b %Y %H:%M")}' await telegram_bot.send_message( chat_id=self.chat.user_id, text=answer, - reply_markup=new_event_kb) + reply_markup=new_event_kb, + ) return answer async def _submit_new_event(self, memo_dict): - answer = 'New event was successfully created 🎉' - await telegram_bot.send_message( - chat_id=self.chat.user_id, text=answer) + answer = "New event was successfully created 🎉" + await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer) # Save to database create_event( db=next(get_db()), - title=memo_dict['title'], - start=memo_dict['start'], - end=memo_dict['end'], - content=memo_dict['content'], + title=memo_dict["title"], + start=memo_dict["start"], + end=memo_dict["end"], + content=memo_dict["content"], owner_id=self.user.id, - location=memo_dict['location'], + location=memo_dict["location"], ) # Delete current session del telegram_bot.MEMORY[self.chat.user_id] @@ -255,7 +270,7 @@ async def _submit_new_event(self, memo_dict): async def reply_unknown_user(chat): - answer = f''' + answer = f""" Hello, {chat.first_name}! To use PyLendar Bot you have to register @@ -265,6 +280,6 @@ async def reply_unknown_user(chat): Keep it secret! https://calendar.pythonic.guru/profile/ -''' +""" await telegram_bot.send_message(chat_id=chat.user_id, text=answer) return answer diff --git a/app/templates/agenda.html b/app/templates/agenda.html index 693c5eee..6896fa7d 100644 --- a/app/templates/agenda.html +++ b/app/templates/agenda.html @@ -1,34 +1,49 @@ -{% extends "base.html" %} +{% extends "partials/index/index_base.html" %} {% block head %} {{ super() }} <link href="{{ url_for('static', path='/agenda_style.css') }}" rel="stylesheet"> + <script type="text/javascript" src="{{ url_for('static', path='js/categories_filter.js') }}"></script> {% endblock %} {% block content %} - <form method="GET" action="/agenda#dates" class="mx-3 pt-3"> - <div class="col-sm-3"> - <label for="start_date">{{ gettext("From") }}</label> - <input class="form-control" type="date" id="start_date" name="start_date" value='{{ start_date }}'> + <div class="agenda_grid"> + <div class="agenda_filter_grid"> + <form method="GET" action="/agenda#dates" class="mx-3 pt-3"> + <div class="col-sm-3"> + <label for="start_date">{{ gettext("From") }}</label> + <input class="form-control" type="date" id="start_date" name="start_date" value='{{ start_date }}'> + </div> + <div class="col-sm-3"> + <label for="end_date">{{ gettext("To") }}</label> + <input class="form-control" type="date" id="end_date" name="end_date" value='{{ end_date }}'><br> + </div> + <div> + <input class="btn btn-primary btn-sm" type="submit" value="{{ gettext('Get Agenda') }}"> + </div> + </form> </div> - <div class="col-sm-3"> - <label for="end_date">{{ gettext("To") }}</label> - <input class="form-control" type="date" id="end_date" name="end_date" value='{{ end_date }}'><br> - </div> - <div> - <input class="btn btn-primary btn-sm" type="submit" value="{{ gettext('Get Agenda') }}"> - </div> - </form> - <div class="exact_date pt-3"> - <div class="mx-3"> - <a class="btn btn-light btn-outline-primary btn-sm" href="/agenda?days=0#dates">{{ gettext("Today") }}</a> + <div class="category_filter"> + <label for="category" class="sr-only">Filter events by category</label> + <input type="text" id="category" name="category" class="form-control-sm" + placeholder="category ID" value="{{ category }}" + onfocus="this.value=''" required> + <button class="btn btn-outline-info btn-sm" id="category-button"> + Filter by Categories + </button> </div> - <div class="mx-3"> - <a class="btn btn-light btn-outline-primary btn-sm" href="/agenda?days=7#dates">{{ gettext("Next 7 days") }}</a> - </div> - <div class="mx-3"> - <a class="btn btn-light btn-outline-primary btn-sm" href="/agenda?days=30#dates">{{ gettext("Next 30 days") }}</a> + + <div class="exact_date pt-3"> + <div class="mx-3"> + <a class="btn btn-light btn-outline-primary btn-sm" href="/agenda?days=0#dates">{{ gettext("Today") }}</a> + </div> + <div class="mx-3"> + <a class="btn btn-light btn-outline-primary btn-sm" href="/agenda?days=7#dates">{{ gettext("Next 7 days") }}</a> + </div> + <div class="mx-3"> + <a class="btn btn-light btn-outline-primary btn-sm" href="/agenda?days=30#dates">{{ gettext("Next 30 days") }}</a> + </div> </div> <div class="mx-3"> <button class="btn btn-light btn-outline-primary btn-sm graph" type="button" name="{{events_for_graph}}">{{ gettext("Week graph") }}</button> @@ -41,7 +56,7 @@ </div> </div> - <div class="pt-4 px-5"> + <div class="pt-4 px-5 main-text-color"> {% if start_date > end_date %} <p>{{ gettext("Start date is greater than end date") }}</p> {% elif events | length == 0 %} @@ -53,20 +68,21 @@ <h1 id="dates">{{ start_date.strftime("%d/%m/%Y") }} - {{ end_date.strftime("%d/ {% endif %} </div> - <div> + <div id="events"> {% for events_date, events_list in events.items() %} - - <div class="p-3">{{ events_date.strftime("%d/%m/%Y") }}</div> - {% for event in events_list %} - <div class="event_line" style="background-color: {{ event[0].color }}"> - {% set availability = 'Busy' if event[0].availability == True else 'Free' %} - <div class="{{ availability | lower }}" title="{{ availability }}"></div> - <div><b>{{ event[0].start.time().strftime("%H:%M") }} - <a class="event-title" href="/event/{{ event[0].id }}">{{ event[0].title | safe }}</a></b><br> - <span class="duration">duration: {{ event[1] }}</span> - </div> + <div class="wrapper"> + <div class="p-3">{{ events_date }}</div> + {% for event in events_list %} + <div class="event_line" style="background-color: {{ event.color }}" data-name="{{event.category_id}}"> + {% set availability = 'Busy' if event.availability else 'Free' %} + <div class="{{ availability | lower }}" title="{{ availability }}"></div> + <div><b>{{ event.start }} - <a class="event-title" href="/event/{{ event.id }}">{{ event.title | safe }}</a></b><br> + <span class="duration">duration: {{ event.duration }}</span> + </div> + </div> + {% endfor %} </div> - {% endfor %} {% endfor %} </div> -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/templates/archive.html b/app/templates/archive.html new file mode 100644 index 00000000..a74da791 --- /dev/null +++ b/app/templates/archive.html @@ -0,0 +1,26 @@ +{% extends "./partials/notification/base.html" %} +{% block page_name %}Archive{% endblock page_name %} + +{% block description %} + <div> + <div class="title">Archived Notifications</div> + <p class="s-paragraph"> + In this page you can view all of your archived notifications.<br/> + Any notification you have <b>marked as read</b> or <b>declined</b>, you will see here.<br/> + You can use the <button class="notification-btn btn-accept"><ion-icon class="icon" name="checkmark-outline"></ion-icon></button> + button to accept an invitation that you already declined. + </p> + </div> +{% endblock description %} + +{% block link %} + <div id="link"><a href="{{ url_for('view_notifications') }}">New notifications</a></div> +{% endblock link %} + +{% block notifications %} + {% include './partials/notification/generate_archive.html' %} +{% endblock notifications %} + +{% block no_notifications_msg %} + <span>You don't have any archived notifications.</span> +{% endblock no_notifications_msg %} diff --git a/app/templates/base.html b/app/templates/base.html index 5d211ad5..b8655f92 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,5 +1,5 @@ <!DOCTYPE html> -<html lang="en"> +<html lang="en" data-color-mode="regular"> <head> {% block head %} @@ -39,40 +39,47 @@ </li> <li class="nav-item"> <a class="nav-link" href="{{ url_for('logout') }}">{{ gettext("Sign Out") }}</a> - <li class="nav-item"> - <li class="nav-item"> - <a class="nav-link" href="{{ url_for('register') }}">Sign Up</a> - </li> - <li class="nav-item"> - <a class="nav-link" href="{{ url_for('agenda') }}">Agenda</a> - </li> - <li class="nav-item"> - <a class="nav-link" href="{{ url_for( 'audio_settings') }}">Audio Settings</a> - </li> - <li class="nav-item"> - <a class="nav-link" href="{{ url_for('view_invitations') }}">Invitations</a> - </li> - <li class="nav-item"> - <a class="nav-link" href="{{ url_for('search') }}">Search</a> - </li> - <li class="nav-item"> - <a class="nav-link" href="{{ url_for('friendview') }}">Friend View</a> - </li> - <li class="nav-item"> - <button id="a-joke" class="btn btn-link">Make me Laugh</button> - </li> - <li class="nav-item"></li> - <a class="nav-link" href="{{ url_for('category_color_insert') }}">Create Categories</a> - </li> - <li class="nav-item"> - <a class="nav-link" href="{{ url_for('weight') }}">Weight</a> - </li> - <li class="nav-item"> - <a class="nav-link" href="{{ url_for('about') }}">{{ gettext("About Us") }}</a> - </li> - <li class="nav-item"> - <a class="nav-link" href="{{ url_for('credits') }}">Credits</a> - </li> + </li> + <li class="nav-item"> + <a class="nav-link" href="{{ url_for('register') }}">Sign Up</a> + </li> + <li class="nav-item"> + <a class="nav-link" href="{{ url_for('agenda') }}">Agenda</a> + </li> + <li class="nav-item"> + <a class="nav-link" href="{{ url_for( 'audio_settings') }}">Audio Settings</a> + </li> + <li class="nav-item"> + <a class="nav-link" href="{{ url_for('search') }}">Search</a> + </li> + <li class="nav-item"> + <a class="nav-link" href="{{ url_for('friendview') }}">Friend View</a> + </li> + <li class="nav-item"> + <button id="a-joke" class="btn btn-link">Make me Laugh</button> + </li> + <li class="nav-item"></li> + <a class="nav-link" href="{{ url_for('category_color_insert') }}">Create Categories</a> + </li> + <li class="nav-item"> + <a class="nav-link" href="{{ url_for('weight') }}">Weight</a> + </li> + <li class="nav-item"> + <a class="nav-link" href="{{ url_for('about') }}">{{ gettext("About Us") }}</a> + </li> + <li class="nav-item"> + <a class="nav-link" href="{{ url_for('credits') }}">Credits</a> + </li> + <li class="nav-item"> + <a class="nav-link" href="{{ url_for('view_notifications') }}"> + <ion-icon name="notifications-outline"></ion-icon> + </a> + </li> + <li class="nav-item"> + <a class="nav-link"> + <ion-icon id="darkmode" name="moon-outline"></ion-icon> + </a> + </li> </ul> </div> </nav> @@ -80,6 +87,7 @@ {% block content %}{% endblock %} </div> + <script defer src="https://use.fontawesome.com/releases/v5.0.8/js/all.js" integrity="sha384-SlE991lGASHoBfWbelyBPLsUlwY1GwNDJo3jSJO04KZ33K2bwfV9YBauFfnzvynJ" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/js/bootstrap.bundle.min.js" integrity="sha384-ygbV9kiqUc6oa4msXn9868pTtWMgiQaeYH7/t7LECLbyPA2x65Kgf80OJFdroafW" crossorigin="anonymous"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.4/Chart.min.js" integrity="sha512-d9xgZrVZpmmQlfonhQUvTR7lMPtO7NkZMkA0ABN3PHCbKA5nqylQ/yWlFAyY6hYgdF1Qh6nYiuADWwKB4C2WSw==" crossorigin="anonymous"></script> @@ -89,11 +97,11 @@ <script src="{{ url_for('static', path='/horoscope.js') }}"></script> <script src="{{ url_for('static', path='/graph.js') }}"></script> <script type="text/javascript" src="{{ url_for( 'static', path='/audio_settings.js' ) }}"></script> + <script src="{{ url_for('static', path='/js/darkmode.js') }}"></script> <script src="{{ url_for('static', path='/joke.js') }}"></script> <script src="https://cdn.jsdelivr.net/npm/sweetalert2@10"></script> <audio id="my-audio" muted="true"></audio> <audio id="sfx" muted="true"></audio> </body> - </html> diff --git a/app/templates/calendar/layout.html b/app/templates/calendar/layout.html new file mode 100644 index 00000000..5f98e980 --- /dev/null +++ b/app/templates/calendar/layout.html @@ -0,0 +1,83 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <!-- Meta --> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + + <!-- Fonts --> + <link href="https://fonts.googleapis.com/css2?family=Assistant:wght@200;300;400;500;600;700;800&display=swap" rel="stylesheet"> + + <!-- CSS --> + <link href="{{ url_for('static', path='/grid_style.css') }}" rel="stylesheet" type="text/css"> + + <title>Calendar + + + +
+ +
+
FEATURE NAME
+
+
+
+
+
{{day.display()}}
+
Location 0oc 00:00
+
+ +
+
+ {% block main %} {% endblock %} +
+
+
+ + + + + + + + diff --git a/app/templates/calendar_day_view.html b/app/templates/calendar_day_view.html index b62491ba..8c2adafa 100644 --- a/app/templates/calendar_day_view.html +++ b/app/templates/calendar_day_view.html @@ -1,78 +1,104 @@ {% extends "partials/calendar/calendar_base.html" %} {% block body %}
- -
- {% if view == 'day' %} - - {{month}} - {{day}} - {% if zodiac %} -
- zodiac sign -
- {% endif %} - {% else %} - {{day}} / {{month}} - {% endif %} -
-
- {% for event in all_day_events %} -

{{ event.title }}

- {% endfor %} -
-
-
- {% for hour in range(25)%} -
-
- {% if view == 'day'%} - {% set hour = hour|string() %} - {{hour.zfill(2)}}:00 - {% endif %} + +
+ {% if view == 'day' %} + + {{month}} + {{day}} + {% if zodiac %} +
+ zodiac sign
-
+ {% endif %} + {% else %} + {{day}} / {{month}} + {% endif %} +
+
+ {% for event in all_day_events %} + {{ event.title }}    + {% endfor %}
- {% endfor %} + {% if international_day %} +
+ The International days are: "{{ international_day.international_day }}"
-
- {% for event, attr in events %} - {% set deleted_event = 'deleted-event' if event.deleted_date != None else '' %} -
-
-

{{ event.title }}

- {% if attr.total_time_visible %} -

{{attr.total_time}}

+ {% endif %} +
+
+ {% for hour in range(25)%} +
+
+ {% if view == 'day'%} + {% set hour = hour|string() %} + {{hour.zfill(2)}}:00 + {% endif %} +
+
+
+ {% endfor %} +
+
+ {% if current_time.is_viewed %} +
+
+
+
{% endif %}
-
- - - +
+ {% for event, attr in events_and_attrs %} + {% set deleted_event = 'deleted_event' if event.deleted_date != None else '' %} +
+
+

{{ + event.title }}

+ {% if attr.total_time_visible %} +

+ {{attr.total_time}}

+ {% endif %} +
+
+ + + +
+
+ {% endfor %}
-
- {% endfor %}
-
- {% if view == 'day'%} - - {% endif %} + {% if view == 'day'%} + + {% endif %}
{% if view == 'day'%}
{% endif %} - + {% endblock body %} diff --git a/app/templates/calendar_monthly_view.html b/app/templates/calendar_monthly_view.html index 5a1d3cf2..6386b5d4 100644 --- a/app/templates/calendar_monthly_view.html +++ b/app/templates/calendar_monthly_view.html @@ -1,17 +1,30 @@ {% extends "partials/calendar/calendar_base.html" %} {% block content %} -
+
-
{{ day.display() }}
-
Location 0oc 00:00
+
{{ day.display() }}
+
Location 0oc 00:00
+
+
+

Time calculator:

+
+ + +
+ + +
+ +

-
-
+
+
{% include 'partials/calendar/monthly_view/monthly_grid.html' %} -
-{% endblock content %} \ No newline at end of file + +{% endblock content %} diff --git a/app/templates/categories.html b/app/templates/categories.html index c70e8b1e..13f4c3d5 100644 --- a/app/templates/categories.html +++ b/app/templates/categories.html @@ -3,23 +3,22 @@ {% block head %} {{ super() }} - {% endblock %} {% block content %}
-

It's time to make some decisions

-

- Here you can create your unique categories and choose your favorite color -

+

It's time to make some decisions

+

+ Here you can create your unique categories and choose your favorite color +

-
- -

- -

-

-
+
+ +

+ +

+

+
{% if message %}
@@ -27,4 +26,4 @@

{% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/templates/celebrity.html b/app/templates/celebrity.html index e549f2ab..37086d26 100644 --- a/app/templates/celebrity.html +++ b/app/templates/celebrity.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "partials/index/index_base.html" %} {% block content %} @@ -16,4 +16,4 @@
Celebrities born today ({{ date }}):
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/templates/corona_stats.html b/app/templates/corona_stats.html new file mode 100644 index 00000000..69834afd --- /dev/null +++ b/app/templates/corona_stats.html @@ -0,0 +1,16 @@ + +{% if corona_stats_data.get("error") is none %} +
+
+
+

+ + COVID 19 Vaccinated

+

+ {{ corona_stats_data["vaccinated_second_dose_perc"] }}% + ({{ corona_stats_data["vaccinated_second_dose_total"] }}) +

+
+
+
+{% endif %} diff --git a/app/templates/credits.html b/app/templates/credits.html index 04d714dc..7b136bb8 100644 --- a/app/templates/credits.html +++ b/app/templates/credits.html @@ -6,7 +6,7 @@ {% endblock %} {% block content %} -

Say hello to our developers:

+

Say hello to our developers:

{% for credit in credit_list %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/templates/event/partials/edit_event_details_tab.html b/app/templates/event/partials/edit_event_details_tab.html new file mode 100644 index 00000000..75e48b41 --- /dev/null +++ b/app/templates/event/partials/edit_event_details_tab.html @@ -0,0 +1,73 @@ +
+ + +
+ +
+ + +
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ + + + +
+
+ + + + +
+
+ + + +
+
+ +
+
+
+ + +
+
+ + + + +
+
diff --git a/app/templates/eventedit.html b/app/templates/eventedit.html index e2f49ed3..a409b9c5 100644 --- a/app/templates/eventedit.html +++ b/app/templates/eventedit.html @@ -1,30 +1,45 @@ - -
- - -
-
- {% include "partials/calendar/event/edit_event_details_tab.html" %} -
- - -
-
- + + + + + + + + + + Event edit + + + + + +
+
+ {% include "partials/calendar/event/edit_event_details_tab.html" %}
- - + + +
+
+ +
+ + + + + + diff --git a/app/templates/eventview.html b/app/templates/eventview.html index 602f290c..06dec1f1 100644 --- a/app/templates/eventview.html +++ b/app/templates/eventview.html @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends "partials/index/index_base.html" %} {% block head %} {{ super() }} @@ -9,9 +9,9 @@ {% endblock head %} {% block content %} -
+
-
diff --git a/app/templates/partials/notification/base.html b/app/templates/partials/notification/base.html new file mode 100644 index 00000000..0df8fcde --- /dev/null +++ b/app/templates/partials/notification/base.html @@ -0,0 +1,56 @@ +{% extends "partials/base.html" %} +{% block head %} + {{super()}} + + + + + +{% endblock head %} +{% block body %} +
+ {% include 'partials/calendar/navigation.html' %} +
+ {% include 'partials/calendar/feature_settings/example.html' %} +
+
+ {% block content %} +
+ {% block description %} + {% endblock description %} + +
+
+
+ {% block link %} + {% endblock link %} +
+ {% if notifications %} +
+ + {% block optional %} + {% endblock optional %} + +
+ {% block notifications %} + {% endblock notifications %} +
+ + {% else %} + {% block no_notifications_msg %} + {% endblock no_notifications_msg %} +
+ {% endif %} +
+ {% endblock content %} +
+
+ + + + +{% endblock body %} diff --git a/app/templates/partials/notification/generate_archive.html b/app/templates/partials/notification/generate_archive.html new file mode 100644 index 00000000..2d0e0ed3 --- /dev/null +++ b/app/templates/partials/notification/generate_archive.html @@ -0,0 +1,29 @@ +{% for n in notifications %} + {% set type = n.__table__.name %} + +
+ {% if type == "invitations" %} +
+ Invitation from {{ n.event.owner.username }} - + {{ n.event.title }} + (declined) +
+
+
+ + +
+
+ + {% elif type == "messages" %} +
+ {% if n.link %}{{ n.body }} + {% else %}{{ n.body }}{% endif %} +
+ {% endif %} + +
+{% endfor %} diff --git a/app/templates/partials/notification/generate_notifications.html b/app/templates/partials/notification/generate_notifications.html new file mode 100644 index 00000000..b779db22 --- /dev/null +++ b/app/templates/partials/notification/generate_notifications.html @@ -0,0 +1,48 @@ +{% for n in notifications %} + {% set type = n.__table__.name %} + +
+ {% if type == "invitations" %} +
+ Invitation from {{ n.event.owner.username }} - + {{ n.event.title }} + ({{ n.event.start.strftime('%H:%M %m/%d/%Y') }}) +
+
+
+ + +
+
+ + +
+
+ + {% elif type == "messages" %} +
+ {% if n.link %}{{ n.body }} + {% else %}{{ n.body }}{% endif %} +
+
+
+ + +
+
+ {% endif %} + +
+{% endfor %} diff --git a/app/templates/partials/user_profile/middle_content.html b/app/templates/partials/user_profile/middle_content.html index 193dc351..540ac80f 100644 --- a/app/templates/partials/user_profile/middle_content.html +++ b/app/templates/partials/user_profile/middle_content.html @@ -3,6 +3,7 @@
+

Upcoming events

{% for event in events %} {% include 'partials/user_profile/middle_content/event_card.html' %} @@ -10,4 +11,4 @@ {% include 'partials/user_profile/middle_content/update_event_modal.html' %} {% endfor %}
-
\ No newline at end of file +
diff --git a/app/templates/partials/user_profile/middle_content/event_card.html b/app/templates/partials/user_profile/middle_content/event_card.html index 9c86560e..070598e9 100644 --- a/app/templates/partials/user_profile/middle_content/event_card.html +++ b/app/templates/partials/user_profile/middle_content/event_card.html @@ -1,15 +1,15 @@
- {{ gettext("Upcoming event on (date)", date=event.start) }} + Upcoming event on {{ event.start.strftime("%d/%m/%Y %H:%M") }} {% include 'partials/user_profile/middle_content/event_settings.html' %}

- The Event {{ event }} - description ... + {{ event.title }} - {{ event.content if event.content is not none }}


- Last updated {{time}} ago + {{ event.title }} details
-
\ No newline at end of file +
diff --git a/app/templates/partials/user_profile/sidebar_left.html b/app/templates/partials/user_profile/sidebar_left.html index 92c48acb..f2493b43 100644 --- a/app/templates/partials/user_profile/sidebar_left.html +++ b/app/templates/partials/user_profile/sidebar_left.html @@ -3,10 +3,14 @@ {% include 'partials/user_profile/sidebar_left/profile_card.html' %} + + {% include "corona_stats.html" %} + + {% include 'partials/user_profile/sidebar_left/features_card.html' %} {% include 'partials/user_profile/sidebar_left/daily_horoscope.html' %} -
\ No newline at end of file +
diff --git a/app/templates/partials/user_profile/sidebar_left/profile_card/user_details.html b/app/templates/partials/user_profile/sidebar_left/profile_card/user_details.html index 5f85416f..3c67f576 100644 --- a/app/templates/partials/user_profile/sidebar_left/profile_card/user_details.html +++ b/app/templates/partials/user_profile/sidebar_left/profile_card/user_details.html @@ -3,7 +3,7 @@
{{ user.full_name }}

- + Settings

@@ -11,4 +11,4 @@
{{ user.full_name }}

{{ user.description }}

-
\ No newline at end of file +
diff --git a/app/templates/profile.html b/app/templates/profile.html index db43bd9a..e6336c5c 100644 --- a/app/templates/profile.html +++ b/app/templates/profile.html @@ -1,4 +1,4 @@ -{% extends "base.html" %} +{% extends "partials/index/index_base.html" %} {% include "partials/calendar/event/text_editor_partial_head.html" %} {% block content %} @@ -13,7 +13,14 @@ {% include 'partials/user_profile/sidebar_right.html' %} +<<<<<<< HEAD
{% include "partials/calendar/event/text_editor_partial_body.html" %} +======= +
+
+ +{% include "partials/calendar/event/text_editor_partial_body.html" %} +>>>>>>> develop {% endblock content %} diff --git a/app/templates/register.html b/app/templates/register.html index a660b33e..1eb9c602 100644 --- a/app/templates/register.html +++ b/app/templates/register.html @@ -1,12 +1,12 @@ -{% extends "base.html" %} +{% extends "partials/index/index_base.html" %} {% block content %}
-

Please Register

-
+

Please Register

+
- {% if errors %} @@ -20,7 +20,7 @@

Please Register

Must be between 3 to 20 characters.
- {% if errors and "username" in errors %} @@ -112,4 +112,4 @@

Please Register

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/templates/reset_password.html b/app/templates/reset_password.html new file mode 100644 index 00000000..cbfa5fbd --- /dev/null +++ b/app/templates/reset_password.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block content %} +
+
Please choose a new password:
+ {% if message %} +
{{ message }}
+ {% endif %} +
+
+ +
+
+
+
+
+ +
+
+
+
+ + Must be between 3 to 20 characters. + +
+
+
+ +
+
+
+
+ + Must be equal to password. + +
+
+ +
+ +
+ +{% endblock %} diff --git a/app/templates/reset_password_mail.html b/app/templates/reset_password_mail.html new file mode 100644 index 00000000..1170631c --- /dev/null +++ b/app/templates/reset_password_mail.html @@ -0,0 +1,6 @@ +

Hi, {{ recipient }}!

+

You received this email from Calendar because you asked to reset your password.

+

To continue with resetting your password, please click the confirmation link

+

This confirmation link will expired in 15 minutes

+

This email has been sent to {{ email }}.

+

If you did not ask for it, please ignore it.

diff --git a/app/templates/salary/month.j2 b/app/templates/salary/month.j2 index bc240968..1d407192 100644 --- a/app/templates/salary/month.j2 +++ b/app/templates/salary/month.j2 @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends "partials/index/index_base.html" %} {% block content %}
@@ -35,4 +35,4 @@

Need to alter your settings? Edit your settings here

-{% endblock content %} \ No newline at end of file +{% endblock content %} diff --git a/app/templates/salary/pick.j2 b/app/templates/salary/pick.j2 index 4c57f0cc..e3014dc8 100644 --- a/app/templates/salary/pick.j2 +++ b/app/templates/salary/pick.j2 @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends "partials/index/index_base.html" %} {% block content %}
@@ -32,4 +32,4 @@

Want to create salary settings for a different category? Create settings here

-{% endblock content %} \ No newline at end of file +{% endblock content %} diff --git a/app/templates/salary/settings.j2 b/app/templates/salary/settings.j2 index 7c950148..a7782e92 100644 --- a/app/templates/salary/settings.j2 +++ b/app/templates/salary/settings.j2 @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends "partials/index/index_base.html" %} {% block content %}
@@ -12,7 +12,7 @@ Salary Settings - +
{% if categories %}
@@ -24,7 +24,7 @@ {% endfor %}
- +

Want a new category? Create one here

@@ -32,7 +32,7 @@
- +
@@ -121,4 +121,4 @@ {% endif %}
-{% endblock content %} \ No newline at end of file +{% endblock content %} diff --git a/app/templates/salary/view.j2 b/app/templates/salary/view.j2 index 95283348..bff053e0 100644 --- a/app/templates/salary/view.j2 +++ b/app/templates/salary/view.j2 @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends "partials/index/index_base.html" %} {% block content %}
@@ -7,7 +7,7 @@

{{ category }}

{{ month }} {{ salary.year}}

- +

Base Salary

Hourly Wage: {{ wage.wage }}

@@ -24,13 +24,13 @@ {% if salary.bonus %}

Bonus: {{ salary.bonus }}

{% endif %} - + {% if salary.deduction %}

Deductions

Deduction: {{ salary.deduction }}

{% endif %}
- +
-{% endblock content %} \ No newline at end of file +{% endblock content %} diff --git a/app/templates/search.html b/app/templates/search.html index 6db8bd60..eb56dbb0 100644 --- a/app/templates/search.html +++ b/app/templates/search.html @@ -1,9 +1,9 @@ -{% extends "base.html" %} +{% extends "partials/index/index_base.html" %} {% block content %} -
+

Hello, {{ username }}

@@ -20,7 +20,7 @@

Hello, {{ username }}

{% if message %} -
+
{{ message }}
{% endif %} @@ -68,4 +68,4 @@

Hello, {{ username }}

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/app/templates/settings.html b/app/templates/settings.html new file mode 100644 index 00000000..ee741658 --- /dev/null +++ b/app/templates/settings.html @@ -0,0 +1,127 @@ +{% extends "./partials/base.html" %} +{% block head %} + {{ super() }} + + + + + + +{% endblock head %} +{% block page_name %}Month View{% endblock page_name %} +{% block body %} +
+ {% include 'partials/calendar/navigation.html' %} +
+ {% include 'partials/calendar/feature_settings/example.html' %} +
+
+ {% block content %} +
+
+
SETTINGS
+

Change your preferences

+
+ +
+
+ + +
+ +
+

Language and region

+ + + + + + + + +
+ +
+

Audio settings

+
+ + +
+
+ + +
+
+ +
+

View options

+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ {% endblock content %} +
+
+ + +{% endblock body %} diff --git a/app/templates/weekview.html b/app/templates/weekview.html index a23f62de..76c626d4 100644 --- a/app/templates/weekview.html +++ b/app/templates/weekview.html @@ -12,12 +12,15 @@
- {% for day, dayview, events_and_attr in week %} + {% for day, dayview, events_and_attrs, current_time, all_day_events in week %}
-
{{ day.strftime('%A').upper()[:3] }}
+
{{ day.strftime('%a').upper()}} + {% for event in all_day_events %} + {{ event.title }} + {% endfor %} +
{% set month = day.month %} {% set day = day.day %} - {% set events = events_and_attr%} {% include dayview.template %}
{% endfor %} @@ -35,4 +38,4 @@
- \ No newline at end of file + diff --git a/requirements.txt b/requirements.txt index 5bbdca17..694f6209 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 @@ -66,6 +69,7 @@ importlib-metadata==3.3.0 inflect==4.1.0 iniconfig==1.1.1 iso-639==0.4.5 +isort==5.6.4 Jinja2==2.11.2 joblib==1.0.0 lazy-object-proxy==1.5.2 @@ -77,6 +81,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 +89,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 +154,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/schema.md b/schema.md index 669162c6..8b9a03a3 100644 --- a/schema.md +++ b/schema.md @@ -47,4 +47,4 @@ ├── test_categories.py ├── test_email.py ├── test_event.py - └── test_profile.py \ No newline at end of file + └── test_profile.py diff --git a/tests/conftest.py b/tests/conftest.py index 1d8a21d2..9cc2cf02 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ import calendar +import nest_asyncio import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @@ -8,20 +9,24 @@ from app.database.models import Base pytest_plugins = [ - 'tests.user_fixture', - 'tests.event_fixture', - 'tests.dayview_fixture', - 'tests.invitation_fixture', - 'tests.association_fixture', - 'tests.client_fixture', - 'tests.asyncio_fixture', - 'tests.logger_fixture', - 'tests.category_fixture', - 'smtpdfix', - 'tests.quotes_fixture', - 'tests.zodiac_fixture', - 'tests.jokes_fixture', - 'tests.comment_fixture', + "tests.fixtures.user_fixture", + "tests.fixtures.event_fixture", + "tests.fixtures.invitation_fixture", + "tests.fixtures.message_fixture", + "tests.fixtures.association_fixture", + "tests.fixtures.client_fixture", + "tests.fixtures.asyncio_fixture", + "tests.fixtures.logger_fixture", + "tests.fixtures.category_fixture", + "tests.fixtures.quotes_fixture", + "tests.fixtures.zodiac_fixture", + "tests.fixtures.dayview_fixture", + "tests.fixtures.comment_fixture", + "tests.fixtures.quotes_fixture", + "tests.fixtures.zodiac_fixture", + "tests.fixtures.jokes_fixture", + "tests.fixtures.comment_fixture", + "smtpdfix", ] # When testing in a PostgreSQL environment please make sure that: @@ -30,21 +35,22 @@ if PSQL_ENVIRONMENT: SQLALCHEMY_TEST_DATABASE_URL = ( - "postgresql://postgres:1234" - "@localhost/postgres" - ) - test_engine = create_engine( - SQLALCHEMY_TEST_DATABASE_URL + "postgresql://postgres:1234" "@localhost/postgres" ) + test_engine = create_engine(SQLALCHEMY_TEST_DATABASE_URL) else: SQLALCHEMY_TEST_DATABASE_URL = "sqlite:///./test.db" test_engine = create_engine( - SQLALCHEMY_TEST_DATABASE_URL, connect_args={"check_same_thread": False} + SQLALCHEMY_TEST_DATABASE_URL, + connect_args={"check_same_thread": False}, ) TestingSessionLocal = sessionmaker( - autocommit=False, autoflush=False, bind=test_engine) + autocommit=False, + autoflush=False, + bind=test_engine, +) def get_test_db(): @@ -65,11 +71,15 @@ def session(): def sqlite_engine(): SQLALCHEMY_TEST_DATABASE_URL = "sqlite:///./test.db" sqlite_test_engine = create_engine( - SQLALCHEMY_TEST_DATABASE_URL, connect_args={"check_same_thread": False} + SQLALCHEMY_TEST_DATABASE_URL, + connect_args={"check_same_thread": False}, ) TestingSession = sessionmaker( - autocommit=False, autoflush=False, bind=sqlite_test_engine) + autocommit=False, + autoflush=False, + bind=sqlite_test_engine, + ) yield sqlite_test_engine session = TestingSession() @@ -80,3 +90,6 @@ def sqlite_engine(): @pytest.fixture def Calendar(): return calendar.Calendar(0) + + +nest_asyncio.apply() diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/association_fixture.py b/tests/fixtures/association_fixture.py similarity index 71% rename from tests/association_fixture.py rename to tests/fixtures/association_fixture.py index 92c845c2..f56d3e57 100644 --- a/tests/association_fixture.py +++ b/tests/fixtures/association_fixture.py @@ -7,6 +7,5 @@ @pytest.fixture def association(event: Event, session: Session) -> UserEvent: return ( - session.query(UserEvent) - .filter(UserEvent.event_id == event.id) + session.query(UserEvent).filter(UserEvent.event_id == event.id) ).first() diff --git a/tests/asyncio_fixture.py b/tests/fixtures/asyncio_fixture.py similarity index 83% rename from tests/asyncio_fixture.py rename to tests/fixtures/asyncio_fixture.py index db6645c5..e1553250 100644 --- a/tests/asyncio_fixture.py +++ b/tests/fixtures/asyncio_fixture.py @@ -1,14 +1,14 @@ from datetime import datetime, timedelta -from httpx import AsyncClient import pytest +from httpx import AsyncClient from app.database.models import Base from app.main import app from app.routers import telegram from app.routers.event import create_event -from tests.client_fixture import get_test_placeholder_user from tests.conftest import get_test_db, test_engine +from tests.fixtures.client_fixture import get_test_placeholder_user @pytest.fixture @@ -32,22 +32,24 @@ def fake_user_events(session): session.commit() create_event( db=session, - title='Cool today event', + title="Cool today event", + color="red", start=today_date, end=today_date + timedelta(days=2), all_day=False, - content='test event', + content="test event", owner_id=user.id, location="Here", is_google_event=False, ) create_event( db=session, - title='Cool (somewhen in two days) event', + title="Cool (somewhen in two days) event", + color="blue", start=today_date + timedelta(days=1), end=today_date + timedelta(days=3), all_day=False, - content='this week test event', + content="this week test event", owner_id=user.id, location="Here", is_google_event=False, diff --git a/tests/category_fixture.py b/tests/fixtures/category_fixture.py similarity index 64% rename from tests/category_fixture.py rename to tests/fixtures/category_fixture.py index 469b3593..fcca9680 100644 --- a/tests/category_fixture.py +++ b/tests/fixtures/category_fixture.py @@ -6,8 +6,12 @@ @pytest.fixture def category(session: Session, sender: User) -> Category: - category = Category.create(session, name="Guitar Lesson", color="121212", - user_id=sender.id) + category = Category.create( + session, + name="Guitar Lesson", + color="121212", + user_id=sender.id, + ) yield category session.delete(category) session.commit() diff --git a/tests/client_fixture.py b/tests/fixtures/client_fixture.py similarity index 81% rename from tests/client_fixture.py rename to tests/fixtures/client_fixture.py index 465cfe8d..eb96ef68 100644 --- a/tests/client_fixture.py +++ b/tests/fixtures/client_fixture.py @@ -1,7 +1,7 @@ -from typing import Generator, Iterator +from typing import Dict, Generator, Iterator -from fastapi.testclient import TestClient import pytest +from fastapi.testclient import TestClient from sqlalchemy.orm import Session from app import main @@ -10,20 +10,29 @@ agenda, audio, categories, + dayview, event, friendview, google_connect, - invitation, + meds, + notification, profile, + weekview, weight, ) from app.routers.salary import routes as salary from tests import security_testing_routes from tests.conftest import get_test_db, test_engine +LOGIN_DATA_TYPE = Dict[str, str] + main.app.include_router(security_testing_routes.router) +def login_client(client: TestClient, data: LOGIN_DATA_TYPE) -> None: + client.post(client.app.url_path_for("login"), data=data) + + def get_test_placeholder_user() -> User: return User( username="fake_user", @@ -56,6 +65,11 @@ def agenda_test_client() -> Generator[TestClient, None, None]: yield from create_test_client(agenda.get_db) +@pytest.fixture(scope="session") +def notification_test_client(): + yield from create_test_client(notification.get_db) + + @pytest.fixture(scope="session") def friendview_test_client() -> Generator[TestClient, None, None]: yield from create_test_client(friendview.get_db) @@ -76,11 +90,6 @@ def home_test_client() -> Generator[TestClient, None, None]: yield from create_test_client(main.get_db) -@pytest.fixture(scope="session") -def invitation_test_client() -> Generator[TestClient, None, None]: - yield from create_test_client(invitation.get_db) - - @pytest.fixture(scope="session") def categories_test_client() -> Generator[TestClient, None, None]: yield from create_test_client(categories.get_db) @@ -116,6 +125,11 @@ def salary_test_client() -> Iterator[TestClient]: yield from create_test_client(salary.get_db) +@pytest.fixture(scope="session") +def meds_test_client() -> Iterator[TestClient]: + yield from create_test_client(meds.get_db) + + @pytest.fixture(scope="session") def google_connect_test_client(): Base.metadata.create_all(bind=test_engine) @@ -126,3 +140,13 @@ def google_connect_test_client(): main.app.dependency_overrides = {} Base.metadata.drop_all(bind=test_engine) + + +@pytest.fixture(scope="session") +def dayview_test_client() -> Iterator[TestClient]: + yield from create_test_client(dayview.get_db) + + +@pytest.fixture(scope="session") +def weekview_test_client() -> Iterator[TestClient]: + yield from create_test_client(weekview.get_db) diff --git a/tests/comment_fixture.py b/tests/fixtures/comment_fixture.py similarity index 79% rename from tests/comment_fixture.py rename to tests/fixtures/comment_fixture.py index 5c0a3671..651f01f9 100644 --- a/tests/comment_fixture.py +++ b/tests/fixtures/comment_fixture.py @@ -11,10 +11,10 @@ @pytest.fixture def comment(session: Session, event: Event, user: User) -> Iterator[Comment]: data = { - 'user': user, - 'event': event, - 'content': 'test comment', - 'time': datetime(2021, 1, 1, 0, 1), + "user": user, + "event": event, + "content": "test comment", + "time": datetime(2021, 1, 1, 0, 1), } create_model(session, Comment, **data) comment = session.query(Comment).first() diff --git a/tests/dayview_fixture.py b/tests/fixtures/dayview_fixture.py similarity index 52% rename from tests/dayview_fixture.py rename to tests/fixtures/dayview_fixture.py index 769651a3..4ebef7a7 100644 --- a/tests/dayview_fixture.py +++ b/tests/fixtures/dayview_fixture.py @@ -9,63 +9,112 @@ def event1(): start = datetime(year=2021, month=2, day=1, hour=7, minute=5) end = datetime(year=2021, month=2, day=1, hour=9, minute=15) - return Event(title='test1', content='test', - start=start, end=end, owner_id=1) + return Event( + title="test1", + content="test", + start=start, + end=end, + owner_id=1, + ) @pytest.fixture def event2(): start = datetime(year=2021, month=2, day=1, hour=13, minute=13) end = datetime(year=2021, month=2, day=1, hour=15, minute=46) - return Event(title='test2', content='test', - start=start, end=end, owner_id=1, color='blue') + return Event( + title="test2", + content="test", + start=start, + end=end, + owner_id=1, + color="blue", + ) @pytest.fixture def event3(): start = datetime(year=2021, month=2, day=3, hour=7, minute=5) end = datetime(year=2021, month=2, day=3, hour=9, minute=15) - return Event(title='test3', content='test', - start=start, end=end, owner_id=1) + return Event( + title="test3", + content="test", + start=start, + end=end, + owner_id=1, + ) @pytest.fixture def all_day_event1(): start = datetime(year=2021, month=2, day=3, hour=7, minute=5) end = datetime(year=2021, month=2, day=3, hour=9, minute=15) - return Event(title='test3', content='test', all_day=True, - start=start, end=end, owner_id=1) + return Event( + title="test3", + content="test", + all_day=True, + start=start, + end=end, + owner_id=1, + ) @pytest.fixture def small_event(): start = datetime(year=2021, month=2, day=3, hour=7) end = datetime(year=2021, month=2, day=3, hour=8, minute=30) - return Event(title='test3', content='test', - start=start, end=end, owner_id=1) + return Event( + title="test3", + content="test", + start=start, + end=end, + owner_id=1, + ) @pytest.fixture def event_with_no_minutes_modified(): start = datetime(year=2021, month=2, day=3, hour=7) end = datetime(year=2021, month=2, day=3, hour=8) - return Event(title='test_no_modify', content='test', - start=start, end=end, owner_id=1) + return Event( + title="test_no_modify", + content="test", + start=start, + end=end, + owner_id=1, + ) @pytest.fixture def multiday_event(): start = datetime(year=2021, month=2, day=1, hour=13) end = datetime(year=2021, month=2, day=3, hour=13) - return Event(title='test_multiday', content='test', - start=start, end=end, owner_id=1, color='blue') + return Event( + title="test_multiday", + content="test", + start=start, + end=end, + owner_id=1, + color="blue", + ) + + +@pytest.fixture +def not_today(): + date = datetime(year=2012, month=12, day=12, hour=12, minute=12) + return date @pytest.fixture def weekdays(): return [ - 'Sunday', 'Monday', 'Tuesday', - 'Wednesday', 'Thursday', 'Friday', 'Saturday', + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", ] diff --git a/tests/event_fixture.py b/tests/fixtures/event_fixture.py similarity index 82% rename from tests/event_fixture.py rename to tests/fixtures/event_fixture.py index 989c41fb..17213e6f 100644 --- a/tests/event_fixture.py +++ b/tests/fixtures/event_fixture.py @@ -13,10 +13,10 @@ def event(sender: User, category: Category, session: Session) -> Event: return create_event( db=session, - title='event', + title="event", start=today_date, end=today_date, - content='test event', + content="test event", owner_id=sender.id, location="Some random location", vc_link=None, @@ -28,11 +28,11 @@ def event(sender: User, category: Category, session: Session) -> Event: def today_event(sender: User, session: Session) -> Event: return create_event( db=session, - title='event 1', + title="event 1", start=today_date + timedelta(hours=7), end=today_date + timedelta(hours=9), all_day=False, - content='test event', + content="test event", owner_id=sender.id, ) @@ -41,11 +41,12 @@ def today_event(sender: User, session: Session) -> Event: def today_event_2(sender: User, session: Session) -> Event: return create_event( db=session, - title='event 2', + title="event 2", + color="blue", start=today_date + timedelta(hours=3), end=today_date + timedelta(days=2, hours=3), all_day=False, - content='test event', + content="test event", owner_id=sender.id, ) @@ -54,11 +55,12 @@ def today_event_2(sender: User, session: Session) -> Event: def yesterday_event(sender: User, session: Session) -> Event: return create_event( db=session, - title='event 3', + title="event 3", + color="green", start=today_date - timedelta(hours=8), end=today_date, all_day=False, - content='test event', + content="test event", owner_id=sender.id, ) @@ -67,11 +69,12 @@ def yesterday_event(sender: User, session: Session) -> Event: def next_week_event(sender: User, session: Session) -> Event: return create_event( db=session, - title='event 4', + title="event 4", + color="blue", start=today_date + timedelta(days=7, hours=2), end=today_date + timedelta(days=7, hours=4), all_day=False, - content='test event', + content="test event", owner_id=sender.id, ) @@ -80,11 +83,12 @@ def next_week_event(sender: User, session: Session) -> Event: def next_month_event(sender: User, session: Session) -> Event: return create_event( db=session, - title='event 5', + title="event 5", + color="green", start=today_date + timedelta(days=20, hours=4), end=today_date + timedelta(days=20, hours=6), all_day=False, - content='test event', + content="test event", owner_id=sender.id, ) @@ -93,11 +97,12 @@ def next_month_event(sender: User, session: Session) -> Event: def old_event(sender: User, session: Session) -> Event: return create_event( db=session, - title='event 6', + title="event 6", + color="red", start=today_date - timedelta(days=5), end=today_date - timedelta(days=1), all_day=False, - content='test event', + content="test event", owner_id=sender.id, ) @@ -106,11 +111,11 @@ def old_event(sender: User, session: Session) -> Event: def all_day_event(sender: User, category: Category, session: Session) -> Event: return create_event( db=session, - title='event', + title="event", start=today_date, end=today_date, all_day=True, - content='test event', + content="test event", owner_id=sender.id, location="Some random location", category_id=category.id, diff --git a/tests/invitation_fixture.py b/tests/fixtures/invitation_fixture.py similarity index 89% rename from tests/invitation_fixture.py rename to tests/fixtures/invitation_fixture.py index 7a37715b..aeafd201 100644 --- a/tests/invitation_fixture.py +++ b/tests/fixtures/invitation_fixture.py @@ -10,7 +10,9 @@ @pytest.fixture def invitation( - event: Event, user: User, session: Session + event: Event, + user: User, + session: Session, ) -> Generator[Invitation, None, None]: """Returns an Invitation object after being created in the database. @@ -23,7 +25,8 @@ def invitation( An Invitation object. """ invitation = create_model( - session, Invitation, + session, + Invitation, creation=datetime.now(), recipient=user, event=event, diff --git a/tests/jokes_fixture.py b/tests/fixtures/jokes_fixture.py similarity index 89% rename from tests/jokes_fixture.py rename to tests/fixtures/jokes_fixture.py index d7e3258c..062d5d45 100644 --- a/tests/jokes_fixture.py +++ b/tests/fixtures/jokes_fixture.py @@ -16,5 +16,5 @@ def joke(session: Session) -> Joke: yield from add_joke( session=session, id_joke=1, - text='Chuck Norris can slam a revolving door.', + text="Chuck Norris can slam a revolving door.", ) diff --git a/tests/logger_fixture.py b/tests/fixtures/logger_fixture.py similarity index 56% rename from tests/logger_fixture.py rename to tests/fixtures/logger_fixture.py index f6102f80..e3f488f7 100644 --- a/tests/logger_fixture.py +++ b/tests/fixtures/logger_fixture.py @@ -1,21 +1,23 @@ import logging +import pytest from _pytest.logging import caplog as _caplog # noqa: F401 from loguru import logger -import pytest from app import config from app.internal.logger_customizer import LoggerCustomizer -@pytest.fixture(scope='module') +@pytest.fixture(scope="module") def logger_instance(): - _logger = LoggerCustomizer.make_logger(config.LOG_PATH, - config.LOG_FILENAME, - config.LOG_LEVEL, - config.LOG_ROTATION_INTERVAL, - config.LOG_RETENTION_INTERVAL, - config.LOG_FORMAT) + _logger = LoggerCustomizer.make_logger( + config.LOG_PATH, + config.LOG_FILENAME, + config.LOG_LEVEL, + config.LOG_ROTATION_INTERVAL, + config.LOG_RETENTION_INTERVAL, + config.LOG_FORMAT, + ) return _logger diff --git a/tests/fixtures/message_fixture.py b/tests/fixtures/message_fixture.py new file mode 100644 index 00000000..a1aa207a --- /dev/null +++ b/tests/fixtures/message_fixture.py @@ -0,0 +1,31 @@ +import pytest +from sqlalchemy.orm import Session + +from app.database.models import Message, User +from app.internal.utils import create_model, delete_instance + + +@pytest.fixture +def message(user: User, session: Session) -> Message: + invitation = create_model( + session, + Message, + body="A test message", + link="#", + recipient_id=user.id, + ) + yield invitation + delete_instance(session, invitation) + + +@pytest.fixture +def sec_message(user: User, session: Session) -> Message: + invitation = create_model( + session, + Message, + body="A test message", + link="#", + recipient_id=user.id, + ) + yield invitation + delete_instance(session, invitation) diff --git a/tests/quotes_fixture.py b/tests/fixtures/quotes_fixture.py similarity index 74% rename from tests/quotes_fixture.py rename to tests/fixtures/quotes_fixture.py index 3f9d4e80..ef7c0bbd 100644 --- a/tests/quotes_fixture.py +++ b/tests/fixtures/quotes_fixture.py @@ -6,7 +6,10 @@ def add_quote( - session: Session, id_quote: int, text: str, author: str + session: Session, + id_quote: int, + text: str, + author: str, ) -> Quote: quote = create_model( session, @@ -24,8 +27,8 @@ def quote1(session: Session) -> Quote: yield from add_quote( session=session, id_quote=1, - text='You have to believe in yourself.', - author='Sun Tzu', + text="You have to believe in yourself.", + author="Sun Tzu", ) @@ -34,6 +37,6 @@ def quote2(session: Session) -> Quote: yield from add_quote( session=session, id_quote=2, - text='Wisdom begins in wonder.', - author='Socrates', + text="Wisdom begins in wonder.", + author="Socrates", ) diff --git a/tests/user_fixture.py b/tests/fixtures/user_fixture.py similarity index 68% rename from tests/user_fixture.py rename to tests/fixtures/user_fixture.py index b50fb900..e2a7ad26 100644 --- a/tests/user_fixture.py +++ b/tests/fixtures/user_fixture.py @@ -4,20 +4,24 @@ from sqlalchemy.orm import Session from app.database.models import User +from app.database.schemas import UserCreate from app.internal.utils import create_model, delete_instance +from app.routers.register import create_user @pytest.fixture -def user(session: Session) -> Generator[User, None, None]: - mock_user = create_model( - session, - User, +async def user(session: Session) -> Generator[User, None, None]: + schema = UserCreate( username="test_username", password="test_password", + confirm_password="test_password", email="test.email@gmail.com", + full_name="test_full_name", + description="test_description", language_id=1, target_weight=60, ) + mock_user = await create_user(session, schema) yield mock_user delete_instance(session, mock_user) diff --git a/tests/zodiac_fixture.py b/tests/fixtures/zodiac_fixture.py similarity index 92% rename from tests/zodiac_fixture.py rename to tests/fixtures/zodiac_fixture.py index f5ebee5e..fab6e784 100644 --- a/tests/zodiac_fixture.py +++ b/tests/fixtures/zodiac_fixture.py @@ -8,7 +8,8 @@ @pytest.fixture def zodiac_sign(session: Session) -> Zodiac: zodiac = create_model( - session, Zodiac, + session, + Zodiac, name="aries", start_month=3, start_day_in_month=20, diff --git a/tests/meds/test_internal.py b/tests/meds/test_internal.py new file mode 100644 index 00000000..8a627d4d --- /dev/null +++ b/tests/meds/test_internal.py @@ -0,0 +1,508 @@ +from datetime import date, datetime, time +from typing import Dict, List + +import pytest +from sqlalchemy.orm.session import Session + +from app.database.models import Event, User +from app.internal import meds + +NAME = "Pasta" +QUOTE = "I don't like sand. It's coarse and rough and irritating and it \ + gets everywhere." +WEB_FORM = { + "name": NAME, + "start": "2015-10-21", + "first": "", + "end": "2015-10-22", + "amount": "3", + "early": "08:00", + "late": "22:00", + "min": "04:00", + "max": "06:00", + "note": QUOTE, +} +FORM = meds.trans_form(WEB_FORM)[0] + + +def create_test_form( + form_dict: bool = False, **kwargs: Dict[str, str] +) -> meds.Form: + form = WEB_FORM.copy() + for k, v in kwargs.items(): + form[k] = v + if form_dict: + return form + translated_form, _ = meds.trans_form(form) + return translated_form + + +ADJUST = [ + (datetime(2015, 10, 21), time(8), time(22), False, datetime(2015, 10, 21)), + (datetime(2015, 10, 21), time(8), time(22), True, datetime(2015, 10, 21)), + (datetime(2015, 10, 21), time(8), time(8), False, datetime(2015, 10, 21)), + (datetime(2015, 10, 21), time(8), time(8), True, datetime(2015, 10, 22)), + (datetime(2015, 10, 21), time(8), time(2), False, datetime(2015, 10, 22)), + (datetime(2015, 10, 21), time(8), time(2), True, datetime(2015, 10, 22)), +] + +FORM_TRANS = [ + ( + WEB_FORM, + meds.Form( + name=NAME, + first=None, + amount=3, + early=time(8), + late=time(22), + min=time(4), + max=time(6), + start=datetime(2015, 10, 21, 8), + end=datetime(2015, 10, 22, 22), + note=QUOTE, + ), + { + "name": NAME, + "first": None, + "amount": 3, + "early": time(8), + "late": time(22), + "min": time(4), + "max": time(6), + "start": date(2015, 10, 21), + "end": date(2015, 10, 22), + "note": QUOTE, + }, + ), + ( + create_test_form(form_dict=True, first="13:30"), + meds.Form( + name=NAME, + first=time(13, 30), + amount=3, + early=time(8), + late=time(22), + min=time(4), + max=time(6), + start=datetime(2015, 10, 21, 13, 30), + end=datetime(2015, 10, 22, 22), + note=QUOTE, + ), + { + "name": NAME, + "first": time(13, 30), + "amount": 3, + "early": time(8), + "late": time(22), + "min": time(4), + "max": time(6), + "start": date(2015, 10, 21), + "end": date(2015, 10, 22), + "note": QUOTE, + }, + ), +] + +TIMES = [ + (time(13), 780), + (time(17, 26), 1046), +] + +INTERVAL_MINUTE = [ + (time(4), time(4), 0), + (time(8), time(22), 840), + (time(12), time(2), 840), +] + +AMOUNTS = [ + (1, time(12), time(9), time(17), True), + (2, time(4), time(8), time(22), True), + (3, time(8), time(10), time(20), False), +] + +EVENTS = [ + (FORM, True), + (create_test_form(amount="60"), False), + (create_test_form(end="2015-11-22"), False), +] + +FORM_VALIDATE = [ + (FORM, [False, False, False, False]), + ( + create_test_form( + end=WEB_FORM["start"], + max=WEB_FORM["min"], + amount="1", + late="10:00", + ), + [False, False, False, False], + ), + (create_test_form(end="1985-10-26"), [True, False, False, False]), + (create_test_form(max="03:00"), [False, True, False, False]), + (create_test_form(late="10:00"), [False, False, True, False]), + (create_test_form(min="00:01", amount="60"), [False, False, False, True]), + ( + create_test_form( + end="1985-10-26", + max="03:00", + late="10:00", + amount="60", + ), + [True, True, True, False], + ), + ( + create_test_form(max="03:00", late="10:00", amount="60"), + [False, True, True, True], + ), +] + +CALC_INTERVAL = [ + (create_test_form(amount="1"), 0), + (FORM, 18000), + (create_test_form(min="00:01", max="23:59"), 25200), +] + +REMINDER_TIMES = [ + (FORM, [time(10), time(15), time(20)]), + (create_test_form(amount="1"), [time(15)]), + ( + create_test_form(min="00:01", max="23:59"), + [time(8), time(15), time(22)], + ), + ( + create_test_form(early="13:00", late="02:00"), + [time(14, 30), time(19, 30), time(0, 30)], + ), +] + +DATETIMES_VALIDATE = [ + (datetime(1605, 11, 5, 23), date(1605, 11, 5), time(8), time(22), False), + (datetime(1605, 11, 5, 21), date(1605, 11, 5), time(8), time(22), True), + (datetime(1605, 11, 5, 23), date(1605, 11, 5), time(12), time(2), True), +] + +VALIDATE_FIRST = [ + (datetime(2015, 10, 21, 10, 45), time(15), time(4), time(6), True), + (datetime(2015, 10, 21, 10, 45), time(12), time(4), time(6), False), + (datetime(2015, 10, 21, 10, 45), time(17), time(4), time(6), False), +] + +DIFFERENT = [ + ( + datetime(2015, 10, 21, 11, 45), + time(4), + time(8), + time(22), + datetime(2015, 10, 21, 15, 45), + ), + (datetime(2015, 10, 21, 20, 45), time(4), time(8), time(22), None), +] + +CREATE_FIRST = [ + ( + create_test_form(first="10:45"), + time(15), + datetime(2015, 10, 21, 10, 45), + datetime(2015, 10, 21, 15), + ), + ( + create_test_form(first="10:45"), + time(14), + datetime(2015, 10, 21, 10, 45), + datetime(2015, 10, 21, 14, 45), + ), + ( + create_test_form(first="20:30", late="02:00"), + time(1), + datetime(2015, 10, 21, 20, 30), + datetime(2015, 10, 22, 1), + ), + ( + create_test_form(first="21:30", late="02:00"), + time(1), + datetime(2015, 10, 21, 21, 30), + datetime(2015, 10, 22, 1, 30), + ), + ( + create_test_form(first="16:30", late="02:00"), + time(10), + datetime(2015, 10, 21, 16, 30), + None, + ), +] + +FIRST = [ + ( + create_test_form(first="10:45"), + [time(10), time(15), time(20)], + [ + datetime(2015, 10, 21, 10, 45), + datetime(2015, 10, 21, 15), + datetime(2015, 10, 21, 20), + ], + ), + ( + create_test_form(first="13:30"), + [time(10), time(15), time(20)], + [ + datetime(2015, 10, 21, 13, 30), + datetime(2015, 10, 21, 17, 30), + datetime(2015, 10, 21, 21, 30), + ], + ), + ( + create_test_form(first="17:20"), + [time(10), time(15), time(20)], + [datetime(2015, 10, 21, 17, 20), datetime(2015, 10, 21, 21, 20)], + ), + ( + create_test_form(first="16:43", early="12:00", late="02:00"), + [time(14), time(19), time(0)], + [ + datetime(2015, 10, 21, 16, 43), + datetime(2015, 10, 21, 20, 43), + datetime(2015, 10, 22, 0, 43), + ], + ), +] + +REMINDERS = [ + ( + [time(10), time(15), time(20)], + time(8), + datetime(2015, 10, 21, 8), + 0, + datetime(2015, 10, 22, 22), + [ + datetime(2015, 10, 21, 10), + datetime(2015, 10, 21, 15), + datetime(2015, 10, 21, 20), + ], + ), + ( + [time(10), time(15), time(20)], + time(8), + datetime(2015, 10, 21, 8), + 1, + datetime(2015, 10, 22, 22), + [ + datetime(2015, 10, 22, 10), + datetime(2015, 10, 22, 15), + datetime(2015, 10, 22, 20), + ], + ), + ( + [time(10), time(15), time(20)], + time(8), + datetime(2015, 10, 21, 8), + 2, + datetime(2015, 10, 22, 22), + [], + ), +] + +DATETIMES = [ + ( + FORM, + [ + datetime(2015, 10, 21, 10), + datetime(2015, 10, 21, 15), + datetime(2015, 10, 21, 20), + datetime(2015, 10, 22, 10), + datetime(2015, 10, 22, 15), + datetime(2015, 10, 22, 20), + ], + ), + ( + create_test_form(first="13:30"), + [ + datetime(2015, 10, 21, 13, 30), + datetime(2015, 10, 21, 17, 30), + datetime(2015, 10, 21, 21, 30), + datetime(2015, 10, 22, 10), + datetime(2015, 10, 22, 15), + datetime(2015, 10, 22, 20), + ], + ), +] + +CREATE = [ + (create_test_form(name=None), False), + (FORM, True), +] + + +@pytest.mark.parametrize("datetime_obj, early, late, eq, new_obj", ADJUST) +def test_adjust_day( + datetime_obj: datetime, + early: time, + late: time, + eq: bool, + new_obj: datetime, +) -> None: + assert meds.adjust_day(datetime_obj, early, late, eq) == new_obj + + +@pytest.mark.parametrize("form, form_obj ,form_dict", FORM_TRANS) +def test_trans_form( + form: Dict[str, str], + form_obj: meds.Form, + form_dict: Dict[str, str], +) -> None: + translated_form_obj, translated_form_dict = meds.trans_form(form) + assert translated_form_obj == form_obj + assert translated_form_dict == form_dict + + +@pytest.mark.parametrize("time_obj, minutes", TIMES) +def test_convert_time_to_minutes(time_obj: time, minutes: int) -> None: + assert meds.convert_time_to_minutes(time_obj) == minutes + + +@pytest.mark.parametrize("early, late, interval", INTERVAL_MINUTE) +def test_get_interval_in_minutes( + early: time, + late: time, + interval: int, +) -> None: + assert meds.get_interval_in_minutes(early, late) == interval + + +@pytest.mark.parametrize("amount, minimum, early, late, boolean", AMOUNTS) +def test_validate_amount( + amount: int, + minimum: time, + early: time, + late: time, + boolean: bool, +) -> None: + assert meds.validate_amount(amount, minimum, early, late) == boolean + + +@pytest.mark.parametrize("form, boolean", EVENTS) +def test_validate_events(form: meds.Form, boolean: bool) -> None: + datetimes = meds.get_reminder_datetimes(form) + assert meds.validate_events(datetimes) is boolean + + +@pytest.mark.parametrize("form, booleans", FORM_VALIDATE) +def test_validate_form(form: meds.Form, booleans: List[bool]) -> None: + errors = meds.validate_form(form) + for i, error in enumerate(meds.ERRORS.values()): + message = error in errors + print(i, error, message) + assert message is booleans[i] + + +@pytest.mark.parametrize("form, interval", CALC_INTERVAL) +def test_calc_reminder_interval_in_seconds( + form: meds.Form, + interval: int, +) -> None: + assert meds.calc_reminder_interval_in_seconds(form) == interval + + +@pytest.mark.parametrize("form, times", REMINDER_TIMES) +def test_get_reminder_times(form: meds.Form, times: List[time]) -> None: + assert meds.get_reminder_times(form) == times + + +@pytest.mark.parametrize("t, day, early, late, boolean", DATETIMES_VALIDATE) +def test_validate_datetime( + t: datetime, + day: date, + early: time, + late: time, + boolean: bool, +) -> None: + assert meds.validate_datetime(t, day, early, late) == boolean + + +@pytest.mark.parametrize( + "previous, reminder_time, minimum, maximum, boolean", + VALIDATE_FIRST, +) +def test_validate_first_day_reminder( + previous: datetime, + reminder_time: time, + minimum: time, + maximum: time, + boolean: bool, +) -> None: + assert ( + meds.validate_first_day_reminder( + previous, + reminder_time, + minimum, + maximum, + ) + == boolean + ) + + +@pytest.mark.parametrize("previous, minimum, early, late, reminder", DIFFERENT) +def test_get_different_time_reminder( + previous: datetime, + minimum: time, + early: time, + late: time, + reminder: datetime, +) -> None: + new = meds.get_different_time_reminder(previous, minimum, early, late) + assert new == reminder + + +@pytest.mark.parametrize("form, time_obj, previous, reminder", CREATE_FIRST) +def test_create_first_day_reminder( + form: meds.Form, + time_obj: time, + previous: datetime, + reminder: datetime, +) -> None: + new = meds.create_first_day_reminder(form, time_obj, previous) + assert new == reminder + + +@pytest.mark.parametrize("form, times, datetimes", FIRST) +def test_get_first_day_reminders( + form: meds.Form, + times: List[time], + datetimes: List[datetime], +) -> None: + assert list(meds.get_first_day_reminders(form, times)) == datetimes + + +@pytest.mark.parametrize("times, early, start, day, end, reminders", REMINDERS) +def test_reminder_generator( + times: List[time], + early: time, + start: datetime, + day: date, + end: datetime, + reminders: List[datetime], +) -> None: + new = list(meds.reminder_generator(times, early, start, day, end)) + assert new == reminders + + +@pytest.mark.parametrize("form, datetimes", DATETIMES) +def test_get_reminder_datetimes( + form: meds.Form, + datetimes: List[datetime], +) -> None: + assert list(meds.get_reminder_datetimes(form)) == datetimes + + +@pytest.mark.parametrize("form, boolean", CREATE) +def test_create_events( + session: Session, + user: User, + form: meds.Form, + boolean: bool, +) -> None: + assert session.query(Event).first() is None + meds.create_events(session, user.id, form) + event = session.query(Event).first() + assert event + title = "-" in event.title + assert title is boolean diff --git a/tests/meds/test_routers.py b/tests/meds/test_routers.py new file mode 100644 index 00000000..81773c65 --- /dev/null +++ b/tests/meds/test_routers.py @@ -0,0 +1,42 @@ +from typing import Dict + +import pytest +from sqlalchemy.orm.session import Session +from starlette.testclient import TestClient + +from app.database.models import Event +from app.routers import meds +from tests.meds.test_internal import WEB_FORM, create_test_form + +PYLENDAR = [ + (WEB_FORM, True), + (create_test_form(form_dict=True, end="1985-10-26"), False), +] + + +def test_meds_page_returns_ok(meds_test_client: TestClient) -> None: + path = meds.router.url_path_for("medications") + response = meds_test_client.get(path) + assert response.ok + + +@pytest.mark.parametrize("form, pylendar", PYLENDAR) +def test_meds_send_form_success( + meds_test_client: TestClient, + session: Session, + form: Dict[str, str], + pylendar: bool, +) -> None: + assert session.query(Event).first() is None + path = meds.router.url_path_for("medications") + response = meds_test_client.post(path, data=form, allow_redirects=True) + assert response.ok + message = "PyLendar" in response.text + assert message is pylendar + message = "alert-danger" in response.text + assert message is not pylendar + event = session.query(Event).first() + if pylendar: + assert event + else: + assert event is None diff --git a/tests/salary/conftest.py b/tests/salary/conftest.py index 0cffee6d..51c77288 100644 --- a/tests/salary/conftest.py +++ b/tests/salary/conftest.py @@ -7,34 +7,33 @@ from app.internal.utils import create_model, delete_instance from app.routers.salary import config from app.routers.salary.routes import router -from tests.conftest import get_test_db -from tests.conftest import test_engine +from tests.conftest import get_test_db, test_engine MESSAGES = { - 'create_settings': 'Already created your settings?', - 'pick_settings': 'Edit Settings', - 'edit_settings': 'Settings don\'t need editing?', - 'pick_category': 'View Salary', - 'view_salary': 'Need to alter your settings?', - 'salary_calc': 'Total Salary:', + "create_settings": "Already created your settings?", + "pick_settings": "Edit Settings", + "edit_settings": "Settings don't need editing?", + "pick_category": "View Salary", + "view_salary": "Need to alter your settings?", + "salary_calc": "Total Salary:", } ROUTES = { - 'home': router.url_path_for('salary_home'), - 'new': router.url_path_for('create_settings'), - 'edit_pick': router.url_path_for('pick_settings'), - 'edit': lambda x: router.url_path_for('edit_settings', category_id=x), - 'view_pick': router.url_path_for('pick_category'), - 'view': lambda x: router.url_path_for('view_salary', category_id=x), + "home": router.url_path_for("salary_home"), + "new": router.url_path_for("create_settings"), + "edit_pick": router.url_path_for("pick_settings"), + "edit": lambda x: router.url_path_for("edit_settings", category_id=x), + "view_pick": router.url_path_for("pick_category"), + "view": lambda x: router.url_path_for("view_salary", category_id=x), } CATEGORY_ID = 1 INVALID_CATEGORY_ID = 2 ALT_CATEGORY_ID = 42 -MONTH = '2021-01' +MONTH = "2021-01" -@pytest.fixture(scope='package') +@pytest.fixture(scope="package") def salary_session() -> Iterator[Session]: Base.metadata.create_all(bind=test_engine) session = get_test_db() @@ -46,18 +45,21 @@ def salary_session() -> Iterator[Session]: @pytest.fixture def salary_user(salary_session: Session): test_user = create_model( - salary_session, User, - username='test_username', - password='test_password', - email='test.email@gmail.com', + salary_session, + User, + username="test_username", + password="test_password", + email="test.email@gmail.com", ) yield test_user delete_instance(salary_session, test_user) @pytest.fixture -def wage(salary_session: Session, - salary_user: User) -> Iterator[SalarySettings]: +def wage( + salary_session: Session, + salary_user: User, +) -> Iterator[SalarySettings]: wage = create_model( salary_session, SalarySettings, diff --git a/tests/salary/test_routes.py b/tests/salary/test_routes.py index 13e22e05..c788071a 100644 --- a/tests/salary/test_routes.py +++ b/tests/salary/test_routes.py @@ -1,7 +1,7 @@ from unittest import mock -from fastapi import status import pytest +from fastapi import status from requests.sessions import Session from starlette.testclient import TestClient @@ -12,32 +12,40 @@ from tests.salary.test_utils import get_event_by_category PATHS = [ - (conftest.ROUTES['new']), - (conftest.ROUTES['edit_pick']), - (conftest.ROUTES['edit'](conftest.CATEGORY_ID)), - (conftest.ROUTES['view_pick']), - (conftest.ROUTES['view'](conftest.CATEGORY_ID)), + (conftest.ROUTES["new"]), + (conftest.ROUTES["edit_pick"]), + (conftest.ROUTES["edit"](conftest.CATEGORY_ID)), + (conftest.ROUTES["view_pick"]), + (conftest.ROUTES["view"](conftest.CATEGORY_ID)), ] EMPTY_PICKS = [ - (conftest.ROUTES['edit_pick']), - (conftest.ROUTES['view_pick']), + (conftest.ROUTES["edit_pick"]), + (conftest.ROUTES["view_pick"]), ] CATEGORY_PICK = [ - (conftest.ROUTES['edit_pick'], conftest.MESSAGES['edit_settings']), - (conftest.ROUTES['view_pick'], conftest.MESSAGES['view_salary']), + (conftest.ROUTES["edit_pick"], conftest.MESSAGES["edit_settings"]), + (conftest.ROUTES["view_pick"], conftest.MESSAGES["view_salary"]), ] INVALID = [ - (conftest.ROUTES['edit'](conftest.ALT_CATEGORY_ID), - conftest.MESSAGES['pick_settings']), - (conftest.ROUTES['view'](conftest.ALT_CATEGORY_ID), - conftest.MESSAGES['pick_category']), - (conftest.ROUTES['edit'](conftest.INVALID_CATEGORY_ID), - conftest.MESSAGES['pick_settings']), - (conftest.ROUTES['view'](conftest.INVALID_CATEGORY_ID), - conftest.MESSAGES['pick_category']), + ( + conftest.ROUTES["edit"](conftest.ALT_CATEGORY_ID), + conftest.MESSAGES["pick_settings"], + ), + ( + conftest.ROUTES["view"](conftest.ALT_CATEGORY_ID), + conftest.MESSAGES["pick_category"], + ), + ( + conftest.ROUTES["edit"](conftest.INVALID_CATEGORY_ID), + conftest.MESSAGES["pick_settings"], + ), + ( + conftest.ROUTES["view"](conftest.INVALID_CATEGORY_ID), + conftest.MESSAGES["pick_category"], + ), ] @@ -49,10 +57,10 @@ def get_current_user(salary_session: Session) -> User: def test_get_user_categories() -> None: # Code revision required after categories feature is added categories = { - 1: 'Workout', - 17: 'Flight', - 42: 'Going to the Movies', - 666: 'Lucy\'s Inferno', + 1: "Workout", + 17: "Flight", + 42: "Going to the Movies", + 666: "Lucy's Inferno", } assert routes.get_user_categories() == categories @@ -60,162 +68,201 @@ def test_get_user_categories() -> None: def test_get_holiday_categories() -> None: # Code revision required after holiday times feature is added holidays = { - 1: 'Israel - Jewish', - 3: 'Iraq - Muslim', - 17: 'Cuba - Santeria', - 666: 'Hell - Satanist', + 1: "Israel - Jewish", + 3: "Iraq - Muslim", + 17: "Cuba - Santeria", + 666: "Hell - Satanist", } assert routes.get_holiday_categories() == holidays -def test_get_salary_categories_empty(salary_session: Session, - salary_user: User) -> None: +def test_get_salary_categories_empty( + salary_session: Session, + salary_user: User, +) -> None: # Code revision required after categories feature is added assert routes.get_salary_categories(salary_session, salary_user.id) == {} -def test_get_salary_categories(salary_session: Session, - wage: SalarySettings) -> None: +def test_get_salary_categories( + salary_session: Session, + wage: SalarySettings, +) -> None: # Code revision required after categories feature is added - assert wage.category_id in routes.get_salary_categories(salary_session, - wage.user_id, True) + assert wage.category_id in routes.get_salary_categories( + salary_session, + wage.user_id, + True, + ) -def test_get_salary_categories_new(salary_session: Session, - wage: SalarySettings) -> None: +def test_get_salary_categories_new( + salary_session: Session, + wage: SalarySettings, +) -> None: # Code revision required after categories feature is added assert wage.category_id not in routes.get_salary_categories( - salary_session, wage.user_id, False) - - -@pytest.mark.parametrize('path', PATHS) -def test_pages_respond_ok(salary_test_client: TestClient, - wage: SalarySettings, path: str) -> None: + salary_session, + wage.user_id, + False, + ) + + +@pytest.mark.parametrize("path", PATHS) +def test_pages_respond_ok( + salary_test_client: TestClient, + wage: SalarySettings, + path: str, +) -> None: response = salary_test_client.get(path) assert response.ok -def test_home_page_redirects_to_new( - salary_test_client: TestClient) -> None: - response = salary_test_client.get(conftest.ROUTES['home']) +def test_home_page_redirects_to_new(salary_test_client: TestClient) -> None: + response = salary_test_client.get(conftest.ROUTES["home"]) assert response.ok - assert conftest.MESSAGES['create_settings'] in response.text + assert conftest.MESSAGES["create_settings"] in response.text -@mock.patch('app.routers.salary.routes.get_current_user', - new=get_current_user) -def test_home_page_redirects_to_view(salary_test_client: TestClient, - wage: SalarySettings) -> None: - response = salary_test_client.get(conftest.ROUTES['home']) +@mock.patch("app.routers.salary.routes.get_current_user", new=get_current_user) +def test_home_page_redirects_to_view( + salary_test_client: TestClient, + wage: SalarySettings, +) -> None: + response = salary_test_client.get(conftest.ROUTES["home"]) assert response.ok - assert conftest.MESSAGES['pick_category'] in response.text + assert conftest.MESSAGES["pick_category"] in response.text -@mock.patch('app.routers.salary.routes.get_current_user', - new=get_current_user) -def test_create_settings(salary_test_client: TestClient, - salary_session: Session, salary_user: User) -> None: +@mock.patch("app.routers.salary.routes.get_current_user", new=get_current_user) +def test_create_settings( + salary_test_client: TestClient, + salary_session: Session, + salary_user: User, +) -> None: category_id = conftest.CATEGORY_ID - assert utils.get_settings(salary_session, salary_user.id, - category_id) is None + assert ( + utils.get_settings(salary_session, salary_user.id, category_id) is None + ) data = { - 'category_id': category_id, - 'wage': utils.DEFAULT_SETTINGS.wage, - 'off_day': utils.DEFAULT_SETTINGS.off_day, - 'holiday_category_id': utils.DEFAULT_SETTINGS.holiday_category_id, - 'regular_hour_basis': utils.DEFAULT_SETTINGS.regular_hour_basis, - 'night_hour_basis': utils.DEFAULT_SETTINGS.night_hour_basis, - 'night_start': utils.DEFAULT_SETTINGS.night_start, - 'night_end': utils.DEFAULT_SETTINGS.night_end, - 'night_min_len': utils.DEFAULT_SETTINGS.night_min_len, - 'first_overtime_amount': utils.DEFAULT_SETTINGS.first_overtime_amount, - 'first_overtime_pay': utils.DEFAULT_SETTINGS.first_overtime_pay, - 'second_overtime_pay': utils.DEFAULT_SETTINGS.second_overtime_pay, - 'week_working_hours': utils.DEFAULT_SETTINGS.week_working_hours, - 'daily_transport': utils.DEFAULT_SETTINGS.daily_transport, + "category_id": category_id, + "wage": utils.DEFAULT_SETTINGS.wage, + "off_day": utils.DEFAULT_SETTINGS.off_day, + "holiday_category_id": utils.DEFAULT_SETTINGS.holiday_category_id, + "regular_hour_basis": utils.DEFAULT_SETTINGS.regular_hour_basis, + "night_hour_basis": utils.DEFAULT_SETTINGS.night_hour_basis, + "night_start": utils.DEFAULT_SETTINGS.night_start, + "night_end": utils.DEFAULT_SETTINGS.night_end, + "night_min_len": utils.DEFAULT_SETTINGS.night_min_len, + "first_overtime_amount": utils.DEFAULT_SETTINGS.first_overtime_amount, + "first_overtime_pay": utils.DEFAULT_SETTINGS.first_overtime_pay, + "second_overtime_pay": utils.DEFAULT_SETTINGS.second_overtime_pay, + "week_working_hours": utils.DEFAULT_SETTINGS.week_working_hours, + "daily_transport": utils.DEFAULT_SETTINGS.daily_transport, } response = salary_test_client.post( - conftest.ROUTES['new'], data=data, allow_redirects=True) + conftest.ROUTES["new"], + data=data, + allow_redirects=True, + ) assert response.ok - assert conftest.MESSAGES['view_salary'] in response.text + assert conftest.MESSAGES["view_salary"] in response.text settings = utils.get_settings(salary_session, salary_user.id, category_id) assert settings delete_instance(salary_session, settings) -@pytest.mark.parametrize('path', EMPTY_PICKS) -def test_empty_category_pick_redirects_to_new(salary_test_client: TestClient, - path: str) -> None: +@pytest.mark.parametrize("path", EMPTY_PICKS) +def test_empty_category_pick_redirects_to_new( + salary_test_client: TestClient, + path: str, +) -> None: response = salary_test_client.get(path) - assert any(temp.status_code == status.HTTP_307_TEMPORARY_REDIRECT - for temp in response.history) - - -@pytest.mark.parametrize('path, message', CATEGORY_PICK) -@mock.patch('app.routers.salary.routes.get_current_user', - new=get_current_user) -def test_pick_category(salary_test_client: TestClient, wage: SalarySettings, - path: str, message: str) -> None: - data = {'category_id': wage.category_id} + assert any( + temp.status_code == status.HTTP_307_TEMPORARY_REDIRECT + for temp in response.history + ) + + +@pytest.mark.parametrize("path, message", CATEGORY_PICK) +@mock.patch("app.routers.salary.routes.get_current_user", new=get_current_user) +def test_pick_category( + salary_test_client: TestClient, + wage: SalarySettings, + path: str, + message: str, +) -> None: + data = {"category_id": wage.category_id} response = salary_test_client.post(path, data=data, allow_redirects=True) assert message in response.text -@mock.patch('app.routers.salary.routes.get_current_user', - new=get_current_user) -def test_edit_settings(salary_test_client: TestClient, salary_session: Session, - wage: SalarySettings) -> None: +@mock.patch("app.routers.salary.routes.get_current_user", new=get_current_user) +def test_edit_settings( + salary_test_client: TestClient, + salary_session: Session, + wage: SalarySettings, +) -> None: category_id = wage.category_id settings = utils.get_settings(salary_session, wage.user_id, category_id) - route = conftest.ROUTES['edit'](category_id) + route = conftest.ROUTES["edit"](category_id) data = { - 'wage': wage.wage + 1, - 'off_day': wage.off_day, - 'holiday_category_id': wage.holiday_category_id, - 'regular_hour_basis': wage.regular_hour_basis, - 'night_hour_basis': wage.night_hour_basis, - 'night_start': wage.night_start, - 'night_end': wage.night_end, - 'night_min_len': wage.night_min_len, - 'first_overtime_amount': wage.first_overtime_amount, - 'first_overtime_pay': wage.first_overtime_pay, - 'second_overtime_pay': wage.second_overtime_pay, - 'week_working_hours': wage.week_working_hours, - 'daily_transport': wage.daily_transport, + "wage": wage.wage + 1, + "off_day": wage.off_day, + "holiday_category_id": wage.holiday_category_id, + "regular_hour_basis": wage.regular_hour_basis, + "night_hour_basis": wage.night_hour_basis, + "night_start": wage.night_start, + "night_end": wage.night_end, + "night_min_len": wage.night_min_len, + "first_overtime_amount": wage.first_overtime_amount, + "first_overtime_pay": wage.first_overtime_pay, + "second_overtime_pay": wage.second_overtime_pay, + "week_working_hours": wage.week_working_hours, + "daily_transport": wage.daily_transport, } response = salary_test_client.post(route, data=data, allow_redirects=True) assert response.ok - assert conftest.MESSAGES['view_salary'] in response.text - assert settings != utils.get_settings(salary_session, wage.user_id, - wage.category_id) + assert conftest.MESSAGES["view_salary"] in response.text + assert settings != utils.get_settings( + salary_session, + wage.user_id, + wage.category_id, + ) -@pytest.mark.parametrize('path, message', INVALID) -@mock.patch('app.routers.salary.routes.get_current_user', - new=get_current_user) +@pytest.mark.parametrize("path, message", INVALID) +@mock.patch("app.routers.salary.routes.get_current_user", new=get_current_user) def test_invalid_category_redirect( - salary_test_client: TestClient, wage: SalarySettings, path: str, - message: str) -> None: + salary_test_client: TestClient, + wage: SalarySettings, + path: str, + message: str, +) -> None: response = salary_test_client.get(path) - assert any(temp.status_code == status.HTTP_307_TEMPORARY_REDIRECT - for temp in response.history) - print(response.text) + assert any( + temp.status_code == status.HTTP_307_TEMPORARY_REDIRECT + for temp in response.history + ) assert message in response.text -@mock.patch('app.routers.salary.routes.get_current_user', - new=get_current_user) -@mock.patch('app.routers.salary.utils.get_event_by_category', - new=get_event_by_category) -def test_view_salary(salary_test_client: TestClient, - wage: SalarySettings) -> None: - route = (conftest.ROUTES['view'](wage.category_id)) +@mock.patch("app.routers.salary.routes.get_current_user", new=get_current_user) +@mock.patch( + "app.routers.salary.utils.get_event_by_category", + new=get_event_by_category, +) +def test_view_salary( + salary_test_client: TestClient, + wage: SalarySettings, +) -> None: + route = conftest.ROUTES["view"](wage.category_id) data = { - 'month': conftest.MONTH, - 'bonus': 1000, - 'deduction': 1000, - 'overtime': True + "month": conftest.MONTH, + "bonus": 1000, + "deduction": 1000, + "overtime": True, } response = salary_test_client.post(route, data=data) assert response.ok - assert conftest.MESSAGES['salary_calc'] in response.text + assert conftest.MESSAGES["salary_calc"] in response.text diff --git a/tests/salary/test_utils.py b/tests/salary/test_utils.py index ed269d3f..e8b6298f 100644 --- a/tests/salary/test_utils.py +++ b/tests/salary/test_utils.py @@ -1,4 +1,4 @@ -from datetime import datetime, time, timedelta +from datetime import datetime, timedelta from typing import Dict, List, Tuple from unittest import mock @@ -9,12 +9,22 @@ from app.routers.salary import config, utils NIGHT_TIMES = [ - (datetime(2020, 1, 15), False, - (datetime.combine(datetime(2020, 1, 15), config.NIGHT_START), - datetime.combine(datetime(2020, 1, 16), config.NIGHT_END))), - (datetime(2020, 1, 15), True, - (datetime.combine(datetime(2020, 1, 14), config.NIGHT_START), - datetime.combine(datetime(2020, 1, 15), config.NIGHT_END))) + ( + datetime(2020, 1, 15), + False, + ( + datetime.combine(datetime(2020, 1, 15), config.NIGHT_START), + datetime.combine(datetime(2020, 1, 16), config.NIGHT_END), + ), + ), + ( + datetime(2020, 1, 15), + True, + ( + datetime.combine(datetime(2020, 1, 14), config.NIGHT_START), + datetime.combine(datetime(2020, 1, 15), config.NIGHT_END), + ), + ), ] NIGHT_SHIFTS = [ @@ -31,30 +41,55 @@ # Date changing (datetime(2020, 12, 1, 19, 15), datetime(2020, 12, 2, 1, 15), True), # Entire night - (datetime(2020, 12, 1, 19, 15), datetime(2020, 12, 2, 7, 15), True) + (datetime(2020, 12, 1, 19, 15), datetime(2020, 12, 2, 7, 15), True), ] HOLIDAY_TIMES = [ - (datetime(2020, 1, 3, 15), datetime(2020, 1, 4, 1), # Friday - Saturday - (datetime(2020, 1, 4), datetime(2020, 1, 5))), - (datetime(2020, 1, 4, 15), datetime(2020, 1, 5, 1), # Saturday - Sunday - (datetime(2020, 1, 4), datetime(2020, 1, 5))), - (datetime(2020, 1, 5, 15), datetime(2020, 1, 6, 1), # Sunday - Monday - (datetime.min, datetime.min)), + ( + datetime(2020, 1, 3, 15), + datetime(2020, 1, 4, 1), # Friday - Saturday + (datetime(2020, 1, 4), datetime(2020, 1, 5)), + ), + ( + datetime(2020, 1, 4, 15), + datetime(2020, 1, 5, 1), # Saturday - Sunday + (datetime(2020, 1, 4), datetime(2020, 1, 5)), + ), + ( + datetime(2020, 1, 5, 15), + datetime(2020, 1, 6, 1), # Sunday - Monday + (datetime.min, datetime.min), + ), ] SYNC_TIMES = [ - (datetime(2020, 1, 3, 15), datetime(2020, 1, 3, 22), - datetime(2020, 1, 3, 18, 30), datetime(2020, 1, 4, 2), 3.5), - (datetime(2020, 1, 3, 15), datetime(2020, 1, 3, 22), - datetime(2020, 1, 4, 15), datetime(2020, 1, 4, 22), 0.0), + ( + datetime(2020, 1, 3, 15), + datetime(2020, 1, 3, 22), + datetime(2020, 1, 3, 18, 30), + datetime(2020, 1, 4, 2), + 3.5, + ), + ( + datetime(2020, 1, 3, 15), + datetime(2020, 1, 3, 22), + datetime(2020, 1, 4, 15), + datetime(2020, 1, 4, 22), + 0.0, + ), ] HOUR_BASIS = [ - (datetime(2021, 1, 4, 9), datetime(2021, 1, 4, 19), # Regular shift - config.REGULAR_HOUR_BASIS), - (datetime(2021, 1, 4, 18), datetime(2021, 1, 5, 4), # Night shift - config.NIGHT_HOUR_BASIS), + ( + datetime(2021, 1, 4, 9), + datetime(2021, 1, 4, 19), # Regular shift + config.REGULAR_HOUR_BASIS, + ), + ( + datetime(2021, 1, 4, 18), + datetime(2021, 1, 5, 4), # Night shift + config.NIGHT_HOUR_BASIS, + ), ] OVERTIMES = [ @@ -88,7 +123,7 @@ # Off-day shift (datetime(2021, 1, 2, 9), datetime(2021, 1, 2, 19), (15.5, 2)), # Night off-day shift - (datetime(2021, 1, 2, 14), datetime(2021, 1, 3, 0), (16, 3)) + (datetime(2021, 1, 2, 14), datetime(2021, 1, 3, 0), (16, 3)), ] SHIFTS = [ @@ -103,30 +138,69 @@ ] WEEK_SHIFTS = [ - ((Event(start=datetime(2021, 1, 10, 9), - end=datetime(2021, 1, 10, 19)),), 0.0), - ((Event(start=datetime(2021, 1, 10, 9), - end=datetime(2021, 1, 10, 19)), - Event(start=datetime(2021, 1, 11, 9), - end=datetime(2021, 1, 11, 17)), - Event(start=datetime(2021, 1, 12, 9), - end=datetime(2021, 1, 12, 17)), - Event(start=datetime(2021, 1, 13, 9), - end=datetime(2021, 1, 13, 18)), - Event(start=datetime(2021, 1, 14, 9), - end=datetime(2021, 1, 14, 17))), 0.0), - ((Event(start=datetime(2021, 1, 10, 9), - end=datetime(2021, 1, 10, 19)), - Event(start=datetime(2021, 1, 11, 9), - end=datetime(2021, 1, 11, 17)), - Event(start=datetime(2021, 1, 12, 9), - end=datetime(2021, 1, 12, 17)), - Event(start=datetime(2021, 1, 13, 9), - end=datetime(2021, 1, 13, 18)), - Event(start=datetime(2021, 1, 14, 9), - end=datetime(2021, 1, 14, 17)), - Event(start=datetime(2021, 1, 15, 9), - end=datetime(2021, 1, 15, 14, 58))), 119.0), + ( + ( + Event( + start=datetime(2021, 1, 10, 9), + end=datetime(2021, 1, 10, 19), + ), + ), + 0.0, + ), + ( + ( + Event( + start=datetime(2021, 1, 10, 9), + end=datetime(2021, 1, 10, 19), + ), + Event( + start=datetime(2021, 1, 11, 9), + end=datetime(2021, 1, 11, 17), + ), + Event( + start=datetime(2021, 1, 12, 9), + end=datetime(2021, 1, 12, 17), + ), + Event( + start=datetime(2021, 1, 13, 9), + end=datetime(2021, 1, 13, 18), + ), + Event( + start=datetime(2021, 1, 14, 9), + end=datetime(2021, 1, 14, 17), + ), + ), + 0.0, + ), + ( + ( + Event( + start=datetime(2021, 1, 10, 9), + end=datetime(2021, 1, 10, 19), + ), + Event( + start=datetime(2021, 1, 11, 9), + end=datetime(2021, 1, 11, 17), + ), + Event( + start=datetime(2021, 1, 12, 9), + end=datetime(2021, 1, 12, 17), + ), + Event( + start=datetime(2021, 1, 13, 9), + end=datetime(2021, 1, 13, 18), + ), + Event( + start=datetime(2021, 1, 14, 9), + end=datetime(2021, 1, 14, 17), + ), + Event( + start=datetime(2021, 1, 15, 9), + end=datetime(2021, 1, 15, 14, 58), + ), + ), + 119.0, + ), ] MONTHS = [ @@ -134,10 +208,7 @@ (2020, 12, (datetime(2020, 12, 1), datetime(2021, 1, 1))), ] -MONTH_SHIFTS = [ - (False, 0.0), - (True, 720.0) -] +MONTH_SHIFTS = [(False, 0.0), (True, 720.0)] TRANSPORT = [ (6, 11.8, 70.8), @@ -146,64 +217,76 @@ ] SALARIES = [ - (False, 0, { - 'year': 2021, - 'month': 1, - 'num_of_shifts': 20, - 'base_salary': 4800.0, - 'month_weekly_overtime': 0, - 'transport': 236, - 'bonus': 0, - 'deduction': 0, - 'salary': 5036.0, - }), - (True, 10000, { - 'year': 2021, - 'month': 1, - 'num_of_shifts': 20, - 'base_salary': 4800.0, - 'month_weekly_overtime': 0, - 'transport': 236, - 'bonus': 0, - 'deduction': 5036.0, - 'salary': 0.0, - }), -] - -TIMES = [ - ('13:30', time(13, 30)), - ('15:42:00', time(15, 42)) + ( + False, + 0, + { + "year": 2021, + "month": 1, + "num_of_shifts": 20, + "base_salary": 4800.0, + "month_weekly_overtime": 0, + "transport": 236, + "bonus": 0, + "deduction": 0, + "salary": 5036.0, + }, + ), + ( + True, + 10000, + { + "year": 2021, + "month": 1, + "num_of_shifts": 20, + "base_salary": 4800.0, + "month_weekly_overtime": 0, + "transport": 236, + "bonus": 0, + "deduction": 5036.0, + "salary": 0.0, + }, + ), ] UPDATES = [ - ({ - 'wage': '35', - 'off_day': '6', - 'holiday_category_id': '7', - 'regular_hour_basis': '19', - 'night_hour_basis': '6.5', - 'night_start': '13:00', - 'night_end': '14:30:00', - 'night_min_len': '20:42', - 'first_overtime_amount': '4', - 'first_overtime_pay': '1', - 'second_overtime_pay': '2', - 'week_working_hours': '80', - 'daily_transport': '20', - }, True), - ({}, False) + ( + { + "wage": "35", + "off_day": "6", + "holiday_category_id": "7", + "regular_hour_basis": "19", + "night_hour_basis": "6.5", + "night_start": "13:00", + "night_end": "14:30:00", + "night_min_len": "20:42", + "first_overtime_amount": "4", + "first_overtime_pay": "1", + "second_overtime_pay": "2", + "week_working_hours": "80", + "daily_transport": "20", + }, + True, + ), + ({}, False), ] -def create_month_shifts(start: datetime, end: datetime, - add_sixth_day: bool = False) -> List[Event]: +def create_month_shifts( + start: datetime, + end: datetime, + add_sixth_day: bool = False, +) -> List[Event]: shifts = [] for i in range(4): for j in range(6): if j < 5 or add_sixth_day: - shifts.append(Event( - start=start + timedelta(i) * 7 + timedelta(j), - end=end + timedelta(i) * 7 + timedelta(j))) + shifts.append( + Event( + start=start + timedelta(i) * 7 + timedelta(j), + end=end + timedelta(i) * 7 + timedelta(j), + ), + ) return shifts @@ -222,96 +305,135 @@ def test_get_shift_len() -> None: assert utils.get_shift_len(start, end) == 1.2 -@pytest.mark.parametrize('date, prev_day, night_times', NIGHT_TIMES) -def test_get_night_times(wage: SalarySettings, date: datetime, prev_day: bool, - night_times: Tuple[datetime, datetime]) -> None: +@pytest.mark.parametrize("date, prev_day, night_times", NIGHT_TIMES) +def test_get_night_times( + wage: SalarySettings, + date: datetime, + prev_day: bool, + night_times: Tuple[datetime, datetime], +) -> None: assert utils.get_night_times(date, wage, prev_day) == night_times -@pytest.mark.parametrize('start, end, boolean', NIGHT_SHIFTS) -def test_is_night_shift(wage: SalarySettings, start: datetime, end: datetime, - boolean: bool) -> None: +@pytest.mark.parametrize("start, end, boolean", NIGHT_SHIFTS) +def test_is_night_shift( + wage: SalarySettings, + start: datetime, + end: datetime, + boolean: bool, +) -> None: assert utils.is_night_shift(start, end, wage) == boolean -@pytest.mark.parametrize('start, end, dates', - HOLIDAY_TIMES) +@pytest.mark.parametrize("start, end, dates", HOLIDAY_TIMES) def test_get_relevant_holiday_times( - wage: SalarySettings, start: datetime, end: datetime, - dates: Tuple[datetime, datetime]) -> None: + wage: SalarySettings, + start: datetime, + end: datetime, + dates: Tuple[datetime, datetime], +) -> None: # Code revision required after holiday times feature is added # Code revision required after Shabbat times feature is added - assert utils.get_relevant_holiday_times( - start, end, wage) == dates + assert utils.get_relevant_holiday_times(start, end, wage) == dates @pytest.mark.parametrize( - 'event_1_start, event_1_end, event_2_start, event_2_end, total', - SYNC_TIMES) -def test_get_total_synchronous_hours(event_1_start: datetime, - event_1_end: datetime, - event_2_start: datetime, - event_2_end: datetime, - total: float) -> None: - assert utils.get_total_synchronous_hours( - event_1_start, event_1_end, event_2_start, event_2_end) == total - - -@pytest.mark.parametrize('start, end, basis', HOUR_BASIS) -def test_get_hour_basis(wage: SalarySettings, start: datetime, - end: datetime, basis: float) -> None: + "event_1_start, event_1_end, event_2_start, event_2_end, total", + SYNC_TIMES, +) +def test_get_total_synchronous_hours( + event_1_start: datetime, + event_1_end: datetime, + event_2_start: datetime, + event_2_end: datetime, + total: float, +) -> None: + assert ( + utils.get_total_synchronous_hours( + event_1_start, + event_1_end, + event_2_start, + event_2_end, + ) + == total + ) + + +@pytest.mark.parametrize("start, end, basis", HOUR_BASIS) +def test_get_hour_basis( + wage: SalarySettings, + start: datetime, + end: datetime, + basis: float, +) -> None: assert utils.get_hour_basis(start, end, wage) == basis -@pytest.mark.parametrize('start, end, overtimes', OVERTIMES) +@pytest.mark.parametrize("start, end, overtimes", OVERTIMES) def test_calc_overtime_hours( - wage: SalarySettings, start: datetime, end: datetime, - overtimes: Tuple[float, float]) -> None: + wage: SalarySettings, + start: datetime, + end: datetime, + overtimes: Tuple[float, float], +) -> None: assert utils.calc_overtime_hours(start, end, wage) == overtimes -@pytest.mark.parametrize('shift_start, shift_end, total', HOLIDAY_HOURS) -def test_get_hours_during_holiday(wage: SalarySettings, shift_start: datetime, - shift_end: datetime, total: float) -> None: +@pytest.mark.parametrize("shift_start, shift_end, total", HOLIDAY_HOURS) +def test_get_hours_during_holiday( + wage: SalarySettings, + shift_start: datetime, + shift_end: datetime, + total: float, +) -> None: # Code revision required after holiday times feature is added # Code revision required after Shabbat times feature is added - assert utils.get_hours_during_holiday( - shift_start, shift_end, wage) == total + assert ( + utils.get_hours_during_holiday(shift_start, shift_end, wage) == total + ) -@pytest.mark.parametrize('start, end, overtimes', HOLIDAY_OVERTIMES) -def test_adjust_overtime(wage: SalarySettings, start: datetime, end: datetime, - overtimes: Tuple[float, float]) -> None: +@pytest.mark.parametrize("start, end, overtimes", HOLIDAY_OVERTIMES) +def test_adjust_overtime( + wage: SalarySettings, + start: datetime, + end: datetime, + overtimes: Tuple[float, float], +) -> None: assert utils.adjust_overtime(start, end, wage) == overtimes -@pytest.mark.parametrize('start, end, salary', SHIFTS) -def test_calc_shift_salary(wage: SalarySettings, start: datetime, - end: datetime, salary: float) -> None: +@pytest.mark.parametrize("start, end, salary", SHIFTS) +def test_calc_shift_salary( + wage: SalarySettings, + start: datetime, + end: datetime, + salary: float, +) -> None: assert utils.calc_shift_salary(start, end, wage) == salary -@pytest.mark.parametrize('shifts, overtime', WEEK_SHIFTS) -def test_calc_weekly_overtime(wage: SalarySettings, shifts: Tuple[Event, ...], - overtime: float) -> None: +@pytest.mark.parametrize("shifts, overtime", WEEK_SHIFTS) +def test_calc_weekly_overtime( + wage: SalarySettings, + shifts: Tuple[Event, ...], + overtime: float, +) -> None: assert utils.calc_weekly_overtime(shifts, wage) == overtime def test_get_event_by_category() -> None: # Code revision required after categories feature is added shifts = ( - Event(start=datetime(2021, 1, 10, 9), - end=datetime(2021, 1, 10, 19)), - Event(start=datetime(2021, 1, 11, 9), - end=datetime(2021, 1, 11, 17)), - Event(start=datetime(2021, 1, 12, 9), - end=datetime(2021, 1, 12, 17)), - Event(start=datetime(2021, 1, 13, 9), - end=datetime(2021, 1, 13, 18)), - Event(start=datetime(2021, 1, 14, 9), - end=datetime(2021, 1, 14, 17)), - Event(start=datetime(2021, 1, 15, 9), - end=datetime(2021, 1, 15, 14, 58)), + Event(start=datetime(2021, 1, 10, 9), end=datetime(2021, 1, 10, 19)), + Event(start=datetime(2021, 1, 11, 9), end=datetime(2021, 1, 11, 17)), + Event(start=datetime(2021, 1, 12, 9), end=datetime(2021, 1, 12, 17)), + Event(start=datetime(2021, 1, 13, 9), end=datetime(2021, 1, 13, 18)), + Event(start=datetime(2021, 1, 14, 9), end=datetime(2021, 1, 14, 17)), + Event( + start=datetime(2021, 1, 15, 9), + end=datetime(2021, 1, 15, 14, 58), + ), ) events = utils.get_event_by_category() assert len(events) == len(shifts) @@ -319,9 +441,12 @@ def test_get_event_by_category() -> None: assert event.start == shifts[i].start and event.end == shifts[i].end -@pytest.mark.parametrize('year, month, month_times', MONTHS) -def test_get_month_end(year: int, month: int, - month_times: Tuple[datetime, datetime]) -> None: +@pytest.mark.parametrize("year, month, month_times", MONTHS) +def test_get_month_end( + year: int, + month: int, + month_times: Tuple[datetime, datetime], +) -> None: assert utils.get_month_times(year, month) == month_times @@ -338,9 +463,12 @@ def test_get_relevant_weeks() -> None: assert week == next(relevant_weeks) -@pytest.mark.parametrize('add_sixth_day, total', MONTH_SHIFTS) -def test_get_monthly_overtime(wage: SalarySettings, add_sixth_day: bool, - total: float) -> None: +@pytest.mark.parametrize("add_sixth_day, total", MONTH_SHIFTS) +def test_get_monthly_overtime( + wage: SalarySettings, + add_sixth_day: bool, + total: float, +) -> None: start = datetime(2021, 1, 3, 9) end = datetime(2021, 1, 3, 17) shifts = create_month_shifts(start, end, add_sixth_day) @@ -348,34 +476,40 @@ def test_get_monthly_overtime(wage: SalarySettings, add_sixth_day: bool, assert utils.get_monthly_overtime(shifts, weeks, wage) == total -@pytest.mark.parametrize('amount, daily_transport, total', TRANSPORT) -def test_calc_transport(amount: int, daily_transport: float, - total: float) -> None: +@pytest.mark.parametrize("amount, daily_transport, total", TRANSPORT) +def test_calc_transport( + amount: int, + daily_transport: float, + total: float, +) -> None: assert utils.calc_transport(amount, daily_transport) == total -@pytest.mark.parametrize('overtime, deduction, salary', SALARIES) -@mock.patch('app.routers.salary.utils.get_event_by_category', - side_effect=get_event_by_category) +@pytest.mark.parametrize("overtime, deduction, salary", SALARIES) +@mock.patch( + "app.routers.salary.utils.get_event_by_category", + side_effect=get_event_by_category, +) def test_calc_salary( - mocked_func, wage: SalarySettings, overtime: bool, - deduction: config.NUMERIC, salary: Dict[str, config.NUMERIC]) -> None: + mocked_func, + wage: SalarySettings, + overtime: bool, + deduction: config.NUMERIC, + salary: Dict[str, config.NUMERIC], +) -> None: # Code revision required after categories feature is added assert utils.calc_salary(2021, 1, wage, overtime, 0, deduction) == salary -def test_get_settings(salary_session: Session, - wage: SalarySettings) -> None: - assert utils.get_settings(salary_session, wage.user_id, - wage.category_id) - - -@pytest.mark.parametrize('string, formatted_time', TIMES) -def test_get_time_from_string(string: str, formatted_time: time) -> None: - assert utils.get_time_from_string(string) == formatted_time +def test_get_settings(salary_session: Session, wage: SalarySettings) -> None: + assert utils.get_settings(salary_session, wage.user_id, wage.category_id) -@pytest.mark.parametrize('form, boolean', UPDATES) -def test_update_settings(salary_session: Session, wage: SalarySettings, - form: Dict[str, str], boolean: bool) -> None: +@pytest.mark.parametrize("form, boolean", UPDATES) +def test_update_settings( + salary_session: Session, + wage: SalarySettings, + form: Dict[str, str], + boolean: bool, +) -> None: assert utils.update_settings(salary_session, wage, form) == boolean diff --git a/tests/security_testing_routes.py b/tests/security_testing_routes.py index 4df73e8b..36f95732 100644 --- a/tests/security_testing_routes.py +++ b/tests/security_testing_routes.py @@ -1,16 +1,17 @@ from fastapi import APIRouter, Depends, Request -from app.internal.security.dependancies import ( - current_user, current_user_from_db, - is_logged_in, is_manager, User +from app.internal.security.dependencies import ( + User, + current_user, + current_user_from_db, + is_logged_in, + is_manager, ) +# These routes are for security testing. +# They represent an example for how to use +# security dependencies in other routes. -""" -These routes are for security testing. -They represent an example for how to use -security dependencies in other routes. -""" router = APIRouter( prefix="", tags=["/security"], @@ -18,9 +19,8 @@ ) -@router.get('/is_logged_in') -async def is_logged_in( - request: Request, user: bool = Depends(is_logged_in)): +@router.get("/is_logged_in") +async def is_logged_in(request: Request, user: bool = Depends(is_logged_in)): """This is how to protect route for logged in user only. Dependency will return True. if user not looged-in, will be redirected to login route. @@ -28,9 +28,8 @@ async def is_logged_in( return {"user": user} -@router.get('/is_manager') -async def is_manager( - request: Request, user: bool = Depends(is_manager)): +@router.get("/is_manager") +async def is_manager(request: Request, user: bool = Depends(is_manager)): """This is how to protect route for logged in manager only. Dependency will return True. if user not looged-in, or have no manager permission, @@ -39,9 +38,11 @@ async def is_manager( return {"manager": user} -@router.get('/current_user_from_db') +@router.get("/current_user_from_db") async def current_user_from_db( - request: Request, user: User = Depends(current_user_from_db)): + request: Request, + user: User = Depends(current_user_from_db), +): """This is how to protect route for logged in user only. Dependency will return User object. if user not looged-in, will be redirected to login route. @@ -49,9 +50,8 @@ async def current_user_from_db( return {"user": user.username} -@router.get('/current_user') -async def current_user( - request: Request, user: User = Depends(current_user)): +@router.get("/current_user") +async def current_user(request: Request, user: User = Depends(current_user)): """This is how to protect route for logged in user only. Dependency will return schema.CurrentUser object, contains user_id and username. diff --git a/tests/test_a_telegram_asyncio.py b/tests/test_a_telegram_asyncio.py index faf99d98..e880ac1d 100644 --- a/tests/test_a_telegram_asyncio.py +++ b/tests/test_a_telegram_asyncio.py @@ -1,146 +1,150 @@ from datetime import datetime, timedelta -from fastapi import status import pytest +from fastapi import status from app.telegram.handlers import MessageHandler, reply_unknown_user from app.telegram.keyboards import DATE_FORMAT from app.telegram.models import Bot, Chat -from tests.asyncio_fixture import today_date -from tests.client_fixture import get_test_placeholder_user +from tests.fixtures.asyncio_fixture import today_date +from tests.fixtures.client_fixture import get_test_placeholder_user def gen_message(text): return { - 'update_id': 10000000, - 'message': { - 'message_id': 2434, - 'from': { - 'id': 666666, - 'is_bot': False, - 'first_name': 'Moshe', - 'username': 'banana', - 'language_code': 'en' + "update_id": 10000000, + "message": { + "message_id": 2434, + "from": { + "id": 666666, + "is_bot": False, + "first_name": "Moshe", + "username": "banana", + "language_code": "en", }, - 'chat': { - 'id': 666666, - 'first_name': 'Moshe', - 'username': 'banana', - 'type': 'private' + "chat": { + "id": 666666, + "first_name": "Moshe", + "username": "banana", + "type": "private", }, - 'date': 1611240725, - 'text': f'{text}' - } + "date": 1611240725, + "text": f"{text}", + }, } def gen_callback(text): return { - 'update_id': 568265, - 'callback_query': { - 'id': '546565356486', - 'from': { - 'id': 666666, - 'is_bot': False, - 'first_name': 'Moshe', - 'username': 'banana', - 'language_code': 'en' - }, 'message': { - 'message_id': 838, - 'from': { - 'id': 2566252, - 'is_bot': True, - 'first_name': 'PyLandar', - 'username': 'pylander_bot' - }, 'chat': { - 'id': 666666, - 'first_name': 'Moshe', - 'username': 'banana', - 'type': 'private' + "update_id": 568265, + "callback_query": { + "id": "546565356486", + "from": { + "id": 666666, + "is_bot": False, + "first_name": "Moshe", + "username": "banana", + "language_code": "en", + }, + "message": { + "message_id": 838, + "from": { + "id": 2566252, + "is_bot": True, + "first_name": "PyLandar", + "username": "pylander_bot", }, - 'date': 161156, - 'text': 'Choose events day.', - 'reply_markup': { - 'inline_keyboard': [ + "chat": { + "id": 666666, + "first_name": "Moshe", + "username": "banana", + "type": "private", + }, + "date": 161156, + "text": "Choose events day.", + "reply_markup": { + "inline_keyboard": [ [ + {"text": "Today", "callback_data": "Today"}, { - 'text': 'Today', - 'callback_data': 'Today' + "text": "This week", + "callback_data": "This week", }, - { - 'text': 'This week', - 'callback_data': 'This week' - } - ] - ] - } + ], + ], + }, }, - 'chat_instance': '-154494', - 'data': f'{text}'}} + "chat_instance": "-154494", + "data": f"{text}", + }, + } class TestChatModel: - @staticmethod def test_private_message(): - chat = Chat(gen_message('Cool message')) - assert chat.message == 'Cool message' + chat = Chat(gen_message("Cool message")) + assert chat.message == "Cool message" assert chat.user_id == 666666 - assert chat.first_name == 'Moshe' + assert chat.first_name == "Moshe" @staticmethod def test_callback_message(): - chat = Chat(gen_callback('Callback Message')) - assert chat.message == 'Callback Message' + chat = Chat(gen_callback("Callback Message")) + assert chat.message == "Callback Message" assert chat.user_id == 666666 - assert chat.first_name == 'Moshe' + assert chat.first_name == "Moshe" @pytest.mark.asyncio async def test_bot_model(): bot = Bot("fake bot id", "https://google.com") - assert bot.base == 'https://api.telegram.org/botfake bot id/' - assert bot.webhook_setter_url == 'https://api.telegram.org/botfake \ -bot id/setWebhook?url=https://google.com/telegram/' + assert bot.base == "https://api.telegram.org/botfake bot id/" + assert ( + bot.webhook_setter_url + == "https://api.telegram.org/botfake \ +bot id/setWebhook?url=https://google.com/telegram/" + ) assert bot.base == bot._set_base_url("fake bot id") assert bot.webhook_setter_url == bot._set_webhook_setter_url( - "https://google.com") + "https://google.com", + ) set_request = await bot.set_webhook() assert set_request.json() == { - 'ok': False, - 'error_code': 404, - 'description': 'Not Found' + "ok": False, + "error_code": 404, + "description": "Not Found", } drop_request = await bot.drop_webhook() assert drop_request.json() == { - 'ok': False, - 'error_code': 404, - 'description': 'Not Found' + "ok": False, + "error_code": 404, + "description": "Not Found", } send_request = await bot.send_message("654654645", "hello") assert send_request.status_code == status.HTTP_404_NOT_FOUND assert send_request.json() == { - 'ok': False, - 'error_code': 404, - 'description': 'Not Found' + "ok": False, + "error_code": 404, + "description": "Not Found", } class TestBotClient: - @staticmethod @pytest.mark.asyncio async def test_user_not_registered(telegram_client): response = await telegram_client.post( - '/telegram/', json=gen_message('/start')) + "/telegram/", + json=gen_message("/start"), + ) assert response.status_code == status.HTTP_200_OK - assert b'Hello, Moshe!' in response.content - assert b'To use PyLendar Bot you have to register' \ - in response.content + assert b"Hello, Moshe!" in response.content + assert b"To use PyLendar Bot you have to register" in response.content @staticmethod @pytest.mark.asyncio @@ -148,9 +152,11 @@ async def test_user_registered(telegram_client, session): session.add(get_test_placeholder_user()) session.commit() response = await telegram_client.post( - '/telegram/', json=gen_message('/start')) + "/telegram/", + json=gen_message("/start"), + ) assert response.status_code == status.HTTP_200_OK - assert b'Welcome to PyLendar telegram client!' in response.content + assert b"Welcome to PyLendar telegram client!" in response.content class TestHandlers: @@ -158,21 +164,27 @@ class TestHandlers: @pytest.mark.asyncio async def test_start_handlers(self): - chat = Chat(gen_message('/start')) + chat = Chat(gen_message("/start")) message = MessageHandler(chat, self.TEST_USER) - assert '/start' in message.handlers - assert await message.process_callback() == '''Hello, Moshe! -Welcome to PyLendar telegram client!''' + assert "/start" in message.handlers + assert ( + await message.process_callback() + == """Hello, Moshe! +Welcome to PyLendar telegram client!""" + ) @pytest.mark.asyncio async def test_default_handlers(self): wrong_start = MessageHandler( - Chat(gen_message('start')), self.TEST_USER) + Chat(gen_message("start")), + self.TEST_USER, + ) wrong_show_events = MessageHandler( - Chat(gen_message('show_events')), self.TEST_USER) - message = MessageHandler( - Chat(gen_message('hello')), self.TEST_USER) + Chat(gen_message("show_events")), + self.TEST_USER, + ) + message = MessageHandler(Chat(gen_message("hello")), self.TEST_USER) assert await wrong_start.process_callback() == "Unknown command." assert await wrong_show_events.process_callback() == "Unknown command." @@ -180,34 +192,34 @@ async def test_default_handlers(self): @pytest.mark.asyncio async def test_show_events_handler(self): - chat = Chat(gen_message('/show_events')) + chat = Chat(gen_message("/show_events")) message = MessageHandler(chat, self.TEST_USER) - assert await message.process_callback() == 'Choose events day.' + assert await message.process_callback() == "Choose events day." @pytest.mark.asyncio async def test_no_today_events_handler(self): - chat = Chat(gen_callback('Today')) + chat = Chat(gen_callback("Today")) message = MessageHandler(chat, self.TEST_USER) assert await message.process_callback() == "There're no events today." @pytest.mark.asyncio async def test_today_handler(self, fake_user_events): - chat = Chat(gen_callback('Today')) + chat = Chat(gen_callback("Today")) message = MessageHandler(chat, fake_user_events) answer = f"{today_date.strftime('%A, %B %d')}:\n" assert await message.process_callback() == answer @pytest.mark.asyncio async def test_this_week_handler(self): - chat = Chat(gen_callback('This week')) + chat = Chat(gen_callback("This week")) message = MessageHandler(chat, self.TEST_USER) - assert await message.process_callback() == 'Choose a day.' + assert await message.process_callback() == "Choose a day." @pytest.mark.asyncio async def test_no_chosen_day_handler(self): - chat = Chat(gen_callback('10 Feb 2021')) + chat = Chat(gen_callback("10 Feb 2021")) message = MessageHandler(chat, self.TEST_USER) - message.handlers['10 Feb 2021'] = message.chosen_day_handler + message.handlers["10 Feb 2021"] = message.chosen_day_handler answer = "There're no events on February 10." assert await message.process_callback() == answer @@ -223,99 +235,101 @@ async def test_chosen_day_handler(self, fake_user_events): @pytest.mark.asyncio async def test_new_event_handler(self): - chat = Chat(gen_message('/new_event')) + chat = Chat(gen_message("/new_event")) message = MessageHandler(chat, self.TEST_USER) - answer = 'Please, give your event a title.' + answer = "Please, give your event a title." assert await message.process_callback() == answer @pytest.mark.asyncio async def test_process_new_event(self): - chat = Chat(gen_message('New Title')) + chat = Chat(gen_message("New Title")) message = MessageHandler(chat, self.TEST_USER) - answer = 'Title:\nNew Title\n\n' - answer += 'Add a description of the event.' + answer = "Title:\nNew Title\n\n" + answer += "Add a description of the event." assert await message.process_callback() == answer - chat = Chat(gen_message('New Content')) + chat = Chat(gen_message("New Content")) message = MessageHandler(chat, self.TEST_USER) - answer = 'Content:\nNew Content\n\n' - answer += 'Where the event will be held?' + answer = "Content:\nNew Content\n\n" + answer += "Where the event will be held?" assert await message.process_callback() == answer - chat = Chat(gen_message('Universe')) + chat = Chat(gen_message("Universe")) message = MessageHandler(chat, self.TEST_USER) - answer = 'Location:\nUniverse\n\n' - answer += 'When does it start?' + answer = "Location:\nUniverse\n\n" + answer += "When does it start?" assert await message.process_callback() == answer - chat = Chat(gen_message('Not valid start datetime input')) + chat = Chat(gen_message("Not valid start datetime input")) message = MessageHandler(chat, self.TEST_USER) - answer = '❗️ Please, enter a valid date/time.' + answer = "❗️ Please, enter a valid date/time." assert await message.process_callback() == answer - chat = Chat(gen_message('today')) + chat = Chat(gen_message("today")) message = MessageHandler(chat, self.TEST_USER) today = datetime.today() answer = f'Starts on:\n{today.strftime("%d %b %Y %H:%M")}\n\n' - answer += 'And when does it end?' + answer += "And when does it end?" assert await message.process_callback() == answer - chat = Chat(gen_message('Not valid end datetime input')) + chat = Chat(gen_message("Not valid end datetime input")) message = MessageHandler(chat, self.TEST_USER) - answer = '❗️ Please, enter a valid date/time.' + answer = "❗️ Please, enter a valid date/time." assert await message.process_callback() == answer - chat = Chat(gen_message('tomorrow')) + chat = Chat(gen_message("tomorrow")) message = MessageHandler(chat, self.TEST_USER) tomorrow = today + timedelta(days=1) - answer = 'Title:\nNew Title\n\n' - answer += 'Content:\nNew Content\n\n' - answer += 'Location:\nUniverse\n\n' + answer = "Title:\nNew Title\n\n" + answer += "Content:\nNew Content\n\n" + answer += "Location:\nUniverse\n\n" answer += f'Starts on:\n{today.strftime("%d %b %Y %H:%M")}\n\n' answer += f'Ends on:\n{tomorrow.strftime("%d %b %Y %H:%M")}' assert await message.process_callback() == answer - chat = Chat(gen_message('create')) + chat = Chat(gen_message("create")) message = MessageHandler(chat, self.TEST_USER) - answer = 'New event was successfully created 🎉' + answer = "New event was successfully created 🎉" assert await message.process_callback() == answer @pytest.mark.asyncio async def test_process_new_event_cancel(self): - chat = Chat(gen_message('/new_event')) + chat = Chat(gen_message("/new_event")) message = MessageHandler(chat, self.TEST_USER) - answer = 'Please, give your event a title.' + answer = "Please, give your event a title." assert await message.process_callback() == answer - chat = Chat(gen_message('cancel')) + chat = Chat(gen_message("cancel")) message = MessageHandler(chat, self.TEST_USER) - answer = '🚫 The process was canceled.' + answer = "🚫 The process was canceled." assert await message.process_callback() == answer @pytest.mark.asyncio async def test_process_new_event_restart(self): - chat = Chat(gen_message('/new_event')) + chat = Chat(gen_message("/new_event")) message = MessageHandler(chat, self.TEST_USER) - answer = 'Please, give your event a title.' + answer = "Please, give your event a title." assert await message.process_callback() == answer - chat = Chat(gen_message('New Title')) + chat = Chat(gen_message("New Title")) message = MessageHandler(chat, self.TEST_USER) - answer = 'Title:\nNew Title\n\n' - answer += 'Add a description of the event.' + answer = "Title:\nNew Title\n\n" + answer += "Add a description of the event." assert await message.process_callback() == answer - chat = Chat(gen_message('restart')) + chat = Chat(gen_message("restart")) message = MessageHandler(chat, self.TEST_USER) - answer = 'Please, give your event a title.' + answer = "Please, give your event a title." assert await message.process_callback() == answer @pytest.mark.asyncio async def test_reply_unknown_user(): - chat = Chat(gen_message('/show_events')) + chat = Chat(gen_message("/show_events")) answer = await reply_unknown_user(chat) - assert answer == ''' + assert ( + answer + == """ Hello, Moshe! To use PyLendar Bot you have to register @@ -325,4 +339,5 @@ async def test_reply_unknown_user(): Keep it secret! https://calendar.pythonic.guru/profile/ -''' +""" + ) diff --git a/tests/test_astronomy.py b/tests/test_astronomy.py index 58924c58..78954f40 100644 --- a/tests/test_astronomy.py +++ b/tests/test_astronomy.py @@ -1,14 +1,13 @@ import datetime -from fastapi import status import httpx import pytest import requests import responses import respx +from fastapi import status -from app.internal.astronomy import ASTRONOMY_URL -from app.internal.astronomy import get_astronomical_data +from app.internal.astronomy import ASTRONOMY_URL, get_astronomical_data RESPONSE_FROM_MOCK = { "location": { @@ -29,14 +28,14 @@ "moonset": "03:04 AM", "moon_phase": "Waxing Gibbous", "moon_illumination": "79", - } - } + }, + }, } ERROR_RESPONSE_FROM_MOCK = { "error": { "message": "Error Text", - } + }, } @@ -45,7 +44,7 @@ async def test_get_astronomical_data(httpx_mock): requested_date = datetime.datetime(day=4, month=4, year=2020) httpx_mock.add_response(method="GET", json=RESPONSE_FROM_MOCK) output = await get_astronomical_data(requested_date, "tel aviv") - assert output['success'] + assert output["success"] @respx.mock @@ -58,7 +57,7 @@ async def test_astronomical_data_error_from_api(): json=ERROR_RESPONSE_FROM_MOCK, ) output = await get_astronomical_data(requested_date, "123") - assert not output['success'] + assert not output["success"] @respx.mock @@ -66,9 +65,10 @@ async def test_astronomical_data_error_from_api(): async def test_astronomical_exception_from_api(httpx_mock): requested_date = datetime.datetime.now() + datetime.timedelta(days=3) respx.get(ASTRONOMY_URL).mock( - return_value=httpx.Response(status.HTTP_500_INTERNAL_SERVER_ERROR)) + return_value=httpx.Response(status.HTTP_500_INTERNAL_SERVER_ERROR), + ) output = await get_astronomical_data(requested_date, "456") - assert not output['success'] + assert not output["success"] @responses.activate @@ -82,4 +82,4 @@ async def test_astronomical_no_response_from_api(): ) requests.get(ASTRONOMY_URL) output = await get_astronomical_data(requested_date, "789") - assert not output['success'] + assert not output["success"] diff --git a/tests/test_calendar_privacy.py b/tests/test_calendar_privacy.py index 641b8cf5..0fde3d0a 100644 --- a/tests/test_calendar_privacy.py +++ b/tests/test_calendar_privacy.py @@ -1,7 +1,8 @@ from app.internal.calendar_privacy import can_show_calendar + # TODO after user system is merged: # from app.internal.security.dependancies import CurrentUser -from app.routers.user import create_user +from app.routers.register import _create_user def test_can_show_calendar_public(session, user): @@ -10,32 +11,37 @@ def test_can_show_calendar_public(session, user): # current_user = CurrentUser(**user.__dict__) current_user = user result = can_show_calendar( - requested_user_username='test_username', - db=session, current_user=current_user + requested_user_username="test_username", + db=session, + current_user=current_user, ) assert result is True session.commit() def test_can_show_calendar_private(session, user): - another_user = create_user( + another_user = _create_user( session=session, - username='new_test_username2', - email='new_test.email2@gmail.com', - password='passpar_2', - language_id=1 + username="new_test_username2", + email="new_test.email2@gmail.com", + password="passpar_2", + language_id=1, + full_name="test_full_name", + description="test_description", ) current_user = user # TODO to be replaced after user system is merged: # current_user = CurrentUser(**user.__dict__) result_a = can_show_calendar( - requested_user_username='new_test_username2', - db=session, current_user=current_user + requested_user_username="new_test_username2", + db=session, + current_user=current_user, ) result_b = can_show_calendar( - requested_user_username='test_username', - db=session, current_user=current_user + requested_user_username="test_username", + db=session, + current_user=current_user, ) assert result_a is False assert result_b is True diff --git a/tests/test_categories.py b/tests/test_categories.py index bf670d10..7b9e3490 100644 --- a/tests/test_categories.py +++ b/tests/test_categories.py @@ -1,14 +1,15 @@ import pytest from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.testing import mock - from starlette import status from starlette.datastructures import ImmutableMultiDict from app.database.models import Event -from app.routers.categories import (get_user_categories, - validate_request_params, - validate_color_format) +from app.routers.categories import ( + get_user_categories, + validate_color_format, + validate_request_params, +) class TestCategories: @@ -23,37 +24,53 @@ def test_get_categories_logic_succeeded(session, user, category): @staticmethod def test_creating_new_category(categories_test_client, session, user): - CORRECT_ADD_CATEGORY_DATA = {"user_id": user.id, - "name": "Foo", - "color": "eecc11"} - response = categories_test_client.post("/categories/", - data=CORRECT_ADD_CATEGORY_DATA) + CORRECT_ADD_CATEGORY_DATA = { + "user_id": user.id, + "name": "Foo", + "color": "eecc11", + } + response = categories_test_client.post( + "/categories/", + data=CORRECT_ADD_CATEGORY_DATA, + ) assert response.ok assert TestCategories.CREATE_CATEGORY in response.content @staticmethod - def test_create_not_unique_category_failed(categories_test_client, sender, - category): - CATEGORY_ALREADY_EXISTS = {"name": "Guitar Lesson", - "color": "121212", - "user_id": sender.id} - response = categories_test_client.post("/categories/", - data=CATEGORY_ALREADY_EXISTS) + def test_create_not_unique_category_failed( + categories_test_client, + sender, + category, + ): + CATEGORY_ALREADY_EXISTS = { + "name": "Guitar Lesson", + "color": "121212", + "user_id": sender.id, + } + response = categories_test_client.post( + "/categories/", + data=CATEGORY_ALREADY_EXISTS, + ) assert response.ok assert TestCategories.CATEGORY_ALREADY_EXISTS_MSG in response.content @staticmethod def test_creating_new_category_bad_color_format(client, user): - response = client.post("/categories/", - data={"user_id": user.id, "name": "Foo", - "color": "bad format"}) + response = client.post( + "/categories/", + data={"user_id": user.id, "name": "Foo", "color": "bad format"}, + ) assert response.status_code == status.HTTP_400_BAD_REQUEST assert TestCategories.BAD_COLOR_FORMAT in response.json()["detail"] @staticmethod def test_create_event_with_category(category): - event = Event(title="OOO", content="Guitar rocks!!", - owner_id=category.user_id, category_id=category.id) + event = Event( + title="OOO", + content="Guitar rocks!!", + owner_id=category.user_id, + category_id=category.id, + ) assert event.category_id is not None assert event.category_id == category.id @@ -66,36 +83,51 @@ def test_update_event_with_category(today_event, category): @staticmethod def test_get_user_categories(client, category): - response = client.get(f"/categories/user/?" - f"user_id={category.user_id}" - f"&name={category.name}&color={category.color}") + response = client.get( + f"/categories/user/?" + f"user_id={category.user_id}" + f"&name={category.name}&color={category.color}", + ) assert response.ok assert len(response.json()) == 1 assert set(response.json()[0].items()) == { - ("user_id", category.user_id), ("color", "121212"), - ("name", "Guitar Lesson"), ("id", category.id)} + ("user_id", category.user_id), + ("color", "121212"), + ("name", "Guitar Lesson"), + ("id", category.id), + } @staticmethod def test_get_category_by_name(client, sender, category): - response = client.get(f"/categories/user/?" - f"user_id={category.user_id}" - f"&name={category.name}") + response = client.get( + f"/categories/user/?" + f"user_id={category.user_id}" + f"&name={category.name}", + ) assert response.ok assert len(response.json()) == 1 assert set(response.json()[0].items()) == { - ("user_id", category.user_id), ("color", "121212"), - ("name", "Guitar Lesson"), ("id", category.id)} + ("user_id", category.user_id), + ("color", "121212"), + ("name", "Guitar Lesson"), + ("id", category.id), + } @staticmethod def test_get_category_by_color(client, sender, category): - response = client.get(f"/categories/user/?" - f"user_id={category.user_id}&" - f"color={category.color}") + response = client.get( + f"/categories/user/?" + f"user_id={category.user_id}&" + f"color={category.color}", + ) assert response.ok assert len(response.json()) == 1 assert set(response.json()[0].items()) == { - ("user_id", category.user_id), ("color", "121212"), - ("name", "Guitar Lesson"), ("id", category.id)} + ("user_id", category.user_id), + ("color", "121212"), + ("name", "Guitar Lesson"), + ("id", category.id), + } @staticmethod def test_get_category_bad_request(client): @@ -110,38 +142,61 @@ def test_get_category_ok_request(client): @staticmethod def test_repr(category): - assert category.__repr__() == \ - f'' + assert ( + category.__repr__() + == f"" + ) @staticmethod def test_to_dict(category): - assert {c.name: getattr(category, c.name) for c in - category.__table__.columns} == category.to_dict() - - @staticmethod - @pytest.mark.parametrize('params, expected_result', [ - (ImmutableMultiDict([('user_id', ''), ('name', ''), - ('color', 'aabbcc')]), True), - (ImmutableMultiDict([('user_id', ''), ('name', '')]), True), - (ImmutableMultiDict([('user_id', ''), ('color', 'aabbcc')]), True), - (ImmutableMultiDict([('user_id', '')]), True), - (ImmutableMultiDict([('name', ''), ('color', 'aabbcc')]), False), - (ImmutableMultiDict([]), False), - (ImmutableMultiDict([('user_id', ''), ('name', ''), - ('color', 'aabbcc'), ('bad_param', '')]), False), - ]) + assert { + c.name: getattr(category, c.name) + for c in category.__table__.columns + } == category.to_dict() + + @staticmethod + @pytest.mark.parametrize( + "params, expected_result", + [ + ( + ImmutableMultiDict( + [("user_id", ""), ("name", ""), ("color", "aabbcc")], + ), + True, + ), + (ImmutableMultiDict([("user_id", ""), ("name", "")]), True), + (ImmutableMultiDict([("user_id", ""), ("color", "aabbcc")]), True), + (ImmutableMultiDict([("user_id", "")]), True), + (ImmutableMultiDict([("name", ""), ("color", "aabbcc")]), False), + (ImmutableMultiDict([]), False), + ( + ImmutableMultiDict( + [ + ("user_id", ""), + ("name", ""), + ("color", "aabbcc"), + ("bad_param", ""), + ], + ), + False, + ), + ], + ) def test_validate_request_params(params, expected_result): assert validate_request_params(params) == expected_result @staticmethod - @pytest.mark.parametrize('color, expected_result', [ - ("aabbcc", True), - ("110033", True), - ("114b33", True), - ("", False), - ("aabbcg", False), - ("aabbc", False), - ]) + @pytest.mark.parametrize( + "color, expected_result", + [ + ("aabbcc", True), + ("110033", True), + ("114b33", True), + ("", False), + ("aabbcg", False), + ("aabbc", False), + ], + ) def test_validate_color_format(color, expected_result): assert validate_color_format(color) == expected_result diff --git a/tests/test_corona_stats.py b/tests/test_corona_stats.py new file mode 100644 index 00000000..b55fa171 --- /dev/null +++ b/tests/test_corona_stats.py @@ -0,0 +1,98 @@ +import json + +import pytest +from sqlalchemy.orm.exc import NoResultFound + +from app.database.models import CoronaStats +from app.internal import corona_stats + +fake_data = [ + { + "Day_Date": "2020-12-19T00:00:00.000Z", + "vaccinated": 41, + "vaccinated_cum": 58, + "vaccinated_population_perc": 0, + "vaccinated_seconde_dose": 0, + "vaccinated_seconde_dose_cum": 0, + "vaccinated_seconde_dose_population_perc": 0, + }, + { + "Day_Date": "2020-12-20T00:00:00.000Z", + "vaccinated": 7352, + "vaccinated_cum": 7410, + "vaccinated_population_perc": 0.08, + "vaccinated_seconde_dose": 0, + "vaccinated_seconde_dose_cum": 0, + "vaccinated_seconde_dose_population_perc": 0, + }, + { + "Day_Date": "2020-12-21T00:00:00.000Z", + "vaccinated": 24863, + "vaccinated_cum": 32273, + "vaccinated_population_perc": 0.35, + "vaccinated_seconde_dose": 0, + "vaccinated_seconde_dose_cum": 0, + "vaccinated_seconde_dose_population_perc": 0, + }, + { + "Day_Date": "2020-12-22T00:00:00.000Z", + "vaccinated": 44610, + "vaccinated_cum": 76883, + "vaccinated_population_perc": 0.83, + "vaccinated_seconde_dose": 0, + "vaccinated_seconde_dose_cum": 0, + "vaccinated_seconde_dose_population_perc": 0, + }, +] + + +def is_empty(session): + res = session.query(CoronaStats).filter().count() + return res == 0 + + +def test_get_vacinated_data_from_db(session): + with pytest.raises(NoResultFound): + corona_stats.get_vacinated_data_from_db(session) + + +@pytest.mark.asyncio +async def test_get_vacinated_data(httpx_mock): + test_data = json.dumps(fake_data) + httpx_mock.add_response(method="GET", json=test_data) + data = await corona_stats.get_vacinated_data() + assert data + + +def test_save_corona_stats(session): + test_data = (fake_data)[-1] + + corona_stats.save_corona_stats(test_data, session) + + assert is_empty(session) is False + + +@pytest.mark.asyncio +async def test_get_corona_stats(httpx_mock, session): + httpx_mock.add_response(method="GET", json=fake_data) + data = await corona_stats.get_corona_stats(session) + assert data + assert not is_empty(session) + + +def test_serialize_stats(): + stats_object = CoronaStats( + vaccinated_second_dose_perc=100, + vaccinated_second_dose_total=200, + vaccinated=0, + ) + + serialized = corona_stats.serialize_stats(stats_object) + assert type(serialized) is dict + + +def test_create_stats_object(): + stats_object = fake_data[-1] + + unserialized = corona_stats.create_stats_object(stats_object) + assert type(unserialized) is CoronaStats diff --git a/tests/test_dayview.py b/tests/test_dayview.py index 48414553..e0fe4a27 100644 --- a/tests/test_dayview.py +++ b/tests/test_dayview.py @@ -1,17 +1,28 @@ from datetime import datetime, timedelta -from bs4 import BeautifulSoup import pytest +from bs4 import BeautifulSoup -from app.database.models import Event +from app.database.models import Event, User from app.routers.dayview import ( - DivAttributes, + CurrentTimeAttributes, + EventsAttributes, is_all_day_event_in_day, is_specific_time_event_in_day, ) - from app.routers.event import create_event +REGISTER_DETAIL = { + "username": "correct_user", + "full_name": "full_name", + "password": "correct_password", + "confirm_password": "correct_password", + "email": "example@email.com", + "description": "", +} + +LOGIN_DATA = {"username": "correct_user", "password": "correct_password"} + def create_dayview_event(events, session, user): for event in events: @@ -26,21 +37,34 @@ def create_dayview_event(events, session, user): def test_minutes_position_calculation(event_with_no_minutes_modified): - div_attr = DivAttributes(event_with_no_minutes_modified) + div_attr = EventsAttributes(event_with_no_minutes_modified) assert div_attr._minutes_position(div_attr.start_time.minute) is None assert div_attr._minutes_position(div_attr.end_time.minute) is None assert div_attr._minutes_position(0) is None - assert div_attr._minutes_position(60) == 4 + assert div_attr._minutes_position(60)["min_position"] == 4 def test_div_attributes(event1): - div_attr = DivAttributes(event1) + div_attr = EventsAttributes(event1) assert div_attr.total_time == "07:05 - 09:15" assert div_attr.grid_position == "32 / 40" assert div_attr.length == 130 assert div_attr.color == "grey" +def test_current_time_gets_today_attributes(): + today = datetime.now() + current_attr = CurrentTimeAttributes(today) + assert current_attr.dayview_date == today.date() + assert current_attr.is_viewed is True + + +def test_current_time_gets_not_today_attributes(not_today): + current_attr = CurrentTimeAttributes(not_today) + assert str(current_attr.dayview_date) == "2012-12-12" + assert current_attr.is_viewed is False + + @pytest.mark.parametrize( "minutes,css_class,visiblity", [ @@ -59,18 +83,18 @@ def test_font_size_attribute(minutes, css_class, visiblity): end=end, owner_id=1, ) - div_attr = DivAttributes(event) + div_attr = EventsAttributes(event) assert div_attr.title_size_class == css_class assert div_attr.total_time_visible == visiblity def test_div_attr_multiday(multiday_event): day = datetime(year=2021, month=2, day=1) - assert DivAttributes(multiday_event, day).grid_position == "55 / 101" + assert EventsAttributes(multiday_event, day).grid_position == "55 / 101" day += timedelta(hours=24) - assert DivAttributes(multiday_event, day).grid_position == "1 / 101" + assert EventsAttributes(multiday_event, day).grid_position == "1 / 101" day += timedelta(hours=24) - assert DivAttributes(multiday_event, day).grid_position == "1 / 55" + assert EventsAttributes(multiday_event, day).grid_position == "1 / 55" def test_is_specific_time_event_in_day(all_day_event1, event3): @@ -108,19 +132,41 @@ def test_is_all_day_event_in_day(all_day_event1, event3): def test_div_attributes_with_costume_color(event2): - div_attr = DivAttributes(event2) + div_attr = EventsAttributes(event2) assert div_attr.color == "blue" -def test_wrong_timeformat(session, user, client, event1, event2, event3): - create_dayview_event([event1, event2, event3], session=session, user=user) - response = client.get("/day/1-2-2021") +def test_needs_login(session, dayview_test_client): + response = dayview_test_client.get("/day/2021-2-1") + assert response.ok + assert b"Login" in response.content + + +def test_wrong_timeformat(session, dayview_test_client): + dayview_test_client.post( + dayview_test_client.app.url_path_for("register"), + data=REGISTER_DETAIL, + ) + dayview_test_client.post( + dayview_test_client.app.url_path_for("login"), + data=LOGIN_DATA, + ) + response = dayview_test_client.get("/day/1-2-2021") assert response.status_code == 404 -def test_dayview_html(event1, event2, event3, session, user, client): +def test_dayview_html(event1, event2, event3, session, dayview_test_client): + dayview_test_client.post( + dayview_test_client.app.url_path_for("register"), + data=REGISTER_DETAIL, + ) + dayview_test_client.post( + dayview_test_client.app.url_path_for("login"), + data=LOGIN_DATA, + ) + user = session.query(User).filter_by(username="correct_user").first() create_dayview_event([event1, event2, event3], session=session, user=user) - response = client.get("/day/2021-2-1") + response = dayview_test_client.get("/day/2021-2-1") soup = BeautifulSoup(response.content, "html.parser") assert "FEBRUARY" in str(soup.find("div", {"id": "top-tab"})) assert "event1" in str(soup.find("div", {"id": "event1"})) @@ -139,14 +185,22 @@ def test_dayview_html(event1, event2, event3, session, user, client): def test_dayview_html_with_multiday_event( multiday_event, session, - user, - client, + dayview_test_client, day, grid_position, ): + dayview_test_client.post( + dayview_test_client.app.url_path_for("register"), + data=REGISTER_DETAIL, + ) + dayview_test_client.post( + dayview_test_client.app.url_path_for("login"), + data=LOGIN_DATA, + ) + user = session.query(User).filter_by(username="correct_user").first() create_dayview_event([multiday_event], session=session, user=user) session.commit() - response = client.get(f"/day/{day}") + response = dayview_test_client.get(f"/day/{day}") soup = BeautifulSoup(response.content, "html.parser") grid_pos = f"grid-row: {grid_position};" assert grid_pos in str(soup.find("div", {"id": "event1"})) diff --git a/tests/test_email.py b/tests/test_email.py index 37138239..10022beb 100644 --- a/tests/test_email.py +++ b/tests/test_email.py @@ -1,10 +1,15 @@ -from fastapi import BackgroundTasks, status import pytest +from fastapi import BackgroundTasks, status from sqlalchemy.orm import Session from app.database.models import User -from app.internal.email import (mail, send, send_email_file, - send_email_invitation, verify_email_pattern) +from app.internal.email import ( + mail, + send, + send_email_file, + send_email_invitation, + verify_email_pattern, +) from app.internal.utils import create_model, delete_instance @@ -16,10 +21,14 @@ def test_email_send(client, user, event, smtpd): mail.config.MAIL_TLS = False with mail.record_messages() as outbox: response = client.post( - "/email/send", data={ - "event_used": event.id, "user_to_send": user.id, + "/email/send", + data={ + "event_used": event.id, + "user_to_send": user.id, "title": "Testing", - "background_tasks": BackgroundTasks}) + "background_tasks": BackgroundTasks, + }, + ) assert len(outbox) == 1 assert response.ok @@ -30,10 +39,14 @@ def test_failed_email_send(client, user, event, smtpd): mail.config.MAIL_PORT = smtpd.port with mail.record_messages() as outbox: response = client.post( - "/email/send", data={ - "event_used": event.id + 1, "user_to_send": user.id, + "/email/send", + data={ + "event_used": event.id + 1, + "user_to_send": user.id, "title": "Testing", - "background_tasks": BackgroundTasks}) + "background_tasks": BackgroundTasks, + }, + ) assert len(outbox) == 0 assert not response.ok @@ -59,29 +72,40 @@ def test_send_mail_no_body(client, configured_smtpd): response = client.post("/email/invitation/") assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - assert response.json() == {'detail': [{ - 'loc': ['body'], - 'msg': 'field required', - 'type': 'value_error.missing'}]} + assert response.json() == { + "detail": [ + { + "loc": ["body"], + "msg": "field required", + "type": "value_error.missing", + }, + ], + } assert not outbox def test_send_mail_invalid_email(client, configured_smtpd): with mail.record_messages() as outbox: - response = client.post("/email/invitation/", json={ - "sender_name": "string", - "recipient_name": "string", - "recipient_mail": "test#mail.com" - }) + response = client.post( + "/email/invitation/", + json={ + "sender_name": "string", + "recipient_name": "string", + "recipient_mail": "test#mail.com", + }, + ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY assert response.json() == { - "detail": "Please enter valid email address"} + "detail": "Please enter valid email address", + } assert not outbox -def assert_validation_error_missing_body_fields(validation_msg, - missing_fields): +def assert_validation_error_missing_body_fields( + validation_msg, + missing_fields, +): """ helper function for asserting with open api validation errors look at https://fastapi.tiangolo.com/tutorial/path-params/#data-validation @@ -108,102 +132,130 @@ def assert_validation_error_missing_body_fields(validation_msg, assert loc[1] in missing_fields -@pytest.mark.parametrize("body, missing_fields", [ - ( +@pytest.mark.parametrize( + "body, missing_fields", + [ + ( {"sender_name": "string", "recipient_name": "string"}, ["recipient_mail"], - ), - - ( + ), + ( {"sender_name": "string", "recipient_mail": "test@mail.com"}, ["recipient_name"], - ), - ( + ), + ( {"recipient_name": "string", "recipient_mail": "test@mail.com"}, ["sender_name"], - ), - ( + ), + ( {"sender_name": "string"}, ["recipient_name", "recipient_mail"], - ), - ( + ), + ( {"recipient_name": "string"}, ["sender_name", "recipient_mail"], - ), - ( + ), + ( {"recipient_mail": "test@mail.com"}, ["sender_name", "recipient_name"], - ), -]) -def test_send_mail_partial_body(body, missing_fields, - client, configured_smtpd): + ), + ], +) +def test_send_mail_partial_body( + body, + missing_fields, + client, + configured_smtpd, +): with mail.record_messages() as outbox: response = client.post("/email/invitation/", json=body) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - assert_validation_error_missing_body_fields(response.json(), - missing_fields) + assert_validation_error_missing_body_fields( + response.json(), + missing_fields, + ) assert not outbox def test_send_mail_valid_email(client, configured_smtpd): with mail.record_messages() as outbox: - response = client.post("/email/invitation/", json={ - "sender_name": "string", - "recipient_name": "string", - "recipient_mail": "test@mail.com" - } - ) + response = client.post( + "/email/invitation/", + json={ + "sender_name": "string", + "recipient_name": "string", + "recipient_mail": "test@mail.com", + }, + ) assert response.ok assert outbox -@pytest.mark.parametrize("sender_name,recipient_name,recipient_mail", [ - ("", "other_person", "other@mail.com"), - ("us_person", "", "other@mail.com"), -]) -def test_send_mail_bad_invitation(client, - configured_smtpd, - sender_name, - recipient_name, - recipient_mail): +@pytest.mark.parametrize( + "sender_name,recipient_name,recipient_mail", + [ + ("", "other_person", "other@mail.com"), + ("us_person", "", "other@mail.com"), + ], +) +def test_send_mail_bad_invitation( + client, + configured_smtpd, + sender_name, + recipient_name, + recipient_mail, +): with mail.record_messages() as outbox: - response = client.post("/email/invitation/", json={ - "sender_name": sender_name, - "recipient_name": recipient_name, - "recipient_mail": recipient_mail - } - ) + response = client.post( + "/email/invitation/", + json={ + "sender_name": sender_name, + "recipient_name": recipient_name, + "recipient_mail": recipient_mail, + }, + ) assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - assert response.json() == { - "detail": "Couldn't send the email!"} + assert response.json() == {"detail": "Couldn't send the email!"} assert not outbox -@pytest.mark.parametrize("sender_name,recipient_name,recipient_mail", [ - ("", "other_person", "other@mail.com"), - ("us_person", "", "other@mail.com"), - ("us_person", "other_person", "other#mail.com"), -]) -def test_send_mail_bad_invitation_internal(client, - configured_smtpd, - sender_name, - recipient_name, - recipient_mail): +@pytest.mark.parametrize( + "sender_name,recipient_name,recipient_mail", + [ + ("", "other_person", "other@mail.com"), + ("us_person", "", "other@mail.com"), + ("us_person", "other_person", "other#mail.com"), + ], +) +def test_send_mail_bad_invitation_internal( + client, + configured_smtpd, + sender_name, + recipient_name, + recipient_mail, +): background_task = BackgroundTasks() - assert not send_email_invitation(sender_name, - recipient_name, - recipient_mail, - background_task) - - -@pytest.mark.parametrize("recipient_mail,file_path", [ - ("other@mail.com", "non_existing_file"), - ("other#mail.com", __file__), -]) -def test_send_mail_bad_file_internal(client, - configured_smtpd, - recipient_mail, - file_path): + assert not send_email_invitation( + sender_name, + recipient_name, + recipient_mail, + background_task, + ) + + +@pytest.mark.parametrize( + "recipient_mail,file_path", + [ + ("other@mail.com", "non_existing_file"), + ("other#mail.com", __file__), + ], +) +def test_send_mail_bad_file_internal( + client, + configured_smtpd, + recipient_mail, + file_path, +): background_task = BackgroundTasks() assert not send_email_file(file_path, recipient_mail, background_task) @@ -216,10 +268,11 @@ def test_send_mail_good_file_internal(client, configured_smtpd): @pytest.fixture def bad_user(session: Session) -> User: test_user = create_model( - session, User, - username='test_username', - password='test_password', - email='test.email#gmail.com', + session, + User, + username="test_username", + password="test_password", + email="test.email#gmail.com", language_id=1, ) yield test_user @@ -228,15 +281,18 @@ def bad_user(session: Session) -> User: def test_send(session, bad_user, event): background_task = BackgroundTasks() - assert not send(session=session, - event_used=1, - user_to_send=1, - title="Test", - background_tasks=background_task) + assert not send( + session=session, + event_used=1, + user_to_send=1, + title="Test", + background_tasks=background_task, + ) -@pytest.mark.parametrize("email", ["test#mail.com", - "test_mail.com", - "test@mail-com"]) +@pytest.mark.parametrize( + "email", + ["test#mail.com", "test_mail.com", "test@mail-com"], +) def test_verify_email_pattern(email): assert not verify_email_pattern(email) diff --git a/tests/test_emotion.py b/tests/test_emotion.py index 644dfe14..a47dca3a 100644 --- a/tests/test_emotion.py +++ b/tests/test_emotion.py @@ -4,15 +4,13 @@ from app.internal.emotion import ( Emoticon, - is_emotion_above_significance, get_dominant_emotion, get_emotion, get_html_emoticon, + is_emotion_above_significance, ) - from app.routers.event import create_event - HAPPY_MESSAGE = "This is great" # 100% happy SAD_MESSAGE = "I'm so lonely and feel bad" # 100% sad ANGRY_MESSAGE = "I'm so mad, stop it" # 100% angry diff --git a/tests/test_event.py b/tests/test_event.py index e0622a2a..15fa25f9 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -1,20 +1,22 @@ -from datetime import datetime, timedelta import json -import pytest +import os +from datetime import datetime, timedelta +import pytest from fastapi import HTTPException, Request from fastapi.testclient import TestClient -from sqlalchemy.sql.elements import Null +from PIL import Image from sqlalchemy.orm.session import Session +from sqlalchemy.sql.elements import Null from starlette import status +from app.config import PICTURE_EXTENSION from app.database.models import Comment, Event -from app.dependencies import get_db +from app.dependencies import UPLOAD_PATH, get_db from app.internal.privacy import PrivacyKinds from app.internal.utils import delete_instance from app.main import app from app.routers import event as evt - from app.routers.event import event_to_show CORRECT_EVENT_FORM_DATA = { @@ -527,6 +529,36 @@ def test_deleting_an_event_does_not_exist(event_test_client, event): assert response.status_code == status.HTTP_404_NOT_FOUND +def test_event_with_image(event_test_client, client, session): + img = Image.new("RGB", (60, 30), color="red") + img.save("pil_red.png") + with open("pil_red.png", "rb") as img: + imgstr = img.read() + files = {"event_img": imgstr} + data = {**CORRECT_EVENT_FORM_DATA} + response = event_test_client.post( + client.app.url_path_for("create_new_event"), + data=data, + files=files, + ) + event_created = session.query(Event).order_by(Event.id.desc()).first() + event_id = event_created.id + is_event_image = f"{event_id}{PICTURE_EXTENSION}" == event_created.image + assert response.ok + assert ( + client.app.url_path_for("eventview", event_id=event_id).strip( + f"{event_id}", + ) + in response.headers["location"] + ) + assert is_event_image is True + event_image_path = os.path.join(UPLOAD_PATH, event_created.image) + os.remove(event_image_path) + os.remove("pil_red.png") + session.delete(event_created) + session.commit() + + def test_can_show_event_public(event, session, user): assert event_to_show(event, session) == event assert event_to_show(event, session, user) == event @@ -638,7 +670,7 @@ def test_delete_comment( class TestApp: client = TestClient(app) - date_test_data = [datetime.today() - timedelta(1), datetime.today()] + date_test_data = [datetime.today() - timedelta(days=1), datetime.today()] event_test_data = { "title": "Test Title", "location": "Fake City", diff --git a/tests/test_geolocation.py b/tests/test_geolocation.py new file mode 100644 index 00000000..0a634586 --- /dev/null +++ b/tests/test_geolocation.py @@ -0,0 +1,105 @@ +import pytest +from sqlalchemy.sql import func + +from app.database.models import Event +from app.internal.event import get_location_coordinates + + +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 diff --git a/tests/test_google_connect.py b/tests/test_google_connect.py index 02511266..cfb1f466 100644 --- a/tests/test_google_connect.py +++ b/tests/test_google_connect.py @@ -1,15 +1,15 @@ from datetime import datetime + import pytest +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from googleapiclient.http import HttpMock from loguru import logger import app.internal.google_connect as google_connect -from app.routers.event import create_event from app.database.models import OAuthCredentials -from app.routers.user import create_user - -from google.oauth2.credentials import Credentials -from googleapiclient.discovery import build -from googleapiclient.http import HttpMock +from app.routers.event import create_event +from app.routers.register import _create_user @pytest.fixture @@ -24,25 +24,13 @@ def google_events_mock(): "created": "2021-01-13T09:10:02.000Z", "updated": "2021-01-13T09:10:02.388Z", "summary": "some title", - "creator": { - "email": "someemail", - "self": True - }, - "organizer": { - "email": "someemail", - "self": True - }, - "start": { - "dateTime": "2021-02-25T13:00:00+02:00" - }, - "end": { - "dateTime": "2021-02-25T14:00:00+02:00" - }, + "creator": {"email": "someemail", "self": True}, + "organizer": {"email": "someemail", "self": True}, + "start": {"dateTime": "2021-02-25T13:00:00+02:00"}, + "end": {"dateTime": "2021-02-25T14:00:00+02:00"}, "iCalUID": "somecode", "sequence": 0, - "reminders": { - "useDefault": True - } + "reminders": {"useDefault": True}, }, { "kind": "calendar#event", @@ -53,27 +41,15 @@ def google_events_mock(): "created": "2021-01-13T09:10:02.000Z", "updated": "2021-01-13T09:10:02.388Z", "summary": "some title to all day event", - "creator": { - "email": "someemail", - "self": True - }, - "organizer": { - "email": "someemail", - "self": True - }, - "start": { - "date": "2021-02-25" - }, - "end": { - "date": "2021-02-25" - }, + "creator": {"email": "someemail", "self": True}, + "organizer": {"email": "someemail", "self": True}, + "start": {"date": "2021-02-25"}, + "end": {"date": "2021-02-25"}, "iCalUID": "somecode", "sequence": 0, - "location": 'somelocation', - "reminders": { - "useDefault": True - } - } + "location": "somelocation", + "reminders": {"useDefault": True}, + }, ] @@ -85,7 +61,7 @@ def credentials(): token_uri="some_uri", client_id="somecode", client_secret="some_secret", - expiry=datetime(2021, 1, 28) + expiry=datetime(2021, 1, 28), ) return cred @@ -100,30 +76,30 @@ def test_push_events_to_db(google_events_mock, user, session): def test_db_cleanup(google_events_mock, user, session): for event in google_events_mock: location = None - title = event['summary'] + title = event["summary"] # support for all day events - if 'dateTime' in event['start'].keys(): + if "dateTime" in event["start"].keys(): # part time event - start = datetime.fromisoformat(event['start']['dateTime']) - end = datetime.fromisoformat(event['end']['dateTime']) + start = datetime.fromisoformat(event["start"]["dateTime"]) + end = datetime.fromisoformat(event["end"]["dateTime"]) else: # all day event - start = event['start']['date'].split('-') + start = event["start"]["date"].split("-") start = datetime( year=int(start[0]), month=int(start[1]), - day=int(start[2]) + day=int(start[2]), ) - end = event['end']['date'].split('-') + end = event["end"]["date"].split("-") end = datetime( year=int(end[0]), month=int(end[1]), - day=int(end[2]) + day=int(end[2]), ) - if 'location' in event.keys(): - location = event['location'] + if "location" in event.keys(): + location = event["location"] create_event( db=session, @@ -132,20 +108,26 @@ def test_db_cleanup(google_events_mock, user, session): end=end, owner_id=user.id, location=location, - is_google_event=True + is_google_event=True, ) assert google_connect.cleanup_user_google_calendar_events( - user, session) + user, + session, + ) @pytest.mark.usefixtures("session") def test_get_credentials_from_db(session): - user = create_user(session=session, - username='new_test_username', - password='new_test_password', - email='new_test.email@gmail.com', - language_id=1) + user = _create_user( + session=session, + username="new_test_username", + password="new_test_password", + email="new_test.email@gmail.com", + language_id=1, + full_name="test_full_name", + description="test_description", + ) credentials = OAuthCredentials( owner=user, @@ -154,7 +136,7 @@ def test_get_credentials_from_db(session): token_uri="some_uri", client_id="somecode", client_secret="some_secret", - expiry=datetime(2021, 2, 22) + expiry=datetime(2021, 2, 22), ) session.add(credentials) session.commit() @@ -166,17 +148,16 @@ def test_get_credentials_from_db(session): @pytest.mark.usefixtures("session", "user", "credentials") def test_refresh_token(mocker, session, user, credentials): - mocker.patch( - 'google.oauth2.credentials.Credentials.refresh', - return_value=logger.debug('refreshed') + "google.oauth2.credentials.Credentials.refresh", + return_value=logger.debug("refreshed"), ) assert google_connect.refresh_token(credentials, user, session) mocker.patch( - 'google.oauth2.credentials.Credentials.expired', - return_value=False + "google.oauth2.credentials.Credentials.expired", + return_value=False, ) assert google_connect.refresh_token(credentials, user, session) @@ -189,76 +170,75 @@ def __init__(self, service): self.service = service def list(self, *args): - request = self.service.events().list(calendarId='primary', - timeMin=datetime( - 2021, 1, 1).isoformat(), - timeMax=datetime( - 2022, 1, 1).isoformat(), - singleEvents=True, - orderBy='startTime' - ) - http = HttpMock( - 'calendar-linux.json', - {'status': '200'} + request = self.service.events().list( + calendarId="primary", + timeMin=datetime(2021, 1, 1).isoformat(), + timeMax=datetime(2022, 1, 1).isoformat(), + singleEvents=True, + orderBy="startTime", ) + http = HttpMock("calendar-linux.json", {"status": "200"}) response = request.execute(http=http) return response - http = HttpMock( - './tests/calendar-discovery.json', - {'status': '200'} - ) + http = HttpMock("./tests/calendar-discovery.json", {"status": "200"}) - service = build('calendar', 'v3', http=http) + service = build("calendar", "v3", http=http) mocker.patch( - 'googleapiclient.discovery.build', + "googleapiclient.discovery.build", return_value=service, - events=service + events=service, ) mocker.patch( - 'googleapiclient.discovery.Resource', - events=mock_events(service) + "googleapiclient.discovery.Resource", + events=mock_events(service), ) assert google_connect.get_current_year_events(credentials, user, session) -@pytest.mark.usefixtures("user", "session", - "google_connect_test_client", "credentials") +@pytest.mark.usefixtures( + "user", + "session", + "google_connect_test_client", + "credentials", +) def test_google_sync(mocker, google_connect_test_client, session, credentials): - create_user(session=session, - username='new_test_username', - password='new_test_password', - email='new_test.email@gmail.com', - language_id=1) + _create_user( + session=session, + username="new_test_username", + password="new_test_password", + email="new_test.email@gmail.com", + language_id=1, + full_name="test_full_name", + description="test_description", + ) mocker.patch( - 'app.routers.google_connect.get_credentials', - return_value=credentials + "app.routers.google_connect.get_credentials", + return_value=credentials, ) mocker.patch( - 'app.routers.google_connect.fetch_save_events', - return_value=None + "app.routers.google_connect.fetch_save_events", + return_value=None, ) connect = google_connect_test_client.get( - 'google/sync', - headers={ - "referer": 'http://testserver/' - }) + "google/sync", + headers={"referer": "http://testserver/"}, + ) assert connect.ok # second case mocker.patch( - 'app.routers.google_connect.get_credentials', - return_value=None + "app.routers.google_connect.get_credentials", + return_value=None, ) connect = google_connect_test_client.get( - 'google/sync', - headers={ - "referer": 'http://testserver/' - }) + "google/sync", + headers={"referer": "http://testserver/"}, + ) assert connect.ok @@ -270,97 +250,125 @@ def test_is_client_secret_none(): @pytest.mark.usefixtures("session") def test_clean_up_old_credentials_from_db(session): google_connect.clean_up_old_credentials_from_db(session) - assert len(session.query(OAuthCredentials) - .filter_by(user_id=None).all()) == 0 + assert ( + len(session.query(OAuthCredentials).filter_by(user_id=None).all()) == 0 + ) -@pytest.mark.usefixtures("session", 'user', 'credentials') -def test_get_credentials_from_consent_screen(mocker, session, - user, credentials): +@pytest.mark.usefixtures("session", "user", "credentials") +def test_get_credentials_from_consent_screen( + mocker, + session, + user, + credentials, +): mocker.patch( - 'google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file', - return_value=mocker.Mock(name='flow', **{ - "credentials": credentials, - "run_local_server": mocker.Mock(name='run_local_server', - return_value=logger.debug( - 'running server')) - }) + "google_auth_oauthlib.flow.InstalledAppFlow.from_client_secrets_file", + return_value=mocker.Mock( + name="flow", + **{ + "credentials": credentials, + "run_local_server": mocker.Mock( + name="run_local_server", + return_value=logger.debug("running server"), + ), + } + ), ) mocker.patch( - 'app.internal.google_connect.is_client_secret_none', - return_value=False + "app.internal.google_connect.is_client_secret_none", + return_value=False, ) - assert google_connect.get_credentials_from_consent_screen( - user, session) == credentials + assert ( + google_connect.get_credentials_from_consent_screen(user, session) + == credentials + ) @pytest.mark.usefixtures("session") def test_create_google_event(session): - user = create_user(session=session, - username='new_test_username', - password='new_test_password', - email='new_test.email@gmail.com', - language_id=1) + user = _create_user( + session=session, + username="new_test_username", + password="new_test_password", + email="new_test.email@gmail.com", + language_id=1, + full_name="test_full_name", + description="test_description", + ) event = google_connect.create_google_event( - 'title', - datetime(2021, 1, 1, 15, 15), - datetime(2021, 1, 1, 15, 30), - user, - 'location', - session - ) + "title", + datetime(2021, 1, 1, 15, 15), + datetime(2021, 1, 1, 15, 30), + user, + "location", + session, + ) - assert event.title == 'title' + assert event.title == "title" -@pytest.mark.usefixtures("session", "user", 'credentials') +@pytest.mark.usefixtures("session", "user", "credentials") def test_get_credentials(mocker, session, user, credentials): - user = create_user( + user = _create_user( session=session, - username='new_test_username', - password='new_test_password', - email='new_test.email@gmail.com', - language_id=1 + username="new_test_username", + password="new_test_password", + email="new_test.email@gmail.com", + language_id=1, + full_name="test_full_name", + description="test_description", ) mocker.patch( - 'app.internal.google_connect.get_credentials_from_consent_screen', - return_value=credentials + "app.internal.google_connect.get_credentials_from_consent_screen", + return_value=credentials, ) - assert google_connect.get_credentials(user=user, - session=session) == credentials + assert ( + google_connect.get_credentials(user=user, session=session) + == credentials + ) mocker.patch( - 'app.internal.google_connect.get_credentials', - return_value=credentials + "app.internal.google_connect.get_credentials", + return_value=credentials, ) mocker.patch( - 'app.internal.google_connect.refresh_token', - return_value=credentials + "app.internal.google_connect.refresh_token", + return_value=credentials, ) - assert google_connect.get_credentials(user=user, - session=session) == credentials - + assert ( + google_connect.get_credentials(user=user, session=session) + == credentials + ) -@pytest.mark.usefixtures("session", "user", - 'credentials', 'google_events_mock') -def test_fetch_save_events(mocker, session, user, credentials, - google_events_mock): +@pytest.mark.usefixtures( + "session", + "user", + "credentials", + "google_events_mock", +) +def test_fetch_save_events( + mocker, + session, + user, + credentials, + google_events_mock, +): mocker.patch( - 'app.internal.google_connect.get_current_year_events', - return_value=google_events_mock + "app.internal.google_connect.get_current_year_events", + return_value=google_events_mock, ) - assert google_connect.fetch_save_events(credentials, - user, session) is None + assert google_connect.fetch_save_events(credentials, user, session) is None -@pytest.mark.usefixtures("session", "user", 'credentials') +@pytest.mark.usefixtures("session", "user", "credentials") def test_push_credentials_to_db(session, user, credentials): assert google_connect.push_credentials_to_db(credentials, user, session) diff --git a/tests/test_holidays.py b/tests/test_holidays.py index 7dfab593..79723be0 100644 --- a/tests/test_holidays.py +++ b/tests/test_holidays.py @@ -1,22 +1,24 @@ import os + +from sqlalchemy.orm import Session + from app.database.models import Event, User from app.routers import profile -from sqlalchemy.orm import Session class TestHolidaysImport: - HOLIDAYS = '/profile/holidays/import' + HOLIDAYS = "/profile/holidays/import" @staticmethod def test_import_holidays_page_exists(client): resp = client.get(TestHolidaysImport.HOLIDAYS) assert resp.ok - assert b'Import holidays using ics file' in resp.content + assert b"Import holidays using ics file" in resp.content def test_get_holidays(self, session: Session, user: User): current_folder = os.path.dirname(os.path.realpath(__file__)) - resource_folder = os.path.join(current_folder, 'resources') - test_file = os.path.join(resource_folder, 'ics_example.txt') + resource_folder = os.path.join(current_folder, "resources") + test_file = os.path.join(resource_folder, "ics_example.txt") with open(test_file) as file: ics_content = file.read() holidays = profile.get_holidays_from_file(ics_content, session) @@ -25,8 +27,8 @@ def test_get_holidays(self, session: Session, user: User): def test_wrong_file_get_holidays(self, session: Session, user: User): current_folder = os.path.dirname(os.path.realpath(__file__)) - resource_folder = os.path.join(current_folder, 'resources') - test_file = os.path.join(resource_folder, 'wrong_ics_example.txt') + resource_folder = os.path.join(current_folder, "resources") + test_file = os.path.join(resource_folder, "wrong_ics_example.txt") with open(test_file) as file: ics_content = file.read() holidays = profile.get_holidays_from_file(ics_content, session) diff --git a/tests/test_international_days.py b/tests/test_international_days.py new file mode 100644 index 00000000..f2fd948f --- /dev/null +++ b/tests/test_international_days.py @@ -0,0 +1,65 @@ +from datetime import date, timedelta + +import pytest + +from app.database.models import InternationalDays +from app.internal import international_days +from app.internal.international_days import get_international_day_per_day +from app.internal.json_data_loader import _insert_into_database +from app.internal.utils import create_model, delete_instance + +DATE = date(2021, 6, 1) +DAY = "Hamburger day" + + +@pytest.fixture +def international_day(session): + inter_day = create_model( + session, + InternationalDays, + id=1, + day=1, + month=6, + international_day="Hamburger day", + ) + yield inter_day + delete_instance(session, inter_day) + + +@pytest.fixture +def all_international_days(session): + _insert_into_database( + session, + "app/resources/international_days.json", + InternationalDays, + international_days.get_international_day, + ) + all_international_days = session.query(InternationalDays) + yield all_international_days + for day in all_international_days: + delete_instance(session, day) + + +def date_range(): + start = date(2024, 1, 1) + end = date(2024, 12, 31) + dates = (end + timedelta(days=1) - start).days + return [start + timedelta(days=i) for i in range(dates)] + + +def test_input_day_equal_output_day(session, international_day): + inter_day = international_days.get_international_day_per_day( + session, + DATE, + ).international_day + assert inter_day == DAY + + +def test_international_day_per_day_no_international_days(session): + result = international_days.get_international_day_per_day(session, DATE) + assert result is None + + +def test_all_international_days_per_day(session, all_international_days): + for day in date_range(): + assert get_international_day_per_day(session, day) diff --git a/tests/test_invitation.py b/tests/test_invitation.py deleted file mode 100644 index c609a973..00000000 --- a/tests/test_invitation.py +++ /dev/null @@ -1,50 +0,0 @@ -from fastapi import status - -from app.routers.invitation import get_all_invitations, get_invitation_by_id - - -class TestInvitations: - NO_INVITATIONS = b"You don't have any invitations." - URL = "/invitations/" - - @staticmethod - def test_view_no_invitations(invitation_test_client): - response = invitation_test_client.get(TestInvitations.URL) - assert response.ok - assert TestInvitations.NO_INVITATIONS in response.content - - @staticmethod - def test_accept_invitations(user, invitation, invitation_test_client): - invitation = {"invite_id ": invitation.id} - resp = invitation_test_client.post( - TestInvitations.URL, data=invitation) - assert resp.status_code == status.HTTP_302_FOUND - - @staticmethod - def test_get_all_invitations_success(invitation, event, user, session): - invitations = get_all_invitations(event=event, db=session) - assert invitations == [invitation] - invitations = get_all_invitations(recipient=user, db=session) - assert invitations == [invitation] - - @staticmethod - def test_get_all_invitations_failure(user, session): - invitations = get_all_invitations(unknown_parameter=user, db=session) - assert invitations == [] - - invitations = get_all_invitations(recipient=None, db=session) - assert invitations == [] - - @staticmethod - def test_get_invitation_by_id(invitation, session): - get_invitation = get_invitation_by_id(invitation.id, db=session) - assert get_invitation == invitation - - @staticmethod - def test_repr(invitation): - invitation_repr = ( - f'' - ) - assert invitation.__repr__() == invitation_repr diff --git a/tests/test_login.py b/tests/test_login.py index 11432738..7b49bb05 100644 --- a/tests/test_login.py +++ b/tests/test_login.py @@ -1,5 +1,4 @@ import pytest - from starlette.status import HTTP_302_FOUND from app.database.models import User @@ -13,205 +12,266 @@ def test_login_route_ok(security_test_client): REGISTER_DETAIL = { - 'username': 'correct_user', 'full_name': 'full_name', - 'password': 'correct_password', 'confirm_password': 'correct_password', - 'email': 'example@email.com', 'description': ""} + "username": "correct_user", + "full_name": "full_name", + "password": "correct_password", + "confirm_password": "correct_password", + "email": "example@email.com", + "description": "", +} LOGIN_WRONG_DETAILS = [ - ('wrong_user', 'wrong_password', b'Please check your credentials'), - ('correct_user', 'wrong_password', b'Please check your credentials'), - ('wrong_user', 'correct_password', b'Please check your credentials'), - ('', 'correct_password', b'Please check your credentials'), - ('correct_user', '', b'Please check your credentials'), - ('', '', b'Please check your credentials'), - ] - -LOGIN_DATA = {'username': 'correct_user', 'password': 'correct_password'} + ("wrong_user", "wrong_password", b"Please check your credentials"), + ("correct_user", "wrong_password", b"Please check your credentials"), + ("wrong_user", "correct_password", b"Please check your credentials"), + ("", "correct_password", b"Please check your credentials"), + ("correct_user", "", b"Please check your credentials"), + ("", "", b"Please check your credentials"), +] + +LOGIN_DATA = {"username": "correct_user", "password": "correct_password"} + WRONG_LOGIN_DATA = { - 'username': 'incorrect_user', 'password': 'correct_password'} + "username": "incorrect_user", + "password": "correct_password", +} @pytest.mark.parametrize( - "username, password, expected_response", LOGIN_WRONG_DETAILS) + "username, password, expected_response", + LOGIN_WRONG_DETAILS, +) def test_login_fails( - session, security_test_client, username, password, expected_response): + session, + security_test_client, + username, + password, + expected_response, +): security_test_client.post( - security_test_client.app.url_path_for('register'), - data=REGISTER_DETAIL) - data = {'username': username, 'password': password} + security_test_client.app.url_path_for("register"), + data=REGISTER_DETAIL, + ) + data = {"username": username, "password": password} data = security_test_client.post( - security_test_client.app.url_path_for('login'), - data=data).content + security_test_client.app.url_path_for("login"), + data=data, + ).content assert expected_response in data def test_login_successfull(session, security_test_client): security_test_client.post( - security_test_client.app.url_path_for('register'), - data=REGISTER_DETAIL) + security_test_client.app.url_path_for("register"), + data=REGISTER_DETAIL, + ) res = security_test_client.post( - security_test_client.app.url_path_for('login'), - data=LOGIN_DATA) + security_test_client.app.url_path_for("login"), + data=LOGIN_DATA, + ) assert res.status_code == HTTP_302_FOUND def test_is_logged_in_dependency_with_logged_in_user( - session, security_test_client): + session, + security_test_client, +): security_test_client.post( - security_test_client.app.url_path_for('register'), - data=REGISTER_DETAIL) + security_test_client.app.url_path_for("register"), + data=REGISTER_DETAIL, + ) security_test_client.post( - security_test_client.app.url_path_for('login'), - data=LOGIN_DATA) + security_test_client.app.url_path_for("login"), + data=LOGIN_DATA, + ) res = security_test_client.get( - security_test_client.app.url_path_for('is_logged_in')) + security_test_client.app.url_path_for("is_logged_in"), + ) assert res.json() == {"user": True} def test_is_logged_in_dependency_without_logged_in_user( - session, security_test_client): + session, + security_test_client, +): res = security_test_client.get( - security_test_client.app.url_path_for('logout')) + security_test_client.app.url_path_for("logout"), + ) res = security_test_client.get( - security_test_client.app.url_path_for('is_logged_in')) - assert b'Please log in' in res.content + security_test_client.app.url_path_for("is_logged_in"), + ) + assert b"Please log in" in res.content def test_is_manager_in_dependency_with_logged_in_regular_user( - session, security_test_client): + session, + security_test_client, +): security_test_client.post( - security_test_client.app.url_path_for('register'), - data=REGISTER_DETAIL) + security_test_client.app.url_path_for("register"), + data=REGISTER_DETAIL, + ) security_test_client.post( - security_test_client.app.url_path_for('login'), - data=LOGIN_DATA) + security_test_client.app.url_path_for("login"), + data=LOGIN_DATA, + ) res = security_test_client.get( - security_test_client.app.url_path_for('is_manager')) + security_test_client.app.url_path_for("is_manager"), + ) assert b"have a permition" in res.content def test_is_manager_in_dependency_with_logged_in_manager( - session, security_test_client): + session, + security_test_client, +): security_test_client.post( - security_test_client.app.url_path_for('register'), - data=REGISTER_DETAIL) - manager = session.query(User).filter( - User.username == 'correct_user').first() + security_test_client.app.url_path_for("register"), + data=REGISTER_DETAIL, + ) + manager = ( + session.query(User).filter(User.username == "correct_user").first() + ) manager.is_manager = True session.commit() security_test_client.post( - security_test_client.app.url_path_for('login'), data=LOGIN_DATA) + security_test_client.app.url_path_for("login"), + data=LOGIN_DATA, + ) res = security_test_client.get( - security_test_client.app.url_path_for('is_manager')) + security_test_client.app.url_path_for("is_manager"), + ) assert res.json() == {"manager": True} def test_logout(session, security_test_client): res = security_test_client.get( - security_test_client.app.url_path_for('logout')) - assert b'Login' in res.content + security_test_client.app.url_path_for("logout"), + ) + assert b"Login" in res.content def test_incorrect_secret_key_in_token(session, security_test_client): user = LoginUser(**LOGIN_DATA) incorrect_token = create_jwt_token(user, jwt_key="wrong secret key") security_test_client.post( - security_test_client.app.url_path_for('register'), - data=REGISTER_DETAIL) + security_test_client.app.url_path_for("register"), + data=REGISTER_DETAIL, + ) params = f"?existing_jwt={incorrect_token}" security_test_client.post( - security_test_client.app.url_path_for('login') + f'{params}', - data=LOGIN_DATA) + security_test_client.app.url_path_for("login") + f"{params}", + data=LOGIN_DATA, + ) res = security_test_client.get( - security_test_client.app.url_path_for('is_logged_in')) - assert b'Your token is incorrect' in res.content + security_test_client.app.url_path_for("is_logged_in"), + ) + assert b"Your token is incorrect" in res.content def test_expired_token(session, security_test_client): - security_test_client.get( - security_test_client.app.url_path_for('logout')) + security_test_client.get(security_test_client.app.url_path_for("logout")) user = LoginUser(**LOGIN_DATA) incorrect_token = create_jwt_token(user, jwt_min_exp=-1) security_test_client.post( - security_test_client.app.url_path_for('register'), - data=REGISTER_DETAIL) + security_test_client.app.url_path_for("register"), + data=REGISTER_DETAIL, + ) params = f"?existing_jwt={incorrect_token}" security_test_client.post( - security_test_client.app.url_path_for('login') + f'{params}', - data=LOGIN_DATA) + security_test_client.app.url_path_for("login") + f"{params}", + data=LOGIN_DATA, + ) res = security_test_client.get( - security_test_client.app.url_path_for('is_logged_in')) - assert b'expired' in res.content + security_test_client.app.url_path_for("is_logged_in"), + ) + assert b"expired" in res.content def test_corrupted_token(session, security_test_client): user = LoginUser(**LOGIN_DATA) incorrect_token = create_jwt_token(user) + "s" security_test_client.post( - security_test_client.app.url_path_for('register'), - data=REGISTER_DETAIL) + security_test_client.app.url_path_for("register"), + data=REGISTER_DETAIL, + ) params = f"?existing_jwt={incorrect_token}" security_test_client.post( - security_test_client.app.url_path_for('login') + f'{params}', - data=LOGIN_DATA) + security_test_client.app.url_path_for("login") + f"{params}", + data=LOGIN_DATA, + ) res = security_test_client.get( - security_test_client.app.url_path_for('is_logged_in')) - assert b'Your token is incorrect' in res.content + security_test_client.app.url_path_for("is_logged_in"), + ) + assert b"Your token is incorrect" in res.content def test_current_user_from_db_dependency_ok(session, security_test_client): security_test_client.post( - security_test_client.app.url_path_for('register'), - data=REGISTER_DETAIL) + security_test_client.app.url_path_for("register"), + data=REGISTER_DETAIL, + ) security_test_client.post( - security_test_client.app.url_path_for('login'), data=LOGIN_DATA) + security_test_client.app.url_path_for("login"), + data=LOGIN_DATA, + ) res = security_test_client.get( - security_test_client.app.url_path_for('current_user_from_db')) - assert res.json() == {"user": 'correct_user'} + security_test_client.app.url_path_for("current_user_from_db"), + ) + assert res.json() == {"user": "correct_user"} def test_current_user_from_db_dependency_not_logged_in( - session, security_test_client): - security_test_client.get( - security_test_client.app.url_path_for('logout')) + session, + security_test_client, +): + security_test_client.get(security_test_client.app.url_path_for("logout")) res = security_test_client.get( - security_test_client.app.url_path_for('current_user_from_db')) - assert b'Please log in' in res.content + security_test_client.app.url_path_for("current_user_from_db"), + ) + assert b"Please log in" in res.content def test_current_user_from_db_dependency_wrong_details( - session, security_test_client): - security_test_client.get( - security_test_client.app.url_path_for('logout')) + session, + security_test_client, +): + security_test_client.get(security_test_client.app.url_path_for("logout")) security_test_client.post( - security_test_client.app.url_path_for('register'), - data=REGISTER_DETAIL) + security_test_client.app.url_path_for("register"), + data=REGISTER_DETAIL, + ) user = LoginUser(**WRONG_LOGIN_DATA) incorrect_token = create_jwt_token(user) params = f"?existing_jwt={incorrect_token}" security_test_client.post( - security_test_client.app.url_path_for('login') + f'{params}', - data=LOGIN_DATA) + security_test_client.app.url_path_for("login") + f"{params}", + data=LOGIN_DATA, + ) res = security_test_client.get( - security_test_client.app.url_path_for('current_user_from_db')) - assert b'Your token is incorrect' in res.content + security_test_client.app.url_path_for("current_user_from_db"), + ) + assert b"Your token is incorrect" in res.content def test_current_user_dependency_ok(session, security_test_client): security_test_client.post( - security_test_client.app.url_path_for('register'), - data=REGISTER_DETAIL) + security_test_client.app.url_path_for("register"), + data=REGISTER_DETAIL, + ) security_test_client.post( - security_test_client.app.url_path_for('login'), data=LOGIN_DATA) + security_test_client.app.url_path_for("login"), + data=LOGIN_DATA, + ) res = security_test_client.get( - security_test_client.app.url_path_for('current_user')) - assert res.json() == {"user": 'correct_user'} + security_test_client.app.url_path_for("current_user"), + ) + assert res.json() == {"user": "correct_user"} -def test_current_user_dependency_not_logged_in( - session, security_test_client): - security_test_client.get( - security_test_client.app.url_path_for('logout')) +def test_current_user_dependency_not_logged_in(session, security_test_client): + security_test_client.get(security_test_client.app.url_path_for("logout")) res = security_test_client.get( - security_test_client.app.url_path_for('current_user')) - assert b'Please log in' in res.content + security_test_client.app.url_path_for("current_user"), + ) + assert b"Please log in" in res.content diff --git a/tests/test_notification.py b/tests/test_notification.py new file mode 100644 index 00000000..23eacfd2 --- /dev/null +++ b/tests/test_notification.py @@ -0,0 +1,177 @@ +from starlette.status import HTTP_406_NOT_ACCEPTABLE + +from app.database.models import InvitationStatusEnum, MessageStatusEnum +from app.internal.notification import get_all_invitations, get_invitation_by_id +from app.routers.notification import router +from tests.fixtures.client_fixture import login_client + + +class TestNotificationRoutes: + NO_NOTIFICATIONS = b"You don't have any new notifications." + NO_NOTIFICATION_IN_ARCHIVE = b"You don't have any archived notifications." + NEW_NOTIFICATIONS_URL = router.url_path_for("view_notifications") + LOGIN_DATA = {"username": "test_username", "password": "test_password"} + + def test_view_no_notifications( + self, + user, + notification_test_client, + ): + login_client(notification_test_client, self.LOGIN_DATA) + resp = notification_test_client.get(self.NEW_NOTIFICATIONS_URL) + assert resp.ok + assert self.NO_NOTIFICATIONS in resp.content + + def test_accept_invitations( + self, + user, + invitation, + notification_test_client, + ): + login_client(notification_test_client, self.LOGIN_DATA) + assert invitation.status == InvitationStatusEnum.UNREAD + data = { + "invite_id": invitation.id, + "next_url": self.NEW_NOTIFICATIONS_URL, + } + url = router.url_path_for("accept_invitations") + resp = notification_test_client.post(url, data=data) + assert resp.ok + assert InvitationStatusEnum.ACCEPTED + + def test_decline_invitations( + self, + user, + invitation, + notification_test_client, + session, + ): + login_client(notification_test_client, self.LOGIN_DATA) + assert invitation.status == InvitationStatusEnum.UNREAD + data = { + "invite_id": invitation.id, + "next_url": self.NEW_NOTIFICATIONS_URL, + } + url = router.url_path_for("decline_invitations") + resp = notification_test_client.post(url, data=data) + assert resp.ok + session.refresh(invitation) + assert invitation.status == InvitationStatusEnum.DECLINED + + def test_mark_message_as_read( + self, + user, + message, + notification_test_client, + session, + ): + login_client(notification_test_client, self.LOGIN_DATA) + assert message.status == MessageStatusEnum.UNREAD + data = { + "message_id": message.id, + "next_url": self.NEW_NOTIFICATIONS_URL, + } + url = router.url_path_for("mark_message_as_read") + resp = notification_test_client.post(url, data=data) + assert resp.ok + session.refresh(message) + assert message.status == MessageStatusEnum.READ + + def test_mark_all_as_read( + self, + user, + message, + sec_message, + notification_test_client, + session, + ): + login_client(notification_test_client, self.LOGIN_DATA) + url = router.url_path_for("mark_all_as_read") + assert message.status == MessageStatusEnum.UNREAD + assert sec_message.status == MessageStatusEnum.UNREAD + data = {"next_url": self.NEW_NOTIFICATIONS_URL} + resp = notification_test_client.post(url, data=data) + assert resp.ok + session.refresh(message) + session.refresh(sec_message) + assert message.status == MessageStatusEnum.READ + assert sec_message.status == MessageStatusEnum.READ + + def test_archive( + self, + user, + message, + notification_test_client, + session, + ): + login_client(notification_test_client, self.LOGIN_DATA) + archive_url = router.url_path_for("view_archive") + resp = notification_test_client.get(archive_url) + assert resp.ok + assert self.NO_NOTIFICATION_IN_ARCHIVE in resp.content + + # read message + data = { + "message_id": message.id, + "next_url": self.NEW_NOTIFICATIONS_URL, + } + url = router.url_path_for("mark_message_as_read") + notification_test_client.post(url, data=data) + + resp = notification_test_client.get(archive_url) + assert resp.ok + assert self.NO_NOTIFICATION_IN_ARCHIVE not in resp.content + + def test_wrong_id( + self, + user, + notification_test_client, + session, + ): + login_client(notification_test_client, self.LOGIN_DATA) + data = { + "message_id": 1, + "next_url": "/", + } + url = router.url_path_for("mark_message_as_read") + resp = notification_test_client.post(url, data=data) + assert resp.status_code == HTTP_406_NOT_ACCEPTABLE + + +class TestNotification: + def test_get_all_invitations_success( + self, + invitation, + event, + user, + session, + ): + invitations = get_all_invitations(event=event, session=session) + assert invitations == [invitation] + invitations = get_all_invitations(recipient=user, session=session) + assert invitations == [invitation] + + def test_get_all_invitations_failure(self, user, session): + invitations = get_all_invitations( + unknown_parameter=user, + session=session, + ) + assert invitations == [] + + invitations = get_all_invitations(recipient=None, session=session) + assert invitations == [] + + def test_get_invitation_by_id(self, invitation, session): + get_invitation = get_invitation_by_id(invitation.id, session=session) + assert get_invitation == invitation + + def test_invitation_repr(self, invitation): + invitation_repr = ( + f"" + ) + assert invitation.__repr__() == invitation_repr + + def test_message_repr(self, message): + message_repr = f"" + assert message.__repr__() == message_repr diff --git a/tests/test_profile.py b/tests/test_profile.py index 880c6ebc..faa1e80b 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -1,8 +1,8 @@ import os +import pytest from fastapi import status from PIL import Image -import pytest from app import config from app.dependencies import MEDIA_PATH @@ -11,98 +11,98 @@ CROP_RESULTS = [ (20, 10, (5, 0, 15, 10)), (10, 20, (0, 5, 10, 15)), - (10, 10, (0, 0, 10, 10)) + (10, 10, (0, 0, 10, 10)), ] def test_get_placeholder_user(): user = get_placeholder_user() - assert user.username == 'new_user' - assert user.email == 'my@email.po' - assert user.password == '1a2s3d4f5g6' - assert user.full_name == 'My Name' + assert user.username == "new_user" + assert user.email == "my@email.po" + assert user.password == "1a2s3d4f5g6" + assert user.full_name == "My Name" -@pytest.mark.parametrize('width, height, result', CROP_RESULTS) +@pytest.mark.parametrize("width, height, result", CROP_RESULTS) def test_get_image_crop_area(width, height, result): assert get_image_crop_area(width, height) == result def test_profile_page(profile_test_client): - profile = profile_test_client.get('/profile') + profile = profile_test_client.get("/profile") data = profile.content assert profile.ok - assert b'profile.png' in data - assert b'FakeName' in data - assert b'Happy new user!' in data - assert b'On This Day' in data + assert b"profile.png" in data + assert b"FakeName" in data + assert b"Happy new user!" in data + assert b"On This Day" in data def test_update_user_fullname(profile_test_client): - new_name_data = { - 'fullname': 'Peter' - } + new_name_data = {"fullname": "Peter"} # Get profile page and initialize database - profile = profile_test_client.get('/profile') + profile = profile_test_client.get("/profile") # Post new data profile = profile_test_client.post( - '/profile/update_user_fullname', data=new_name_data) + "/profile/update_user_fullname", + data=new_name_data, + ) assert profile.status_code == status.HTTP_302_FOUND # Get updated data - data = profile_test_client.get('/profile').content - assert b'Peter' in data + data = profile_test_client.get("/profile").content + assert b"Peter" in data def test_update_user_email(profile_test_client): - new_email = { - 'email': 'very@new.email' - } + new_email = {"email": "very@new.email"} # Get profile page and initialize database - profile = profile_test_client.get('/profile') + profile = profile_test_client.get("/profile") # Post new data profile = profile_test_client.post( - '/profile/update_user_email', data=new_email) + "/profile/update_user_email", + data=new_email, + ) assert profile.status_code == status.HTTP_302_FOUND # Get updated data - data = profile_test_client.get('/profile').content - assert b'very@new.email' in data + data = profile_test_client.get("/profile").content + assert b"very@new.email" in data def test_update_user_description(profile_test_client): - new_description = { - 'description': "FastAPI Developer" - } + new_description = {"description": "FastAPI Developer"} # Get profile page and initialize database - profile = profile_test_client.get('/profile') + profile = profile_test_client.get("/profile") # Post new data profile = profile_test_client.post( - '/profile/update_user_description', data=new_description) + "/profile/update_user_description", + data=new_description, + ) assert profile.status_code == status.HTTP_302_FOUND # Get updated data - data = profile_test_client.get('/profile').content + data = profile_test_client.get("/profile").content assert b"FastAPI Developer" in data def test_update_telegram_id(profile_test_client): - new_telegram_id = { - 'telegram_id': "12345" - } + new_telegram_id = {"telegram_id": "12345"} # Get profile page and initialize database - profile = profile_test_client.get('/profile') + profile = profile_test_client.get("/profile") # Post new data profile = profile_test_client.post( - '/profile/update_telegram_id', data=new_telegram_id) + "/profile/update_telegram_id", + data=new_telegram_id, + ) assert profile.status_code == status.HTTP_302_FOUND # Get updated data - data = profile_test_client.get('/profile').content + data = profile_test_client.get("/profile").content assert b"12345" in data @@ -110,36 +110,35 @@ def test_upload_user_photo(profile_test_client): example_new_photo = f"{MEDIA_PATH}/example.png" # Get profile page and initialize database - profile = profile_test_client.get('/profile') + profile = profile_test_client.get("/profile") # Post new data profile = profile_test_client.post( - '/profile/upload_user_photo', - files={'file': ( - "filename", open(example_new_photo, "rb"), "image/png")}) + "/profile/upload_user_photo", + files={ + "file": ("filename", open(example_new_photo, "rb"), "image/png"), + }, + ) assert profile.status_code == status.HTTP_302_FOUND # Validate new picture saved in media directory - assert 'fake_user.png' in os.listdir(MEDIA_PATH) + assert "fake_user.png" in os.listdir(MEDIA_PATH) # Validate new picture size - new_avatar_path = os.path.join(MEDIA_PATH, 'fake_user.png') + new_avatar_path = os.path.join(MEDIA_PATH, "fake_user.png") assert Image.open(new_avatar_path).size == config.AVATAR_SIZE os.remove(new_avatar_path) def test_update_calendar_privacy(profile_test_client): - new_privacy = { - 'privacy': "Public" - } + new_privacy = {"privacy": "Public"} # Get profile page and initialize database - profile = profile_test_client.get('/profile') + profile = profile_test_client.get("/profile") # Post new data - profile = profile_test_client.post( - '/profile/privacy', data=new_privacy) + profile = profile_test_client.post("/profile/privacy", data=new_privacy) assert profile.status_code == status.HTTP_302_FOUND # Get updated data - data = profile_test_client.get('/profile').content + data = profile_test_client.get("/profile").content assert b"Public" in data diff --git a/tests/test_register.py b/tests/test_register.py index 9394e2ba..a9b619ad 100644 --- a/tests/test_register.py +++ b/tests/test_register.py @@ -8,83 +8,191 @@ def test_register_route_ok(client): REGISTER_FORM_VALIDATORS = [ - ('ad', 'admin_user', 'password', 'password', 'example@mail.com', - 'description', b'Username must contain'), - ('admin', 'admin_user', 'pa', 'pa', 'example@mail.com', - 'description', b'Password must contain'), - ('admin', 'admin_user', 'password', 'wrong_password', 'example@mail.com', - 'description', b"match"), - ('admin', 'admin_user', 'password', 'password', 'invalid_mail', - 'description', b"Email address is not valid"), - ('', 'admin_user', 'password', 'password', 'example@mail.com', - 'description', b'Username field is required'), - ('admin', '', 'password', 'password', 'example@mail.com', - 'description', b'Full_name field is required'), - ('admin', 'admin_user', '', 'password', 'example@mail.com', - 'description', b'Password field is required'), - ('admin', 'admin_user', 'password', '', 'example@mail.com', - 'description', b'Confirm_password field is required'), - ('admin', 'admin_user', 'password', 'password', '', - 'description', b'Email field is required'), - ] - - -""" -Test all active pydantic validators -""" + ( + "ad", + "admin_user", + "password", + "password", + "example@mail.com", + "description", + b"Username must contain", + ), + ( + "admin", + "admin_user", + "pa", + "pa", + "example@mail.com", + "description", + b"Password must contain", + ), + ( + "admin", + "admin_user", + "password", + "wrong_password", + "example@mail.com", + "description", + b"match", + ), + ( + "admin", + "admin_user", + "password", + "password", + "invalid_mail", + "description", + b"Email address is not valid", + ), + ( + "", + "admin_user", + "password", + "password", + "example@mail.com", + "description", + b"Username field is required", + ), + ( + "admin", + "", + "password", + "password", + "example@mail.com", + "description", + b"Full_name field is required", + ), + ( + "admin", + "admin_user", + "", + "password", + "example@mail.com", + "description", + b"Password field is required", + ), + ( + "admin", + "admin_user", + "password", + "", + "example@mail.com", + "description", + b"Confirm_password field is required", + ), + ( + "admin", + "admin_user", + "password", + "password", + "", + "description", + b"Email field is required", + ), + ( + "@admin", + "admin_user", + "password", + "password", + "example@mail.com", + "description", + b"can not start with", + ), +] @pytest.mark.parametrize( "username, full_name, password, confirm_password, email, description," - + "expected_response", REGISTER_FORM_VALIDATORS) + + "expected_response", + REGISTER_FORM_VALIDATORS, +) def test_register_form_validators( - client, username, full_name, password, confirm_password, - email, description, expected_response): + client, + username, + full_name, + password, + confirm_password, + email, + description, + expected_response, +): data = { - 'username': username, 'full_name': full_name, - 'password': password, 'confirm_password': confirm_password, - 'email': email, 'description': description} - data = client.post('/register', data=data).content + "username": username, + "full_name": full_name, + "password": password, + "confirm_password": confirm_password, + "email": email, + "description": description, + } + data = client.post("/register", data=data).content assert expected_response in data -""" -Test successfully register user to database, after passing all validators -""" - - def test_register_successfull(session, security_test_client): - data = {'username': 'username', 'full_name': 'full_name', - 'password': 'password', 'confirm_password': 'password', - 'email': 'example@email.com', 'description': ""} - data = security_test_client.post('/register', data=data) + data = { + "username": "username", + "full_name": "full_name", + "password": "password", + "confirm_password": "password", + "email": "example@email.com", + "description": "", + } + """ + Test successfully register user to database, after passing all validators + """ + data = security_test_client.post("/register", data=data) assert data.status_code == HTTP_302_FOUND UNIQUE_FIELDS_ARE_TAKEN = [ - ('admin', 'admin_user', 'password', 'password', 'example_new@mail.com', - 'description', b'That username is already taken'), - ('admin_new', 'admin_user', 'password', 'password', 'example@mail.com', - 'description', b"Email already registered") - ] - - -""" -Test register a user fails due to unique database fields already in use -""" + ( + "admin", + "admin_user", + "password", + "password", + "example_new@mail.com", + "description", + b"That username is already taken", + ), + ( + "admin_new", + "admin_user", + "password", + "password", + "example@mail.com", + "description", + b"Email already registered", + ), +] @pytest.mark.parametrize( "username, full_name, password, confirm_password," - + "email, description, expected_response", UNIQUE_FIELDS_ARE_TAKEN) + + "email, description, expected_response", + UNIQUE_FIELDS_ARE_TAKEN, +) def test_unique_fields_are_taken( - session, security_test_client, username, - full_name, password, confirm_password, - email, description, expected_response): + session, + security_test_client, + username, + full_name, + password, + confirm_password, + email, + description, + expected_response, +): + """ + Test register a user fails due to unique database fields already in use + """ user_data = { - 'username': 'username', 'full_name': 'full_name', - 'password': 'password', 'confirm_password': 'password', - 'email': 'example@email.com', 'description': ""} - security_test_client.post('/register', data=user_data) - data = security_test_client.post('/register', data=user_data).content + "username": "username", + "full_name": "full_name", + "password": "password", + "confirm_password": "password", + "email": "example@email.com", + "description": "", + } + security_test_client.post("/register", data=user_data) + data = security_test_client.post("/register", data=user_data).content assert expected_response in data diff --git a/tests/test_reset_password.py b/tests/test_reset_password.py new file mode 100644 index 00000000..d49e363b --- /dev/null +++ b/tests/test_reset_password.py @@ -0,0 +1,203 @@ +import pytest +from starlette.status import HTTP_302_FOUND + +from app.internal.email import mail +from app.internal.security.ouath2 import create_jwt_token +from app.internal.security.schema import ForgotPassword + +REGISTER_DETAIL = { + "username": "correct_user", + "full_name": "full_name", + "password": "correct_password", + "confirm_password": "correct_password", + "email": "example@email.com", + "description": "", +} + +FORGOT_PASSWORD_BAD_DETAILS = [ + ("", ""), + ("", "example@email.com"), + ("correct_user", ""), + ("incorrect_user", "example@email.com"), + ("correct_user", "inncorrect@email.com"), +] + +FORGOT_PASSWORD_DETAILS = { + "username": "correct_user", + "email": "example@email.com", +} + +RESET_PASSWORD_BAD_CREDENTIALS = [ + ("", "", ""), + ("correct_user", "", "new_password"), + ("", "new_password", "new_password"), + ("correct_user", "new_password", ""), + ("wrong_user", "new_password", "new_password"), + ("correct_user", "", "new_password"), + ("correct_user", "new_password", ""), + ("correct_user", "new_password", "new_password1"), +] + +RESET_PASSWORD_DETAILS = { + "username": "correct_user", + "password": "new_password", + "confirm-password": "new_password", +} + + +def test_forgot_password_route_ok(security_test_client): + response = security_test_client.get( + security_test_client.app.url_path_for("forgot_password_form"), + ) + assert response.ok + + +@pytest.mark.parametrize("username, email", FORGOT_PASSWORD_BAD_DETAILS) +def test_forgot_password_bad_details( + session, + security_test_client, + username, + email, +): + security_test_client.post("/register", data=REGISTER_DETAIL) + data = {"username": username, "email": email} + res = security_test_client.post( + security_test_client.app.url_path_for("forgot_password"), + data=data, + ) + assert b"Please check your credentials" in res.content + + +def test_email_send(session, security_test_client, smtpd): + security_test_client.post("/register", data=REGISTER_DETAIL) + mail.config.SUPPRESS_SEND = 1 + mail.config.MAIL_SERVER = smtpd.hostname + mail.config.MAIL_PORT = smtpd.port + mail.config.USE_CREDENTIALS = False + mail.config.MAIL_TLS = False + with mail.record_messages() as outbox: + response = security_test_client.post( + security_test_client.app.url_path_for("forgot_password"), + data=FORGOT_PASSWORD_DETAILS, + ) + assert len(outbox) == 1 + assert b"Email for reseting password was sent" in response.content + assert "reset password" in outbox[0]["subject"] + + +def test_reset_password_GET_without_token(session, security_test_client): + res = security_test_client.get( + security_test_client.app.url_path_for("reset_password_form"), + ) + assert b"Verification token is missing" in res.content + + +def test_reset_password_GET_with_token(session, security_test_client): + user = ForgotPassword(**FORGOT_PASSWORD_DETAILS) + token = create_jwt_token(user, jwt_min_exp=15) + params = f"?email_verification_token={token}" + res = security_test_client.get( + security_test_client.app.url_path_for("reset_password_form") + + f"{params}", + ) + assert b"Please choose a new password" in res.content + + +@pytest.mark.parametrize( + "username, password, confirm_password", + RESET_PASSWORD_BAD_CREDENTIALS, +) +def test_reset_password_bad_details( + session, + security_test_client, + username, + password, + confirm_password, +): + security_test_client.post("/register", data=REGISTER_DETAIL) + user = ForgotPassword(**FORGOT_PASSWORD_DETAILS) + token = create_jwt_token(user, jwt_min_exp=15) + data = { + "username": username, + "password": password, + "confirm_password": confirm_password, + } + params = f"?email_verification_token={token}" + res = security_test_client.post( + security_test_client.app.url_path_for("reset_password") + f"{params}", + data=data, + ) + assert b"Please check your credentials" in res.content + + +def test_reset_password_successfully(session, security_test_client): + security_test_client.post("/register", data=REGISTER_DETAIL) + user = ForgotPassword(**FORGOT_PASSWORD_DETAILS) + token = create_jwt_token(user, jwt_min_exp=15) + params = f"?email_verification_token={token}" + res = security_test_client.post( + security_test_client.app.url_path_for("reset_password") + f"{params}", + data=RESET_PASSWORD_DETAILS, + ) + print(res.content) + assert res.status_code == HTTP_302_FOUND + + +def test_reset_password_expired_token(session, security_test_client): + security_test_client.post("/register", data=REGISTER_DETAIL) + user = ForgotPassword(**FORGOT_PASSWORD_DETAILS) + token = create_jwt_token(user, jwt_min_exp=-1) + params = f"?email_verification_token={token}" + res = security_test_client.post( + security_test_client.app.url_path_for("reset_password") + f"{params}", + data=RESET_PASSWORD_DETAILS, + ) + assert res.ok + + +LOGIN_DATA = {"username": "correct_user", "password": "correct_password"} + + +def test_is_logged_in_with_reset_password_token(session, security_test_client): + security_test_client.post("/register", data=REGISTER_DETAIL) + user = ForgotPassword(**FORGOT_PASSWORD_DETAILS) + token = create_jwt_token(user, jwt_min_exp=15) + params = f"?existing_jwt={token}" + security_test_client.post( + security_test_client.app.url_path_for("login") + f"{params}", + data=LOGIN_DATA, + ) + res = security_test_client.get( + security_test_client.app.url_path_for("is_logged_in"), + ) + assert b"Your token is not valid" in res.content + + +def test_is_manager_with_reset_password_token(session, security_test_client): + security_test_client.post("/register", data=REGISTER_DETAIL) + user = ForgotPassword(**FORGOT_PASSWORD_DETAILS) + token = create_jwt_token(user, jwt_min_exp=15) + params = f"?existing_jwt={token}" + security_test_client.post( + security_test_client.app.url_path_for("login") + f"{params}", + data=LOGIN_DATA, + ) + res = security_test_client.get( + security_test_client.app.url_path_for("is_manager"), + ) + assert b"have a permition to enter this page" in res.content + + +def test_current_user_with_reset_password_token(session, security_test_client): + security_test_client.post("/register", data=REGISTER_DETAIL) + user = ForgotPassword(**FORGOT_PASSWORD_DETAILS) + token = create_jwt_token(user, jwt_min_exp=15) + params = f"?existing_jwt={token}" + security_test_client.post( + security_test_client.app.url_path_for("login") + f"{params}", + data=LOGIN_DATA, + ) + res = security_test_client.get( + security_test_client.app.url_path_for("current_user"), + ) + assert b"Your token is not valid" in res.content diff --git a/tests/test_share_event.py b/tests/test_share_event.py index 67679b2c..e202597a 100644 --- a/tests/test_share_event.py +++ b/tests/test_share_event.py @@ -1,49 +1,68 @@ -from app.routers.invitation import get_all_invitations -from app.routers.share import (accept, send_email_invitation, - send_in_app_invitation, share, sort_emails) +from app.database.models import InvitationStatusEnum +from app.internal.notification import get_all_invitations +from app.routers.share import ( + send_email_invitation, + send_in_app_invitation, + share, + sort_emails, +) class TestShareEvent: - def test_share_success(self, user, event, session): - participants = [user.email] - share(event, participants, session) - invitations = get_all_invitations(db=session, recipient_id=user.id) + share(event, [user.email], session) + invitations = get_all_invitations( + session=session, + recipient_id=user.id, + ) assert invitations != [] def test_share_failure(self, event, session): participants = [event.owner.email] share(event, participants, session) invitations = get_all_invitations( - db=session, recipient_id=event.owner.id) + session=session, + recipient_id=event.owner.id, + ) assert invitations == [] def test_sort_emails(self, user, session): # the user is being imported # so he will be created data = [ - 'test.email@gmail.com', # registered user - 'not_logged_in@gmail.com', # unregistered user + "test.email@gmail.com", # registered user + "not_logged_in@gmail.com", # unregistered user ] sorted_data = sort_emails(data, session=session) assert sorted_data == { - 'registered': ['test.email@gmail.com'], - 'unregistered': ['not_logged_in@gmail.com'] + "registered": ["test.email@gmail.com"], + "unregistered": ["not_logged_in@gmail.com"], } def test_send_in_app_invitation_success( - self, user, sender, event, session + self, + user, + sender, + event, + session, ): assert send_in_app_invitation([user.email], event, session=session) - invitation = get_all_invitations(db=session, recipient=user)[0] + invitation = get_all_invitations(session=session, recipient=user)[0] assert invitation.event.owner == sender assert invitation.recipient == user session.delete(invitation) def test_send_in_app_invitation_failure( - self, user, sender, event, session): - assert (send_in_app_invitation( - [sender.email], event, session=session) is False) + self, + user, + sender, + event, + session, + ): + assert ( + send_in_app_invitation([sender.email], event, session=session) + is False + ) def test_send_email_invitation(self, user, event): send_email_invitation([user.email], event) @@ -51,5 +70,9 @@ def test_send_email_invitation(self, user, event): assert True def test_accept(self, invitation, session): - accept(invitation, session=session) - assert invitation.status == 'accepted' + invitation.accept(session=session) + assert invitation.status == InvitationStatusEnum.ACCEPTED + + def test_decline(self, invitation, session): + invitation.decline(session=session) + assert invitation.status == InvitationStatusEnum.DECLINED diff --git a/tests/test_shared_list.py b/tests/test_shared_list.py new file mode 100644 index 00000000..69a8eed7 --- /dev/null +++ b/tests/test_shared_list.py @@ -0,0 +1,105 @@ +from collections import namedtuple + +import pytest +from starlette.datastructures import MultiDict + +from app.routers.event import ( + _check_item_is_valid, + _create_shared_list, + extract_shared_list_from_data, +) + +TEST_SHARED_LISTS = [ + { + "Choco list": [ + { + "name": "Chocolate", + "amount": 2, + "participant": "Elior", + "notes": "Maltesers are awesome!", + }, + ], + }, + { + "TestList": [ + {"name": "Notebooks", "amount": 2.5, "participant": "Efrat"}, + ], + }, +] + +VALID_DATA = MultiDict( + title="test title", + start_date="2021-01-28", + start_time="12:59", + end_date="2021-01-28", + end_time="15:01", + location_type="vc_url", + location="https://us02web.zoom.us/j/875384596", + description="content", + color="red", + availability="True", + privacy="public", + invited="a@a.com,b@b.com", +) + +WRONG_DATA = MultiDict( + title="test title", + start_date="2021-01-28", + start_time="12:59", + end_date="2021-01-28", + end_time="15:01", + location_type="vc_url", + location="https://us02web.zoom.us/j/875384596", + description="content", + color="red", + availability="True", + privacy="public", + invited="a@a.com,b@b.com", +) + + +@pytest.mark.parametrize("test_list", TEST_SHARED_LISTS) +def test_create_shared(test_list, session): + """Check the shared list build function and + communication with db is working.""" + shared_list = _create_shared_list(test_list, session) + assert ( + shared_list.title == list(test_list.keys())[0] + or shared_list.title == "Shared List" + ) + assert shared_list.items[0].name == list(test_list.values())[0][0]["name"] + + +def test_create_shared_list(session): + """Test shared list with wrong data is not created.""" + assert _create_shared_list(MultiDict(), session) is None + + +def test_extract_shared_list_from_data_correct(session): + """Check the shared list extraction function is working.""" + VALID_DATA.setlist("item-name", ["Vanilla", "Strawberries", "Coffee"]) + VALID_DATA.setlist("item-amount", ["3", "2", "1"]) + VALID_DATA.setlist("item-participant", ["Elior", "Efrat", "Yam"]) + assert len(extract_shared_list_from_data(VALID_DATA, session).items) == 3 + + +def test_extract_shared_list_from_data_false_info(session): + """Test extraction of wrong data. + Check the system capability of ignoring + false/missing information.""" + assert ( + not len(extract_shared_list_from_data(WRONG_DATA, session).items) == 3 + ) + + +def test_extract_shared_list_from_data_error_handling(session): + """Test error handling during extraction.""" + WRONG_DATA.setlist("item-name", ["Vanilla", "Strawberries", "Coffee"]) + assert extract_shared_list_from_data(WRONG_DATA, session) + + +def test_check_item_is_valid(): + """Check if a wrong Item object is valid.""" + Item = namedtuple("Item", ["name", "amount", "participant"]) + item = Item(name="Bagel", amount="word", participant="John") + assert not _check_item_is_valid(item) diff --git a/tests/test_showevent.py b/tests/test_showevent.py new file mode 100644 index 00000000..afeefb81 --- /dev/null +++ b/tests/test_showevent.py @@ -0,0 +1,17 @@ +from app.internal.showevent import get_upcoming_events + + +class TestShowview: + def test_get_events_success(self, next_week_event, session): + events = get_upcoming_events( + session=session, + user_id=next_week_event.owner_id, + ) + assert list(events) == [next_week_event] + + def test_only_events_from_now_on(self, yesterday_event, session): + events = get_upcoming_events( + session=session, + user_id=yesterday_event.owner_id, + ) + assert list(events) == [] diff --git a/tests/test_statistics.py b/tests/test_statistics.py index 7ef52afb..707d571a 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -1,40 +1,66 @@ import datetime -from app.internal.statistics import get_statistics -from app.internal.statistics import INVALID_DATE_RANGE, INVALID_USER -from app.internal.statistics import SUCCESS_STATUS +from app.internal.notification import get_all_invitations +from app.internal.statistics import ( + INVALID_DATE_RANGE, + INVALID_USER, + SUCCESS_STATUS, + get_statistics, +) from app.routers.event import create_event -from app.routers.user import create_user -from app.routers.share import send_in_app_invitation, accept -from app.routers.invitation import get_all_invitations +from app.routers.register import _create_user +from app.routers.share import send_in_app_invitation def create_events_and_user_events(session, start, end, owner, invitations): for _ in range(1, 3): event = create_event( - db=session, title="title" + str(_), start=start, end=end, - owner_id=owner, location="location" + str(_)) + db=session, + title="title" + str(_), + start=start, + end=end, + owner_id=owner, + location="location" + str(_), + ) send_in_app_invitation(invitations, event, session) def create_data(session): - _ = [create_user("user" + str(_), "password" + str(_), - "email" + str(_) + '@' + 'gmail.com', "Hebrew", - session) for _ in range(1, 4)] + _ = [ + _create_user( + username="user" + str(_), + password="password" + str(_), + email="email" + str(_) + "@" + "gmail.com", + language_id="Hebrew", + session=session, + description="", + full_name="", + ) + for _ in range(1, 4) + ] start = datetime.datetime.now() + datetime.timedelta(hours=-1) end = datetime.datetime.now() + datetime.timedelta(hours=1) - create_events_and_user_events(session, start, end, 1, - ["email2@gmail.com", "email3@gmail.com"]) + create_events_and_user_events( + session, + start, + end, + 1, + ["email2@gmail.com", "email3@gmail.com"], + ) start = datetime.datetime.now() + datetime.timedelta(days=-1) end = datetime.datetime.now() + datetime.timedelta(days=-1, hours=2) - create_events_and_user_events(session, start, end, 1, - ["email2@gmail.com", "email3@gmail.com"]) + create_events_and_user_events( + session, + start, + end, + 1, + ["email2@gmail.com", "email3@gmail.com"], + ) start = datetime.datetime.now() + datetime.timedelta(hours=1) end = datetime.datetime.now() + datetime.timedelta(hours=1.5) - create_events_and_user_events(session, start, end, 2, - ["email3@gmail.com"]) + create_events_and_user_events(session, start, end, 2, ["email3@gmail.com"]) for invitation in get_all_invitations(session): - accept(invitation, session) + invitation.accept(session) def test_statistics_invalid_date_range(session): diff --git a/tests/test_translation.py b/tests/test_translation.py index c9e5720c..fe3abd85 100644 --- a/tests/test_translation.py +++ b/tests/test_translation.py @@ -1,11 +1,14 @@ +import pytest from fastapi import HTTPException from iso639 import languages -import pytest from textblob import TextBlob from app.internal.translation import ( - _detect_text_language, _get_language_code, _get_user_language, - translate_text, translate_text_for_user + _detect_text_language, + _get_language_code, + _get_user_language, + translate_text, + translate_text_for_user, ) TEXT = [ @@ -20,31 +23,46 @@ def test_translate_text_with_original_lang(text, target_lang, original_lang): answer = translate_text(text, target_lang, original_lang) assert "Hello my friend" == answer - assert TextBlob(text).detect_language() == languages.get( - name=original_lang.capitalize()).alpha2 - assert TextBlob(answer).detect_language() == languages.get( - name=target_lang.capitalize()).alpha2 + assert ( + TextBlob(text).detect_language() + == languages.get(name=original_lang.capitalize()).alpha2 + ) + assert ( + TextBlob(answer).detect_language() + == languages.get(name=target_lang.capitalize()).alpha2 + ) @pytest.mark.parametrize("text, target_lang, original_lang", TEXT) def test_translate_text_without_original_lang( - text, target_lang, original_lang): + text, + target_lang, + original_lang, +): answer = translate_text(text, target_lang) assert "Hello my friend" == answer - assert TextBlob(answer).detect_language() == languages.get( - name=target_lang.capitalize()).alpha2 + assert ( + TextBlob(answer).detect_language() + == languages.get(name=target_lang.capitalize()).alpha2 + ) @pytest.mark.parametrize("text, target_lang, original_lang", TEXT) def test_translate_text_with_identical_original_and_target_lang( - text, target_lang, original_lang): + text, + target_lang, + original_lang, +): answer = translate_text(text, original_lang, original_lang) assert answer == text @pytest.mark.parametrize("text, target_lang, original_lang", TEXT) def test_translate_text_with_same_original_target_lang_without_original_lang( - text, target_lang, original_lang): + text, + target_lang, + original_lang, +): answer = translate_text(text, original_lang) assert answer == text @@ -73,7 +91,12 @@ def test_get_user_language(user, session): @pytest.mark.parametrize("text, target_lang, original_lang", TEXT) def test_translate_text_for_valid_user( - text, target_lang, original_lang, session, user): + text, + target_lang, + original_lang, + session, + user, +): user_id = user.id answer = translate_text_for_user(text, session, user_id) assert answer == "Hello my friend" @@ -90,36 +113,51 @@ def test_detect_text_language(): assert answer == "en" -@pytest.mark.parametrize("text, target_lang, original_lang", - [("Hoghhflaff", "english", "spanish"), - ("Bdonfdjourr", "english", "french"), - ("Hafdllnnc", "english", "german"), - ]) +@pytest.mark.parametrize( + "text, target_lang, original_lang", + [ + ("Hoghhflaff", "english", "spanish"), + ("Bdonfdjourr", "english", "french"), + ("Hafdllnnc", "english", "german"), + ], +) def test_translate_text_with_text_impossible_to_translate( - text, target_lang, original_lang): + text, + target_lang, + original_lang, +): answer = translate_text(text, target_lang, original_lang) assert answer == text -@pytest.mark.parametrize("text, target_lang, original_lang", - [("@Здравствуй#мой$друг!", "english", "russian"), - ("@Hola#mi$amigo!", "english", "spanish"), - ("@Bonjour#mon$ami!", "english", "french"), - ("@Hallo#mein$Freund!", "english", "german"), - ]) +@pytest.mark.parametrize( + "text, target_lang, original_lang", + [ + ("@Здравствуй#мой$друг!", "english", "russian"), + ("@Hola#mi$amigo!", "english", "spanish"), + ("@Bonjour#mon$ami!", "english", "french"), + ("@Hallo#mein$Freund!", "english", "german"), + ], +) def test_translate_text_with_symbols(text, target_lang, original_lang): answer = translate_text(text, target_lang, original_lang) assert "@ Hello # my $ friend!" == answer -@pytest.mark.parametrize("text, target_lang, original_lang", - [("Привет мой друг", "italian", "spanish"), - ("Hola mi amigo", "english", "russian"), - ("Bonjour, mon ami", "russian", "german"), - ("Ciao amico", "french", "german") - ]) +@pytest.mark.parametrize( + "text, target_lang, original_lang", + [ + ("Привет мой друг", "italian", "spanish"), + ("Hola mi amigo", "english", "russian"), + ("Bonjour, mon ami", "russian", "german"), + ("Ciao amico", "french", "german"), + ], +) def test_translate_text_with_with_incorrect_lang( - text, target_lang, original_lang): + text, + target_lang, + original_lang, +): answer = translate_text(text, target_lang, original_lang) assert answer == text diff --git a/tests/test_user.py b/tests/test_user.py index 213e7589..b2dc545c 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -1,24 +1,26 @@ from datetime import datetime + import pytest -from app.routers.user import ( - create_user, does_user_exist, get_users -) +from app.database.models import Event, UserEvent from app.internal.user.availability import disable, enable from app.internal.utils import save -from app.database.models import UserEvent, Event from app.routers.event import create_event +from app.routers.register import _create_user +from app.routers.user import does_user_exist, get_users @pytest.fixture def user1(session): # a user made for testing who doesn't own any event. - user = create_user( + user = _create_user( session=session, - username='new_test_username', - password='new_test_password', - email='new2_test.email@gmail.com', - language_id='english' + username="new_test_username", + full_name="test_user", + password="new_test_password", + email="new2_test.email@gmail.com", + language_id="english", + description="", ) return user @@ -27,21 +29,23 @@ def user1(session): @pytest.fixture def user2(session): # a user made for testing who already owns an event. - user = create_user( + user = _create_user( session=session, - username='new_test_username2', - password='new_test_password2', - email='new_test_love231.email@gmail.com', - language_id='english' + username="new_test_username2", + full_name="test_user", + password="new_test_password2", + email="new_test_love231.email@gmail.com", + language_id="english", + description="", ) data = { - 'title': 'user2 event', - 'start': datetime.strptime('2021-05-05 14:59', '%Y-%m-%d %H:%M'), - 'end': datetime.strptime('2021-05-05 15:01', '%Y-%m-%d %H:%M'), - 'location': 'https://us02web.zoom.us/j/875384596', - 'content': 'content', - 'owner_id': user.id, + "title": "user2 event", + "start": datetime.strptime("2021-05-05 14:59", "%Y-%m-%d %H:%M"), + "end": datetime.strptime("2021-05-05 15:01", "%Y-%m-%d %H:%M"), + "location": "https://us02web.zoom.us/j/875384596", + "content": "content", + "owner_id": user.id, } create_event(session, **data) @@ -52,12 +56,12 @@ def user2(session): @pytest.fixture def event1(session, user2): data = { - 'title': 'test event title', - 'start': datetime.strptime('2021-05-05 14:59', '%Y-%m-%d %H:%M'), - 'end': datetime.strptime('2021-05-05 15:01', '%Y-%m-%d %H:%M'), - 'location': 'https://us02web.zoom.us/j/87538459r6', - 'content': 'content', - 'owner_id': user2.id, + "title": "test event title", + "start": datetime.strptime("2021-05-05 14:59", "%Y-%m-%d %H:%M"), + "end": datetime.strptime("2021-05-05 15:01", "%Y-%m-%d %H:%M"), + "location": "https://us02web.zoom.us/j/87538459r6", + "content": "content", + "owner_id": user2.id, } event = create_event(session, **data) @@ -68,41 +72,41 @@ def test_disabling_no_event_user(session, user1): # users without any future event can disable themselves disable(session, user1.id) assert user1.disabled - future_events = list(session.query(Event.id) - .join(UserEvent) - .filter( - UserEvent.user_id == user1.id, - Event.start > datetime - .now())) + future_events = list( + session.query(Event.id) + .join(UserEvent) + .filter(UserEvent.user_id == user1.id, Event.start > datetime.now()), + ) assert not future_events # making sure that after disabling the user he can be easily enabled. enable(session, user1.id) assert not user1.disabled -def test_disabling_user_participating_event( - session, user1, event1): +def test_disabling_user_participating_event(session, user1, event1): """making sure only users who only participate in events can disable and enable themselves.""" - association = UserEvent( - user_id=user1.id, - event_id=event1.id - ) + association = UserEvent(user_id=user1.id, event_id=event1.id) save(session, association) disable(session, user1.id) assert user1.disabled - future_events = list(session.query(Event.id) - .join(UserEvent) - .filter( - UserEvent.user_id == user1.id, - Event.start > datetime.now(), - Event.owner_id == user1.id)) + future_events = list( + session.query(Event.id) + .join(UserEvent) + .filter( + UserEvent.user_id == user1.id, + Event.start > datetime.now(), + Event.owner_id == user1.id, + ), + ) assert not future_events enable(session, user1.id) assert not user1.disabled - deleted_user_event_connection = session.query(UserEvent).filter( - UserEvent.user_id == user1.id, - UserEvent.event_id == event1.id).first() + deleted_user_event_connection = ( + session.query(UserEvent) + .filter(UserEvent.user_id == user1.id, UserEvent.event_id == event1.id) + .first() + ) session.delete(deleted_user_event_connection) @@ -113,18 +117,19 @@ def test_disabling_event_owning_user(session, user2): class TestUser: - def test_create_user(self, session): - user = create_user( + user = _create_user( session=session, - username='new_test_username', - password='new_test_password', - email='new_test.email@gmail.com', - language_id=1 + username="new_test_username", + password="new_test_password", + email="new_test.email@gmail.com", + language_id=1, + description="", + full_name="test_user", ) - assert user.username == 'new_test_username' - assert user.password == 'new_test_password' - assert user.email == 'new_test.email@gmail.com' + assert user.username == "new_test_username" + assert user.password == "new_test_password" + assert user.email == "new_test.email@gmail.com" assert user.language_id == 1 session.delete(user) session.commit() @@ -135,7 +140,7 @@ def test_get_users_success(self, user, session): assert get_users(email=user.email, session=session) == [user] def test_get_users_failure(self, session, user): - assert get_users(username='wrong username', session=session) == [] + assert get_users(username="wrong username", session=session) == [] assert get_users(wrong_param=user.username, session=session) == [] def test_does_user_exist_success(self, user, session): @@ -144,8 +149,8 @@ def test_does_user_exist_success(self, user, session): assert does_user_exist(email=user.email, session=session) def test_does_user_exist_failure(self, session): - assert not does_user_exist(username='wrong username', session=session) + assert not does_user_exist(username="wrong username", session=session) assert not does_user_exist(session=session) def test_repr(self, user): - assert user.__repr__() == f'' + assert user.__repr__() == f"" diff --git a/tests/test_utils.py b/tests/test_utils.py index ab0a080f..79d07714 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,25 +1,36 @@ +from datetime import date, time + +import pytest from sqlalchemy.orm import Session from app.database.models import User from app.internal import utils +TIMES = [ + ("2021-01-14", date(2021, 1, 14)), + ("13:30", time(13, 30)), + ("15:42:00", time(15, 42)), + ("15", None), + ("2021-01", None), + ("15:42:00.5", None), +] -class TestUtils: +class TestUtils: def test_save_success(self, user: User, session: Session) -> None: - user.username = 'edit_username' + user.username = "edit_username" assert utils.save(session, user) def test_save_failure(self, session: Session) -> None: - user = 'not a user instance' + user = "not a user instance" assert not utils.save(session, user) def test_create_model(self, session: Session) -> None: assert session.query(User).first() is None info = { - 'username': 'test', - 'email': 'test@test.com', - 'password': 'test1234' + "username": "test", + "email": "test@test.com", + "password": "test1234", } utils.create_model(session, User, **info) assert session.query(User).first() @@ -38,3 +49,11 @@ def test_get_current_user(self, session: Session) -> None: def test_get_user(self, user: User, session: Session) -> None: assert utils.get_user(session, user.id) == user assert utils.get_user(session, 2) is None + + @pytest.mark.parametrize("string, formatted_time", TIMES) + def test_get_time_from_string( + self, + string: str, + formatted_time: time, + ) -> None: + assert utils.get_time_from_string(string) == formatted_time diff --git a/tests/test_weekview.py b/tests/test_weekview.py index 7980b38b..bbd1b46e 100644 --- a/tests/test_weekview.py +++ b/tests/test_weekview.py @@ -1,56 +1,97 @@ -from bs4 import BeautifulSoup import pytest +from bs4 import BeautifulSoup +from app.database.models import User from app.routers.event import create_event from app.routers.weekview import get_week_dates +REGISTER_DETAIL = { + "username": "correct_user", + "full_name": "full_name", + "password": "correct_password", + "confirm_password": "correct_password", + "email": "example@email.com", + "description": "", +} + +LOGIN_DATA = {"username": "correct_user", "password": "correct_password"} + def create_weekview_event(events, session, user): for event in events: create_event( db=session, - title='test', + title="test", start=event.start, end=event.end, owner_id=user.id, - color=event.color + color=event.color, ) def test_get_week_dates(weekdays, sunday): week_dates = list(get_week_dates(sunday)) for i in range(6): - assert week_dates[i].strftime('%A') == weekdays[i] + assert week_dates[i].strftime("%A") == weekdays[i] -def test_weekview_day_names(session, user, client, weekdays): - response = client.get("/week/2021-1-3") - soup = BeautifulSoup(response.content, 'html.parser') - day_divs = soup.find_all("div", {"class": 'day-name'}) +def test_weekview_day_names(session, user, weekview_test_client, weekdays): + weekview_test_client.post( + weekview_test_client.app.url_path_for("register"), + data=REGISTER_DETAIL, + ) + weekview_test_client.post( + weekview_test_client.app.url_path_for("login"), + data=LOGIN_DATA, + ) + response = weekview_test_client.get("/week/2021-1-3") + soup = BeautifulSoup(response.content, "html.parser") + day_divs = soup.find_all("div", {"class": "day-name"}) for i in range(6): assert weekdays[i][:3].upper() in str(day_divs[i]) -def test_weekview_day_dates(session, user, client, sunday): - response = client.get("/week/2021-1-3") - soup = BeautifulSoup(response.content, 'html.parser') - day_divs = soup.find_all("span", {"class": 'date-nums'}) +def test_weekview_day_dates(session, weekview_test_client, sunday): + weekview_test_client.post( + weekview_test_client.app.url_path_for("register"), + data=REGISTER_DETAIL, + ) + weekview_test_client.post( + weekview_test_client.app.url_path_for("login"), + data=LOGIN_DATA, + ) + response = weekview_test_client.get("/week/2021-1-3") + soup = BeautifulSoup(response.content, "html.parser") + day_divs = soup.find_all("span", {"class": "date-nums"}) week_dates = list(get_week_dates(sunday)) for i in range(6): - time_str = f'{week_dates[i].day} / {week_dates[i].month}' + time_str = f"{week_dates[i].day} / {week_dates[i].month}" assert time_str in day_divs[i] @pytest.mark.parametrize( "date,event", - [("2021-1-31", 'event1'), - ("2021-1-31", 'event2'), - ("2021-2-3", 'event3')] + [("2021-1-31", "event1"), ("2021-1-31", "event2"), ("2021-2-3", "event3")], ) def test_weekview_html_events( - event1, event2, event3, session, user, client, date, event + event1, + event2, + event3, + session, + weekview_test_client, + date, + event, ): + weekview_test_client.post( + weekview_test_client.app.url_path_for("register"), + data=REGISTER_DETAIL, + ) + weekview_test_client.post( + weekview_test_client.app.url_path_for("login"), + data=LOGIN_DATA, + ) + user = session.query(User).filter_by(username="correct_user").first() create_weekview_event([event1, event2, event3], session=session, user=user) - response = client.get(f"/week/{date}") - soup = BeautifulSoup(response.content, 'html.parser') + response = weekview_test_client.get(f"/week/{date}") + soup = BeautifulSoup(response.content, "html.parser") assert event in str(soup.find("div", {"id": event})) diff --git a/tests/utils.py b/tests/utils.py deleted file mode 100644 index 58ffdbd0..00000000 --- a/tests/utils.py +++ /dev/null @@ -1,13 +0,0 @@ -from sqlalchemy.orm import Session - - -def create_model(session: Session, model_class, **kw): - instance = model_class(**kw) - session.add(instance) - session.commit() - return instance - - -def delete_instance(session: Session, instance): - session.delete(instance) - session.commit() From 08b0a2e067ba9627d03a45c561e9ca1a8c1cca2d Mon Sep 17 00:00:00 2001 From: Yair Engel Date: Fri, 26 Feb 2021 00:40:07 +0200 Subject: [PATCH 11/11] Resolve conflicts --- app/templates/profile.html | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/app/templates/profile.html b/app/templates/profile.html index e6336c5c..1ca1fc06 100644 --- a/app/templates/profile.html +++ b/app/templates/profile.html @@ -2,25 +2,17 @@ {% include "partials/calendar/event/text_editor_partial_head.html" %} {% block content %} -
-
- - {% include 'partials/user_profile/sidebar_left.html' %} +
+
+ + {% include 'partials/user_profile/sidebar_left.html' %} - - {% include 'partials/user_profile/middle_content.html' %} + + {% include 'partials/user_profile/middle_content.html' %} - - {% include 'partials/user_profile/sidebar_right.html' %} - -<<<<<<< HEAD -
+ + {% include 'partials/user_profile/sidebar_right.html' %}
- {% include "partials/calendar/event/text_editor_partial_body.html" %} -=======
-
- -{% include "partials/calendar/event/text_editor_partial_body.html" %} ->>>>>>> develop + {% include "partials/calendar/event/text_editor_partial_body.html" %} {% endblock content %}