From 8928b17c0e728449bb36cc7cb2293346753959f9 Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Tue, 12 Jan 2021 15:40:11 +0200 Subject: [PATCH 001/108] update ORM and added export for an event --- app/config.py | 0 app/utils/__init__.py | 0 app/utils/config.py | 0 app/utils/export.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 app/config.py create mode 100644 app/utils/__init__.py create mode 100644 app/utils/config.py create mode 100644 app/utils/export.py diff --git a/app/config.py b/app/config.py new file mode 100644 index 00000000..e69de29b diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/utils/config.py b/app/utils/config.py new file mode 100644 index 00000000..e69de29b diff --git a/app/utils/export.py b/app/utils/export.py new file mode 100644 index 00000000..e69de29b From 79874a75ea7f70b38e4f724ef0d34de87cbda657 Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Tue, 12 Jan 2021 15:44:57 +0200 Subject: [PATCH 002/108] update ORM and added export for an event --- .gitignore | 3 + .idea/.gitignore | 3 + .idea/calendar.iml | 11 +++ .idea/inspectionProfiles/Project_Default.xml | 12 +++ .../inspectionProfiles/profiles_settings.xml | 6 ++ .idea/misc.xml | 4 + .idea/modules.xml | 8 ++ .idea/vcs.xml | 6 ++ app/config.py | 1 + app/database/models.py | 37 +++++++-- app/utils/config.py | 2 + app/utils/export.py | 82 +++++++++++++++++++ requirements.txt | 1 + 13 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/calendar.iml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml diff --git a/.gitignore b/.gitignore index b6e47617..fce1a58f 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,9 @@ ipython_config.py # pyenv .python-version +# pycharm +.idea/ + # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 00000000..26d33521 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/calendar.iml b/.idea/calendar.iml new file mode 100644 index 00000000..1347654c --- /dev/null +++ b/.idea/calendar.iml @@ -0,0 +1,11 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 00000000..1d812fac --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,12 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 00000000..105ce2da --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..8161a60d --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..f283a1e7 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..94a25f7f --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/config.py b/app/config.py index e69de29b..588c4985 100644 --- a/app/config.py +++ b/app/config.py @@ -0,0 +1 @@ +DOMAIN = 'Our-Domain' diff --git a/app/database/models.py b/app/database/models.py index 1b3bf771..1b8109cc 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -4,26 +4,47 @@ from .database import Base +class UserEvent(Base): + __tablename__ = "user_event" + + user_id = Column('user_id', Integer, ForeignKey('users.id'), primary_key=True) + event_id = Column('event_id', Integer, ForeignKey('events.id'), primary_key=True) + + events = relationship("Event", back_populates="participants") + participants = relationship("User", back_populates="events") + + def __repr__(self): + return f'' + + class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True, index=True) - username = Column(String, unique=True) - email = Column(String, unique=True) - password = Column(String) + username = Column(String, unique=True, nullable=False) + email = Column(String, unique=True, nullable=False) + password = Column(String, nullable=False) is_active = Column(Boolean, default=True) - events = relationship( - "Event", cascade="all, delete", back_populates="owner") + events = relationship("UserEvent", back_populates="participants") + + def __repr__(self): + return f'' class Event(Base): __tablename__ = "events" id = Column(Integer, primary_key=True, index=True) - title = Column(String) + title = Column(String, nullable=False) + start = Column(DateTime, nullable=False) + end = Column(DateTime, nullable=False) content = Column(String) - date = Column(DateTime) + location = Column(String) + + owner = relationship("User") owner_id = Column(Integer, ForeignKey("users.id")) + participants = relationship("UserEvent", back_populates="events") - owner = relationship("User", back_populates="events") + def __repr__(self): + return f'' diff --git a/app/utils/config.py b/app/utils/config.py index e69de29b..e7941072 100644 --- a/app/utils/config.py +++ b/app/utils/config.py @@ -0,0 +1,2 @@ +ICS_VERSION = '2.0' +PRODUCT_ID = '-//Our product id//' diff --git a/app/utils/export.py b/app/utils/export.py index e69de29b..ff94a9a1 100644 --- a/app/utils/export.py +++ b/app/utils/export.py @@ -0,0 +1,82 @@ +from datetime import datetime +from typing import List + +from icalendar import Calendar, Event, vCalAddress + +from .config import ICS_VERSION, PRODUCT_ID + +from app.config import DOMAIN +from app.database.models import Event as UserEvent + + +def generate_id(event: UserEvent) -> bytes: + """Creates an unique id from: + - event id + - event start time + - event end time + - our domain. + """ + + return ( + str(event.id) + + event.start.strftime('%Y%m%d') + + event.end.strftime('%Y%m%d') + + f'@{DOMAIN}' + ).encode() + + +def create_ical(): + """Creates an ical calendar, + and adds the required information""" + + cal = Calendar() + cal.add('version', ICS_VERSION) + cal.add('prodid', PRODUCT_ID) + + return cal + + +def create_event(title, user_event): + """Creates an ical event, + and adds the event information""" + + ievent = Event() + data = [ + ('organizer', vCalAddress(user_event.owner.email)), + ('uid', generate_id(user_event)), + ('dtstart', user_event.start), + ('dtstamp', datetime.now()), + ('dtend', user_event.end), + ('summary', title), + ] + + for pram in data: + ievent.add(*pram) + + return ievent + + +def add_attendees(ievent, attendees: list): + """Adds attendees for the event.""" + + for attendee in attendees: + ievent.add( + 'attendee', + vCalAddress(f'MAILTO:{attendee}'), + encode=0 + ) + + return ievent + + +def event_to_ical(user_event: UserEvent, attendees: List[str]) -> bytes: + """Returns an ical event, + given an "UserEvent" instance + and a list of email""" + + ical = create_ical() + ievent = create_event('Important meeting', user_event) + ievent = add_attendees(ievent, attendees) + ical.add_component(ievent) + + return ical.to_ical() diff --git a/requirements.txt b/requirements.txt index de91534a..ea88c21d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,3 +25,4 @@ typing-extensions==3.7.4.3 uvicorn==0.13.3 wsproto==1.0.0 zipp==3.4.0 +icalendar==4.0.7 From 8f4e690b93e726658cc8491c7b67b10f087e5adb Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Wed, 13 Jan 2021 17:49:36 +0200 Subject: [PATCH 003/108] Added in app shareable events --- .idea/.gitignore | 3 - .idea/calendar.iml | 11 --- .idea/inspectionProfiles/Project_Default.xml | 12 --- .../inspectionProfiles/profiles_settings.xml | 6 -- .idea/misc.xml | 4 - .idea/modules.xml | 8 -- .idea/vcs.xml | 6 -- app/config.py | 3 + app/database/models.py | 29 ++++++- app/features/__init__.py | 0 app/features/share_event.py | 80 +++++++++++++++++++ app/main.py | 30 ++++++- app/templates/requests.html | 25 ++++++ app/utils/event.py | 0 app/utils/export.py | 13 ++- app/utils/invitation.py | 23 ++++++ app/utils/user.py | 42 ++++++++++ app/utils/utils.py | 6 ++ 18 files changed, 238 insertions(+), 63 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/calendar.iml delete mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/inspectionProfiles/profiles_settings.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/vcs.xml create mode 100644 app/features/__init__.py create mode 100644 app/features/share_event.py create mode 100644 app/templates/requests.html create mode 100644 app/utils/event.py create mode 100644 app/utils/invitation.py create mode 100644 app/utils/user.py create mode 100644 app/utils/utils.py diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d33521..00000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/calendar.iml b/.idea/calendar.iml deleted file mode 100644 index 1347654c..00000000 --- a/.idea/calendar.iml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 1d812fac..00000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2da..00000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 8161a60d..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index f283a1e7..00000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7f..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/config.py b/app/config.py index 588c4985..00fe4b43 100644 --- a/app/config.py +++ b/app/config.py @@ -1 +1,4 @@ +from app.database.database import SessionLocal + DOMAIN = 'Our-Domain' +session = SessionLocal() diff --git a/app/database/models.py b/app/database/models.py index 1b8109cc..35633ea1 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1,14 +1,17 @@ +from datetime import datetime + from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String from sqlalchemy.orm import relationship -from .database import Base +from app.database.database import Base class UserEvent(Base): __tablename__ = "user_event" - user_id = Column('user_id', Integer, ForeignKey('users.id'), primary_key=True) - event_id = Column('event_id', Integer, ForeignKey('events.id'), primary_key=True) + id = Column(Integer, primary_key=True, index=True) + user_id = Column('user_id', Integer, ForeignKey('users.id')) + event_id = Column('event_id', Integer, ForeignKey('events.id')) events = relationship("Event", back_populates="participants") participants = relationship("User", back_populates="events") @@ -48,3 +51,23 @@ class Event(Base): def __repr__(self): return f'' + + +class Invitation(Base): + __tablename__ = "invitations" + + id = Column(Integer, primary_key=True, index=True) + status = Column(String, nullable=False, default="unread") + 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 __repr__(self): + return ( + f'' + ) diff --git a/app/features/__init__.py b/app/features/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/features/share_event.py b/app/features/share_event.py new file mode 100644 index 00000000..0a1d6bda --- /dev/null +++ b/app/features/share_event.py @@ -0,0 +1,80 @@ +from typing import List, Dict, Union + +from app.config import session +from app.database.models import Event, Invitation, UserEvent +from app.utils.export import event_to_ical +from app.utils.user import dose_user_exist, get_users +from app.utils.utils import save + + +def sort_emails(emails: List[str]) -> Dict[str, List[str]]: + """Sorts emails to registered + and unregistered users.""" + + emails_dict = {'registered': [], 'unregistered': []} + for email in emails: + + if dose_user_exist(email=email): + emails_dict['registered'] += [email] + else: + emails_dict['unregistered'] += [email] + + return emails_dict + + +def send_email_invitation( + participants: List[str], + event: Event +) -> Union[bool, None]: + """Sends an email with an invitation.""" + + ical_invitation = event_to_ical(event, participants) + for participant in participants: + # sends an email + pass + return True + + +def send_in_app_invitation( + participants: List[str], + event: Event +) -> Union[bool, None]: + """Sends an in-app invitation for registered users.""" + + for participant in participants: + # email is unique + recipient = get_users(email=participant)[0] + + if recipient.id != event.owner.id: + session.add(Invitation(recipient=recipient, event=event)) + + else: + # if user tries to send himself + session.rollback() + return None + + session.commit() + return True + + +def accept(invitation: Invitation) -> None: + """Accepts an invitation by creating an + UserEvent association that represents + participantship at the event.""" + + association = UserEvent( + participants=invitation.recipient, + events=invitation.event + ) + invitation.status = 'accepted' + save(invitation) + save(association) + + +def share(event: Event, participants: List[str]) -> None: + """Sends invitations to all event participants.""" + + registered, unregistered = sort_emails(participants).values() + + send_in_app_invitation(registered, event) + send_email_invitation(unregistered, event) diff --git a/app/main.py b/app/main.py index a8e79cbd..83f07cd1 100644 --- a/app/main.py +++ b/app/main.py @@ -1,14 +1,21 @@ -from fastapi import FastAPI, Request +from fastapi import FastAPI, Form, Request +from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from starlette.status import HTTP_302_FOUND -app = FastAPI() +from app.database.database import Base, engine +from app.features.share_event import accept +from app.utils.invitation import get_all_invitations, get_invitation_by_id -app.mount("/static", StaticFiles(directory="static"), name="static") +app = FastAPI() +app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") +Base.metadata.create_all(bind=engine) + @app.get("/") def home(request: Request): @@ -16,3 +23,20 @@ def home(request: Request): "request": request, "message": "Hello, World!" }) + + +@app.get("/invitations") +def view_invitations(request: Request): + return templates.TemplateResponse("requests.html", { + "request": request, + # recipient_id should be the current user + "invitations": get_all_invitations(), + "message": "Hello, World!" + }) + + +@app.post("/invitations") +async def accept_invitations(invite_id: int = Form(...)): + invitation = get_invitation_by_id(invite_id) + accept(invitation) + return RedirectResponse("/invitations", status_code=HTTP_302_FOUND) diff --git a/app/templates/requests.html b/app/templates/requests.html new file mode 100644 index 00000000..18f8fd70 --- /dev/null +++ b/app/templates/requests.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + + +{% block content %} + +
+

{{message}}

+
+ + {% if invitations %} +
+ {% for i in invitations %} +
+ {{ i.event.owner.username }} - {{ i.event.title }} ({{ i.event.start }}) + + +
+ {% endfor %} +
+ {% else %} + You don't have any invitations. + {% endif %} + + +{% endblock %} \ No newline at end of file diff --git a/app/utils/event.py b/app/utils/event.py new file mode 100644 index 00000000..e69de29b diff --git a/app/utils/export.py b/app/utils/export.py index ff94a9a1..0bf5b393 100644 --- a/app/utils/export.py +++ b/app/utils/export.py @@ -3,10 +3,9 @@ from icalendar import Calendar, Event, vCalAddress -from .config import ICS_VERSION, PRODUCT_ID - from app.config import DOMAIN from app.database.models import Event as UserEvent +from app.utils.config import ICS_VERSION, PRODUCT_ID def generate_id(event: UserEvent) -> bytes: @@ -25,7 +24,7 @@ def generate_id(event: UserEvent) -> bytes: ).encode() -def create_ical(): +def create_ical_calendar(): """Creates an ical calendar, and adds the required information""" @@ -36,7 +35,7 @@ def create_ical(): return cal -def create_event(title, user_event): +def create_ical_event(user_event): """Creates an ical event, and adds the event information""" @@ -47,7 +46,7 @@ def create_event(title, user_event): ('dtstart', user_event.start), ('dtstamp', datetime.now()), ('dtend', user_event.end), - ('summary', title), + ('summary', user_event.title), ] for pram in data: @@ -74,8 +73,8 @@ def event_to_ical(user_event: UserEvent, attendees: List[str]) -> bytes: given an "UserEvent" instance and a list of email""" - ical = create_ical() - ievent = create_event('Important meeting', user_event) + ical = create_ical_calendar() + ievent = create_ical_event(user_event) ievent = add_attendees(ievent, attendees) ical.add_component(ievent) diff --git a/app/utils/invitation.py b/app/utils/invitation.py new file mode 100644 index 00000000..ceca6886 --- /dev/null +++ b/app/utils/invitation.py @@ -0,0 +1,23 @@ +from typing import List + +from sqlalchemy.exc import SQLAlchemyError + +from app.config import session +from app.database.models import Invitation + + +def get_all_invitations(**parm) -> List[Invitation]: + """Returns all invitations filter by parm.""" + + try: + invitations = list(session.query(Invitation).filter_by(**parm)) + except SQLAlchemyError: + return [] + else: + return invitations + + +def get_invitation_by_id(invitation_id: int) -> Invitation: + """Returns a invitation by an id.""" + + return session.query(Invitation).filter_by(id=invitation_id).first() diff --git a/app/utils/user.py b/app/utils/user.py new file mode 100644 index 00000000..e2df735d --- /dev/null +++ b/app/utils/user.py @@ -0,0 +1,42 @@ +from sqlalchemy.exc import SQLAlchemyError + +from app.config import session +from app.database.models import User +from app.utils.utils import save + + +def create_user(username, password, email) -> User: + """Creates and saves a new user.""" + + user = User( + username=username, + password=password, + email=email + ) + save(user) + return user + + +def get_users(**parm): + """Returns all users filter by parm.""" + + try: + users = list(session.query(User).filter_by(**parm)) + except SQLAlchemyError: + return [] + else: + return users + + +def dose_user_exist(user_id=None, username=None, email=None): + """Returns if user exists. + function can receive one of + the there parameters""" + + if user_id: + return len(get_users(id=user_id)) == 1 + if username: + return len(get_users(username=username)) == 1 + if email: + return len(get_users(email=email)) == 1 + return False diff --git a/app/utils/utils.py b/app/utils/utils.py new file mode 100644 index 00000000..13e73948 --- /dev/null +++ b/app/utils/utils.py @@ -0,0 +1,6 @@ +from app.config import session + + +def save(item) -> None: + session.add(item) + session.commit() From 8fb1035ad3b89a155df3450882b91d2b4b7dbd9b Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Wed, 13 Jan 2021 21:28:01 +0200 Subject: [PATCH 004/108] docs: add type annotation and fix folder structure --- app/features/__init__.py | 0 app/{features => internal}/share_event.py | 5 ++--- 2 files changed, 2 insertions(+), 3 deletions(-) delete mode 100644 app/features/__init__.py rename app/{features => internal}/share_event.py (96%) diff --git a/app/features/__init__.py b/app/features/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/app/features/share_event.py b/app/internal/share_event.py similarity index 96% rename from app/features/share_event.py rename to app/internal/share_event.py index 0a1d6bda..da106f06 100644 --- a/app/features/share_event.py +++ b/app/internal/share_event.py @@ -25,14 +25,13 @@ def sort_emails(emails: List[str]) -> Dict[str, List[str]]: def send_email_invitation( participants: List[str], event: Event -) -> Union[bool, None]: +): """Sends an email with an invitation.""" ical_invitation = event_to_ical(event, participants) for participant in participants: # sends an email pass - return True def send_in_app_invitation( @@ -49,7 +48,7 @@ def send_in_app_invitation( session.add(Invitation(recipient=recipient, event=event)) else: - # if user tries to send himself + # if user tries to send to himself. session.rollback() return None From 149b5719f479407fdf6fa08e1dc8a98d537a079c Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Wed, 13 Jan 2021 21:28:37 +0200 Subject: [PATCH 005/108] docs: add type annotation and fix folder structure --- app/internal/share_event.py | 2 +- app/main.py | 13 +++++++------ app/templates/requests.html | 8 ++++---- app/utils/export.py | 4 ++-- app/utils/invitation.py | 6 +++--- app/utils/user.py | 6 +++--- 6 files changed, 20 insertions(+), 19 deletions(-) diff --git a/app/internal/share_event.py b/app/internal/share_event.py index da106f06..52ae11c4 100644 --- a/app/internal/share_event.py +++ b/app/internal/share_event.py @@ -11,7 +11,7 @@ def sort_emails(emails: List[str]) -> Dict[str, List[str]]: """Sorts emails to registered and unregistered users.""" - emails_dict = {'registered': [], 'unregistered': []} + emails_dict = {'registered': [], 'unregistered': []} # type: ignore for email in emails: if dose_user_exist(email=email): diff --git a/app/main.py b/app/main.py index 2bb0e4e0..6af74e24 100644 --- a/app/main.py +++ b/app/main.py @@ -6,7 +6,7 @@ from starlette.status import HTTP_302_FOUND from app.database.database import Base, engine -from app.features.share_event import accept +from app.internal.share_event import accept from app.utils.invitation import get_all_invitations, get_invitation_by_id @@ -25,12 +25,13 @@ def home(request: Request): }) -<<<<<<< HEAD @app.get("/invitations") def view_invitations(request: Request): return templates.TemplateResponse("requests.html", { "request": request, # 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(), "message": "Hello, World!" }) @@ -41,17 +42,17 @@ async def accept_invitations(invite_id: int = Form(...)): invitation = get_invitation_by_id(invite_id) accept(invitation) return RedirectResponse("/invitations", status_code=HTTP_302_FOUND) -======= + + @app.get("/profile") def profile(request: Request): # Get relevant data from database - upcouming_events = range(5) + upcoming_events = range(5) current_username = "Chuck Norris" return templates.TemplateResponse("profile.html", { "request": request, "username": current_username, - "events": upcouming_events + "events": upcoming_events }) ->>>>>>> 91696f700debbd35a250ccd6604a77da16d12b30 diff --git a/app/templates/requests.html b/app/templates/requests.html index 18f8fd70..3afca239 100644 --- a/app/templates/requests.html +++ b/app/templates/requests.html @@ -8,15 +8,15 @@

{{message}}

{% if invitations %} -
+
{% for i in invitations %} -
+ {{ i.event.owner.username }} - {{ i.event.title }} ({{ i.event.start }}) -
+ {% endfor %} - +
{% else %} You don't have any invitations. {% endif %} diff --git a/app/utils/export.py b/app/utils/export.py index 0bf5b393..884ef5e0 100644 --- a/app/utils/export.py +++ b/app/utils/export.py @@ -49,8 +49,8 @@ def create_ical_event(user_event): ('summary', user_event.title), ] - for pram in data: - ievent.add(*pram) + for param in data: + ievent.add(*param) return ievent diff --git a/app/utils/invitation.py b/app/utils/invitation.py index ceca6886..6a097164 100644 --- a/app/utils/invitation.py +++ b/app/utils/invitation.py @@ -6,11 +6,11 @@ from app.database.models import Invitation -def get_all_invitations(**parm) -> List[Invitation]: - """Returns all invitations filter by parm.""" +def get_all_invitations(**param) -> List[Invitation]: + """Returns all invitations filter by param.""" try: - invitations = list(session.query(Invitation).filter_by(**parm)) + invitations = list(session.query(Invitation).filter_by(**param)) except SQLAlchemyError: return [] else: diff --git a/app/utils/user.py b/app/utils/user.py index e2df735d..dc9c7b97 100644 --- a/app/utils/user.py +++ b/app/utils/user.py @@ -17,11 +17,11 @@ def create_user(username, password, email) -> User: return user -def get_users(**parm): - """Returns all users filter by parm.""" +def get_users(**param): + """Returns all users filter by param.""" try: - users = list(session.query(User).filter_by(**parm)) + users = list(session.query(User).filter_by(**param)) except SQLAlchemyError: return [] else: From be96e5d6177fcc0b27dc46a69a208c3b46fc0e57 Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Thu, 14 Jan 2021 11:28:35 +0200 Subject: [PATCH 006/108] docs: fix documentation --- app/config.py | 3 +++ app/internal/share_event.py | 23 +++++++++++------------ app/utils/config.py | 2 -- app/utils/export.py | 10 ++-------- app/utils/invitation.py | 7 ++++--- app/utils/user.py | 9 ++++----- app/utils/utils.py | 13 ++++++++++--- 7 files changed, 34 insertions(+), 33 deletions(-) delete mode 100644 app/utils/config.py diff --git a/app/config.py b/app/config.py index 00fe4b43..5b9c728a 100644 --- a/app/config.py +++ b/app/config.py @@ -1,4 +1,7 @@ from app.database.database import SessionLocal +ICS_VERSION = '2.0' DOMAIN = 'Our-Domain' +PRODUCT_ID = '-//Our product id//' + session = SessionLocal() diff --git a/app/internal/share_event.py b/app/internal/share_event.py index 52ae11c4..d103c35e 100644 --- a/app/internal/share_event.py +++ b/app/internal/share_event.py @@ -3,28 +3,27 @@ from app.config import session from app.database.models import Event, Invitation, UserEvent from app.utils.export import event_to_ical -from app.utils.user import dose_user_exist, get_users +from app.utils.user import does_user_exist, get_users from app.utils.utils import save -def sort_emails(emails: List[str]) -> Dict[str, List[str]]: - """Sorts emails to registered - and unregistered users.""" +def sort_emails(participants: List[str]) -> Dict[str, List[str]]: + """Sorts emails to registered and unregistered users.""" - emails_dict = {'registered': [], 'unregistered': []} # type: ignore - for email in emails: + emails = {'registered': [], 'unregistered': []} # type: ignore + for participant in participants: - if dose_user_exist(email=email): - emails_dict['registered'] += [email] + if does_user_exist(email=participant): + emails['registered'] += [participant] else: - emails_dict['unregistered'] += [email] + emails['unregistered'] += [participant] - return emails_dict + return emails def send_email_invitation( participants: List[str], - event: Event + event: Event, ): """Sends an email with an invitation.""" @@ -36,7 +35,7 @@ def send_email_invitation( def send_in_app_invitation( participants: List[str], - event: Event + event: Event, ) -> Union[bool, None]: """Sends an in-app invitation for registered users.""" diff --git a/app/utils/config.py b/app/utils/config.py deleted file mode 100644 index e7941072..00000000 --- a/app/utils/config.py +++ /dev/null @@ -1,2 +0,0 @@ -ICS_VERSION = '2.0' -PRODUCT_ID = '-//Our product id//' diff --git a/app/utils/export.py b/app/utils/export.py index 884ef5e0..e576f28e 100644 --- a/app/utils/export.py +++ b/app/utils/export.py @@ -3,18 +3,12 @@ from icalendar import Calendar, Event, vCalAddress -from app.config import DOMAIN +from app.config import DOMAIN, ICS_VERSION, PRODUCT_ID from app.database.models import Event as UserEvent -from app.utils.config import ICS_VERSION, PRODUCT_ID def generate_id(event: UserEvent) -> bytes: - """Creates an unique id from: - - event id - - event start time - - event end time - - our domain. - """ + """Creates an unique id.""" return ( str(event.id) diff --git a/app/utils/invitation.py b/app/utils/invitation.py index 6a097164..ad5c926c 100644 --- a/app/utils/invitation.py +++ b/app/utils/invitation.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Union from sqlalchemy.exc import SQLAlchemyError @@ -17,7 +17,8 @@ def get_all_invitations(**param) -> List[Invitation]: return invitations -def get_invitation_by_id(invitation_id: int) -> Invitation: - """Returns a invitation by an id.""" +def get_invitation_by_id(invitation_id: int) -> Union[Invitation, None]: + """Returns a 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/utils/user.py b/app/utils/user.py index dc9c7b97..a94bce55 100644 --- a/app/utils/user.py +++ b/app/utils/user.py @@ -11,7 +11,7 @@ def create_user(username, password, email) -> User: user = User( username=username, password=password, - email=email + email=email, ) save(user) return user @@ -28,10 +28,9 @@ def get_users(**param): return users -def dose_user_exist(user_id=None, username=None, email=None): - """Returns if user exists. - function can receive one of - the there parameters""" +def does_user_exist(*_, user_id=None, username=None, email=None): + """Returns if user exists. function can + receive one of the there parameters""" if user_id: return len(get_users(id=user_id)) == 1 diff --git a/app/utils/utils.py b/app/utils/utils.py index 13e73948..41596318 100644 --- a/app/utils/utils.py +++ b/app/utils/utils.py @@ -1,6 +1,13 @@ from app.config import session +from app.database.database import Base -def save(item) -> None: - session.add(item) - session.commit() +def save(item) -> bool: + """Commits an instance to the db. + source: app.database.database.Base""" + + if issubclass(item.__class__, Base): + session.add(item) + session.commit() + return True + return False From 41ed0c2fef50c3d43890d8ad6bc8b5bd3732dc0b Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Fri, 15 Jan 2021 18:27:22 +0200 Subject: [PATCH 007/108] feat: get weather forecast for date and location --- app/.env | 2 + app/weather_forecast.py | 265 +++++++++++++++++++++++++++++++++ tests/test_weather_forecast.py | 30 ++++ 3 files changed, 297 insertions(+) create mode 100644 app/.env create mode 100644 app/weather_forecast.py create mode 100644 tests/test_weather_forecast.py diff --git a/app/.env b/app/.env new file mode 100644 index 00000000..b7e37336 --- /dev/null +++ b/app/.env @@ -0,0 +1,2 @@ +WEATHER_API_KEY= +ASTRONOMY_API_KEY= diff --git a/app/weather_forecast.py b/app/weather_forecast.py new file mode 100644 index 00000000..0966f9e5 --- /dev/null +++ b/app/weather_forecast.py @@ -0,0 +1,265 @@ +from datetime import datetime, timedelta +from dotenv import load_dotenv +from os import getenv +import requests + + +""" This feature requires an API KEY - get yours free @ visual-crossing-weather.p.rapidapi.com """ + +SUCCESS_STATUS = 0 +ERROR_STATUS = -1 +HISTORY_TYPE = "history" +HISTORICAL_AVERAGE_TYPE = "historical-average" +FORECAST_TYPE = "forecast" +HISTORY_URL = "https://visual-crossing-weather.p.rapidapi.com/history" +FORECAST_URL = "https://visual-crossing-weather.p.rapidapi.com/forecast" +HEADERS = { + 'x-rapidapi-host': "visual-crossing-weather.p.rapidapi.com" + } +BASE_QUERY_STRING = {"aggregateHours": "24", "unitGroup": "metric", "dayStartTime": "00:00:01", + "contentType": "json", "dayEndTime": "23:59:59", "shortColumnNames": "True"} +HISTORICAL_AVERAGE_NUM_OF_YEARS = 3 +OUTPUT = {"Status": SUCCESS_STATUS, "ErrorDescription": None, "MinTempCel": None, "MaxTempCel": None, + "MinTempFar": None, "MaxTempFar": None, "Conditions": None, "ForecastType": None} + + +def validate_date_input(day, month, year): + """ date validation. + Args: + day (int / str) - day part of date. + month (int / str) - month part of date. + year (int / str) - year part of date. + Returns: + (bool) - validate ended in success or not. + day (int) - day part of date. + month (int) - month part of date. + year (int) - year part of date. + """ + try: + day = int(day) + month = int(month) + year = int(year) + except ValueError: + return False, day, month, year + if 1975 <= year <= 2050: + try: + datetime(year=year, month=month, day=day) + except ValueError: + return False, day, month, year + else: + return False, day, month, year + return True, day, month, year + + +def get_data_from_api(url, input_query_string): + """ get the relevant weather data by calling the "Visual Crossing Weather" API. + Args: + url (str) - API url. + input_query_string (dict) - input for the API. + Returns: + success_in_get_weather_data (bool) - did the API call ended in success or failure (location not found etc). + response_json (json dict) - relevant part (data / error) of the JSON returned by the API. + """ + load_dotenv() + HEADERS['x-rapidapi-key'] = getenv('WEATHER_API_KEY') + success_in_get_weather_data = True + response = requests.request("GET", url, headers=HEADERS, params=input_query_string) + try: + response_json = response.json()["locations"] + except KeyError: + success_in_get_weather_data = False + response_json = response.json() + return success_in_get_weather_data, response_json + + +def get_historical_weather(input_date, location): + """ get the relevant weather from history by calling the API. + Args: + input_date (date) - day part of date. + location (str) - location name. + Returns: + (int) - minimum degrees in Celsius. + (int) - maximum degrees in Celsius. + (str) - weather conditions. + (str) - location / error description. + """ + input_query_string = BASE_QUERY_STRING + input_query_string["startDateTime"] = input_date.isoformat() + input_query_string["endDateTime"] = (input_date + timedelta(days=1)).isoformat() + input_query_string["location"] = location + success_in_get_weather_data, api_json = get_data_from_api(HISTORY_URL, input_query_string) + if success_in_get_weather_data: + for item in api_json: + # print("historical:", api_json[item]['values'][0]['mint'], api_json[item]['values'][0]['maxt']) + min_temp = api_json[item]['values'][0]['mint'] + max_temp = api_json[item]['values'][0]['maxt'] + conditions = api_json[item]['values'][0]['conditions'] + return min_temp, max_temp, conditions, api_json[item]['address'] + else: + return None, None, None, api_json['message'] + + +def get_forecast_weather(input_date, location): + """ get the relevant weather forecast by calling the API. + Args: + input_date (date) - day part of date. + location (str) - location name. + Returns: + (int) - minimum degrees in Celsius. + (int) - maximum degrees in Celsius. + (str) - weather conditions. + (str) - location / error description. + """ + input_query_string = BASE_QUERY_STRING + input_query_string["location"] = location + success_in_get_weather_data, api_json = get_data_from_api(FORECAST_URL, input_query_string) + if success_in_get_weather_data: + for item in api_json: + for i in range(len(api_json[item]['values'])): + if input_date == datetime.fromisoformat(api_json[item]['values'][i]['datetimeStr'][:-6]): + min_temp = api_json[item]['values'][i]['mint'] + max_temp = api_json[item]['values'][i]['maxt'] + conditions = api_json[item]['values'][i]['conditions'] + return min_temp, max_temp, conditions, api_json[item]['address'] + else: + return None, None, None, api_json['message'] + + +def get_relevant_years_for_historical_average(day, month): + """ get a list for relevant years to call the get_historical_weather function + according to if date occurred this year or not. + Args: + day (int) - day part of date. + month (int) - month part of date. + Returns: + (list) - relevant years range. + """ + if datetime.now() > datetime(year=datetime.now().year, month=month, day=day): + last_year = datetime.now().year + else: + last_year = datetime.now().year - 1 + return list(range(last_year, last_year - HISTORICAL_AVERAGE_NUM_OF_YEARS, -1)) + + +def get_historical_average_weather(day, month, location): + """ get historical average weather by calling the get_historical_weather function + several times and calculate average. + Args: + day (int) - day part of date. + month (int) - month part of date. + location (str) - location name. + Returns: + (int) - minimum average degrees in Celsius. + (int) - maximum average degrees in Celsius. + (str) - location / error description. + """ + sum_min = 0 + sum_max = 0 + if day == 29 and month == 2: + day = 28 + relevant_years = (get_relevant_years_for_historical_average(day, month)) + for relevant_year in relevant_years: + input_date = datetime(year=relevant_year, month=month, day=day) + min_temp, max_temp, conditions, description = get_historical_weather(input_date, location) + if min_temp is not None: + sum_min += min_temp + sum_max += max_temp + if min_temp is not None: + return sum_min / HISTORICAL_AVERAGE_NUM_OF_YEARS, sum_max / HISTORICAL_AVERAGE_NUM_OF_YEARS, description + else: + return None, None, description + + +def calculate_forecast_type(input_date): + """ calculate relevant forecast type by date. + Args: + input_date (date) - day part of date. + Returns: + output_type (str) - "forecast" / "history" / "historical average". + """ + delta = (input_date - datetime.now()).days + if delta < -1: + output_type = HISTORY_TYPE + elif delta > 15: + output_type = HISTORICAL_AVERAGE_TYPE + else: + output_type = FORECAST_TYPE + return output_type + + +def get_forecast(day, month, year, location): + """ call relevant forecast function according to the relevant type: + "forecast" / "history" / "historical average". + Args: + day (int) - day part of date. + month (int) - month part of date. + year (int) - year part of date. + location (str) - location name. + Returns: + ForecastType (str): + "forecast" - relevant for the upcoming 15 days. + "history" - historical data. + "historical average" - average of the last 3 years on that date. + relevant for future dates (more then forecast). + min_temp (int) - minimum degrees in Celsius. + max_temp (int) - maximum degrees in Celsius. + conditions (str) - weather conditions. + Description (str) - location / error description. + """ + input_date = datetime(year=year, month=month, day=day) + forecast_type = calculate_forecast_type(input_date) + if forecast_type == HISTORY_TYPE: + min_temp, max_temp, conditions, description = get_historical_weather(input_date, location) + if forecast_type == FORECAST_TYPE: + min_temp, max_temp, conditions, description = get_forecast_weather(input_date, location) + if forecast_type == HISTORICAL_AVERAGE_TYPE: + min_temp, max_temp, description = get_historical_average_weather(day, month, location) + conditions = "" + return forecast_type, min_temp, max_temp, conditions, description + + +def get_weather_data(day, month, year, location): + """ get weather data for date & location - main function. + Args: + day (int / str) - day part of date. + month (int / str) - month part of date. + year (int / str) - year part of date. + location (str) - location name. + Returns: dictionary with the following entries: + Status - success / failure. + ErrorDescription - error description (relevant only in case of error). + MinTempCel - minimum degrees in Celsius. + MaxTempCel - maximum degrees in Celsius. + MinTempFar - minimum degrees in Fahrenheit. + MaxTempFar - maximum degrees in Fahrenheit. + ForecastType: + "forecast" - relevant for the upcoming 15 days. + "history" - historical data. + "historical average" - average of the last 3 years on that date. + relevant for future dates (more then forecast). + Address - The location found by the service. + """ + output = OUTPUT + valid_input, day, month, year = validate_date_input(day, month, year) + if valid_input: + forecast_type, min_temp, max_temp, conditions, description = get_forecast(day, month, year, location) + if min_temp is None: + output["Status"] = ERROR_STATUS + output["ErrorDescription"] = description + else: + output["Status"] = SUCCESS_STATUS + output["MinTempCel"] = round(min_temp) + output["MaxTempCel"] = round(max_temp) + output["MinTempFar"] = round((min_temp * 9/5) + 32) + output["MaxTempFar"] = round((max_temp * 9/5) + 32) + output["Conditions"] = conditions + output["ForecastType"] = forecast_type + output["Address"] = description + else: + output["Status"] = ERROR_STATUS + output["ErrorDescription"] = "Invalid date input provided" + return output + + +if __name__ == "__main__": + print(get_weather_data("29", "02", 2024, "tel aviv")) diff --git a/tests/test_weather_forecast.py b/tests/test_weather_forecast.py new file mode 100644 index 00000000..ccc4d079 --- /dev/null +++ b/tests/test_weather_forecast.py @@ -0,0 +1,30 @@ +from datetime import datetime, timedelta +import pytest + +from app.weather_forecast import get_weather_data + + +DATA_GET_WEATHER = [ + pytest.param(4, "d", 2020, "tel aviv", 0, marks=pytest.mark.xfail, id="ivalid input type"), + pytest.param(4, 4, 2020, "tel aviv", 0, id="basic historical test"), + pytest.param(4, 4, 2070, "tel aviv", 0, marks=pytest.mark.xfail, id="year out of range"), + pytest.param(1, 1, 2030, "tel aviv", 0, id="basic historical forecast test - prior in current year"), + pytest.param(31, 12, 2030, "tel aviv", 0, id="basic historical forecast test - future"), + pytest.param(15, 1, 2020, "neo", 0, marks=pytest.mark.xfail, id="location not found test"), + pytest.param(32, 1, 2020, "tel aviv", 0, marks=pytest.mark.xfail, id="invalid date"), + pytest.param(29, 2, 2024, "tel aviv", 0, id="basic historical forecast test"), +] + + +@pytest.mark.parametrize('day, month, year, location, expected', DATA_GET_WEATHER) +def test_get_weather_data(day, month, year, location, expected): + output = get_weather_data(day, month, year, location) + assert output['Status'] == expected + + +def test_get_forecast_weather_data(): + temp_date = datetime.now() + timedelta(days=1) + output = get_weather_data(temp_date.day, temp_date.month, temp_date.year, "tel aviv") + assert output['Status'] == 0 + +# pytest.param(15, 1, 2021, "tel aviv", 0, id="basic forecast test"), From 0bb1c4827cea93dcea7d9791f8024353607b449a Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Fri, 15 Jan 2021 18:29:28 +0200 Subject: [PATCH 008/108] feat: get weather forecast for date and location --- requirements.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/requirements.txt b/requirements.txt index de91534a..d9fd4136 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,3 +25,6 @@ typing-extensions==3.7.4.3 uvicorn==0.13.3 wsproto==1.0.0 zipp==3.4.0 + +requests~=2.25.1 +python-dotenv~=0.15.0 \ No newline at end of file From 9b2a86173213c574357b890d4926faccc64f6587 Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Sat, 16 Jan 2021 00:19:31 +0200 Subject: [PATCH 009/108] add: tests --- app/config.py | 2 +- app/utils/export.py | 4 +- test_db.db | Bin 0 -> 45056 bytes tests/conftest.py | 75 ++++++++++++++++++++++++++++++++++++++ tests/test_event.py | 4 ++ tests/test_export.py | 40 ++++++++++++++++++++ tests/test_invitation.py | 23 ++++++++++++ tests/test_share_event.py | 39 ++++++++++++++++++++ tests/test_user.py | 36 ++++++++++++++++++ tests/test_utils.py | 12 ++++++ 10 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 test_db.db create mode 100644 tests/conftest.py create mode 100644 tests/test_event.py create mode 100644 tests/test_export.py create mode 100644 tests/test_invitation.py create mode 100644 tests/test_share_event.py create mode 100644 tests/test_user.py create mode 100644 tests/test_utils.py diff --git a/app/config.py b/app/config.py index 5b9c728a..77088337 100644 --- a/app/config.py +++ b/app/config.py @@ -1,6 +1,6 @@ from app.database.database import SessionLocal -ICS_VERSION = '2.0' +ICAL_VERSION = '2.0' DOMAIN = 'Our-Domain' PRODUCT_ID = '-//Our product id//' diff --git a/app/utils/export.py b/app/utils/export.py index e576f28e..2f8368a9 100644 --- a/app/utils/export.py +++ b/app/utils/export.py @@ -3,7 +3,7 @@ from icalendar import Calendar, Event, vCalAddress -from app.config import DOMAIN, ICS_VERSION, PRODUCT_ID +from app.config import DOMAIN, ICAL_VERSION, PRODUCT_ID from app.database.models import Event as UserEvent @@ -23,7 +23,7 @@ def create_ical_calendar(): and adds the required information""" cal = Calendar() - cal.add('version', ICS_VERSION) + cal.add('version', ICAL_VERSION) cal.add('prodid', PRODUCT_ID) return cal diff --git a/test_db.db b/test_db.db new file mode 100644 index 0000000000000000000000000000000000000000..cc291e2508a7fea2de3d3638303b921fff321925 GIT binary patch literal 45056 zcmeI)-*4MQ00(f}Nt4=L=OHwiN+2$)P-C&CNv39K(nQ_N-J)d<^U{hUp~xf#86{2^ zyFXsp_5l9^k38*(7yb(nk38%@;EflACd31G=Q@eoL}5rsr2AU3bG~1```k6vIdL96 ztb3tLKJO2DPDpMDR|HWMwg?df;W^!x=pJv2G?0!@=&Lvuo)&yA6h3^htiBW!WnH-Z zt@^Y2-Q~UItxGR2EiL_^d_yC#K>z{}fB*y_009X6e+52KGTO$f_}KG1?umEO^86z& zbV9H12Q9BNcD7WtO`~a&nsvwgn4HT=8WtJLP4Zqxf3hZLwbfPe+11ebtm}>?j|G*} zdYi^}-Aq=Y$XTkx_m{-&@RR>p8I(j{>W!6&D+w$Y;l3l*GDAhhP-JTk_SH*{qh! ziBI3+=A9*ZT1`TQ#r<2volHi1Z$*_chn=T$`NK?)xE~ z#&L~oYIghWXbzwLF|fG)u}`N@a)4$HMqFmfXej!2I-{*>;(n~lM~YsAzb>4$jZ@<} z8I9?a1u?5>n)sBy#n3kdo{1l%ge#AlG5>t0H(A#0IbJswO28gCL2%q3bS5TxLCa}} z-jPeT8;!bYSkvAMcCFgOU6bTlBUvMKmNAax7w0k6duH`Mole7QYZl2DualClE4rS_ zXty;{;FiJTE?us-oWros&s)6vtulW}>(cD3C7v}!|H-iRys*p;R|G-*QT>_)u|WU= z5P$##AOHafKmY;|fB*y_@Sh5ttZlBzp&NvJQO|A{u#*D6O|bO^yHinU_j^1;e0PGj zXM5xJhU~k?EgG({8r+We3N| ziJJ)$ZTwZbzmd2>FkJuts{S-zy+dmu009U<00Izz00bZa0SG_<0^7u!aBI zbbo77;&A={m-@%+s)l|;00Izz00bZa0SG_<0uX=z1TL(ABC-1d>EZML-(T3CqGJ$% z00bZa0SG_<0uX=z1Rwx``4g~nhLIEa?*v4@Ls>4COY6nbdZ|K+#apG)t#YMM`Jl3S zvot-0eG?!mJ^K8A{wUC52tWV=5P$##AOHafKmY;|fB*#kkpTbvUs8V;)IaF~8w4N# z0SG_<0uX=z1Rwwb2tWV=7e*j0rDfpUtj|P2tWV=5P$##AOHafKmY;|fWW*7y#J0U8&13J x9)xa3I{!-mHw!l^<>F=;&;QTc$fCUvfB*y_009U<00Izz00bZafmaqt{|y+iLB#+7 literal 0 HcmV?d00001 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..a94ba693 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,75 @@ +from datetime import datetime + +import pytest + +from app.config import session +from app.database.database import engine, Base +from app.database.models import Event, User, Invitation + + +Base.metadata.create_all(bind=engine) + + +def create_model(model_class, **kw): + instance = model_class(**kw) + session.add(instance) + session.commit() + return instance + + +def delete_instance(instance): + session.delete(instance) + session.commit() + + +@pytest.fixture +def user() -> User: + test_user = create_model( + User, + username='test_username', + password='test_password', + email='test.email@gmail.com', + ) + yield test_user + delete_instance(test_user) + + +@pytest.fixture +def sender() -> User: + sender = create_model( + User, + username='sender_email', + password='sender_password', + email='sender.email@gmail.com', + ) + yield sender + delete_instance(sender) + + +@pytest.fixture +def event(sender: User) -> Event: + event = create_model( + Event, + title='test event', + start=datetime.now(), + end=datetime.now(), + content='test event', + owner=sender, + owner_id=sender.id, + ) + yield event + delete_instance(event) + + +@pytest.fixture +def invitation(event: Event, user: User) -> Event: + invitation = create_model( + Invitation, + creation=datetime.now(), + recipient=user, + event=event, + event_id=event.id, + recipient_id=user.id, + ) + yield invitation + delete_instance(invitation) diff --git a/tests/test_event.py b/tests/test_event.py new file mode 100644 index 00000000..b95a06cc --- /dev/null +++ b/tests/test_event.py @@ -0,0 +1,4 @@ +class TestEvent: + + def test_repr(self, event): + assert event.__repr__() == f'' diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 00000000..3c86060f --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,40 @@ +from icalendar import vCalAddress + +from app.config import ICAL_VERSION, PRODUCT_ID +from app.utils.export import create_ical_calendar, create_ical_event, event_to_ical + + +class TestExport: + + def test_create_ical_calendar(self): + cal = create_ical_calendar() + assert cal.get('version') == ICAL_VERSION + assert cal.get('prodid') == PRODUCT_ID + + def test_create_ical_event(self, event): + ical_event = create_ical_event(event) + assert ical_event.get('organizer') == event.owner.email + assert ical_event.get('summary') == event.title + + def test_add_attendees(self, event, user): + ical_event = create_ical_event(event) + ical_event.add( + 'attendee', + vCalAddress(f'MAILTO:{user.email}'), + encode=0 + ) + assert vCalAddress(f'MAILTO:{user.email}') == ical_event.get('attendee') + + def test_event_to_ical(self, user, event): + ical_event = event_to_ical(event, [user.email]) + + def does_contain(item: str) -> bool: + """Returns if calendar contains item.""" + + return bytes(item, encoding='utf8') in bytes(ical_event) + + assert does_contain(ICAL_VERSION) + assert does_contain(PRODUCT_ID) + assert does_contain(event.owner.email) + assert does_contain(event.title) + diff --git a/tests/test_invitation.py b/tests/test_invitation.py new file mode 100644 index 00000000..942d332a --- /dev/null +++ b/tests/test_invitation.py @@ -0,0 +1,23 @@ +from app.utils.invitation import get_all_invitations, get_invitation_by_id + + +class TestInvitations: + + def test_get_all_invitations_success(self, invitation, event, user): + assert get_all_invitations(event=event) == [invitation] + assert get_all_invitations(recipient=user) == [invitation] + + def test_get_all_invitations_failure(self, user): + assert get_all_invitations(unknown_parameter=user) == [] + assert get_all_invitations(recipient=None) == [] + + def test_get_invitation_by_id(self, invitation): + assert get_invitation_by_id(invitation.id) == invitation + + def test_repr(self, invitation): + invitation_repr = ( + f'' + ) + assert invitation.__repr__() == invitation_repr diff --git a/tests/test_share_event.py b/tests/test_share_event.py new file mode 100644 index 00000000..5666e134 --- /dev/null +++ b/tests/test_share_event.py @@ -0,0 +1,39 @@ +from app.config import session +from app.internal.share_event import sort_emails, send_in_app_invitation, accept, share +from app.utils.invitation import get_all_invitations + + +class TestShareEvent: + + def test_sort_emails(self, user): + # the user is being imported + # so he will be created + data = [ + 'test.email@gmail.com', # registered user + 'not_logged_in@gmail.com', # unregistered user + ] + sorted_data = sort_emails(data) + assert sorted_data == { + 'registered': ['test.email@gmail.com'], + 'unregistered': ['not_logged_in@gmail.com'] + } + + def test_send_in_app_invitation_success(self, user, sender, event): + send_in_app_invitation([user.email], event) + invitation = get_all_invitations(recipient=user)[0] + assert invitation.event.owner == sender + assert invitation.recipient == user + session.delete(invitation) + + def test_send_in_app_invitation_failure(self, event): + send_in_app_invitation([event.owner.email], event) + invitation = get_all_invitations(recipient=event.owner) + assert invitation == [] + + def test_send_email_invitation(self): + # missing + pass + + def test_accept(self, invitation): + accept(invitation) + assert invitation.status == 'accepted' diff --git a/tests/test_user.py b/tests/test_user.py new file mode 100644 index 00000000..d12939f3 --- /dev/null +++ b/tests/test_user.py @@ -0,0 +1,36 @@ +from app.config import session +from app.utils.user import get_users, does_user_exist, create_user + + +class TestUser: + + def test_create_user(self): + user = create_user( + username='new_test_username', + password='new_test_password', + 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' + print(user) + session.delete(user) + + def test_get_users_success(self, user): + assert get_users(username=user.username) == [user] + assert get_users(password=user.password) == [user] + assert get_users(email=user.email) == [user] + + def test_get_users_failure(self): + assert get_users(username='wrong username') == [] + + def test_does_user_exist_success(self, user): + assert does_user_exist(username=user.username) + assert does_user_exist(user_id=user.id) + assert does_user_exist(email=user.email) + + def test_does_user_exist_failure(self): + assert not does_user_exist(username='wrong username') + + def test_repr(self, user): + assert user.__repr__() == f'' diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..4cc872d4 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,12 @@ +from app.utils.utils import save + + +class TestUtils: + + def test_save_success(self, user): + user.username = 'edit_username' + assert save(user) + + def test_save_failure(self): + user = 'not a user instance' + assert not save(user) From ee8840554c73968d8e2ec4ad4875766a313fb63b Mon Sep 17 00:00:00 2001 From: Sagi Zaid Or Date: Sat, 16 Jan 2021 12:36:49 +0200 Subject: [PATCH 010/108] set up commit --- .gitignore | 16 ++++++++++++++++ app/main.py | 8 ++++++++ app/static/style.css | 3 +++ app/templates/home.html | 5 ++++- 4 files changed, 31 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index b6e47617..8764969f 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,19 @@ dmypy.json # Pyre type checker .pyre/ +Scripts/activate +Scripts/activate.bat +Scripts/Activate.ps1 +Scripts/deactivate.bat +Scripts/easy_install-3.7.exe +Scripts/easy_install.exe +Scripts/pip.exe +Scripts/pip3.7.exe +Scripts/pip3.exe +Scripts/py.test.exe +Scripts/pytest.exe +Scripts/python.exe +Scripts/pythonw.exe +Scripts/uvicorn.exe +pyvenv.cfg +.gitignore diff --git a/app/main.py b/app/main.py index dfbf4ca2..535a690b 100644 --- a/app/main.py +++ b/app/main.py @@ -1,15 +1,23 @@ +import uvicorn +from fastapi.staticfiles import StaticFiles from fastapi import FastAPI, Request from fastapi.templating import Jinja2Templates app = FastAPI() +app.mount("/static", StaticFiles(directory="static"), name="static") + templates = Jinja2Templates(directory="templates") + @app.get("/") def home(request: Request): return templates.TemplateResponse("home.html", { "request": request, "message": "Hello, World!" }) + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/app/static/style.css b/app/static/style.css index e69de29b..348a44e2 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -0,0 +1,3 @@ +div{ + background-color: red; +} \ No newline at end of file diff --git a/app/templates/home.html b/app/templates/home.html index 81fece4f..66333329 100644 --- a/app/templates/home.html +++ b/app/templates/home.html @@ -3,9 +3,12 @@ + Calendar - {{message}} +
+ {{message}} +
\ No newline at end of file From aa654ac037700221e84521354cda9a0c4ae7d5f8 Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Sat, 16 Jan 2021 13:35:37 +0200 Subject: [PATCH 011/108] add: timezone support --- app/config.py | 6 +++++- app/main.py | 1 - app/utils/export.py | 44 ++++++++++++++++++++++++++++++++------- requirements.txt | 5 +++-- tests/conftest.py | 6 +----- tests/test_export.py | 8 ++++--- tests/test_share_event.py | 4 +++- tests/test_user.py | 2 +- 8 files changed, 54 insertions(+), 22 deletions(-) diff --git a/app/config.py b/app/config.py index 77088337..5dbfac18 100644 --- a/app/config.py +++ b/app/config.py @@ -1,7 +1,11 @@ from app.database.database import SessionLocal -ICAL_VERSION = '2.0' +# general DOMAIN = 'Our-Domain' + +# export +ICAL_VERSION = '2.0' PRODUCT_ID = '-//Our product id//' +OPTIONAL = [] session = SessionLocal() diff --git a/app/main.py b/app/main.py index 6af74e24..7adfef1e 100644 --- a/app/main.py +++ b/app/main.py @@ -9,7 +9,6 @@ from app.internal.share_event import accept from app.utils.invitation import get_all_invitations, get_invitation_by_id - app = FastAPI() app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") diff --git a/app/utils/export.py b/app/utils/export.py index 2f8368a9..9006a1a8 100644 --- a/app/utils/export.py +++ b/app/utils/export.py @@ -1,7 +1,8 @@ from datetime import datetime from typing import List -from icalendar import Calendar, Event, vCalAddress +from icalendar import Calendar, Event, vCalAddress, vText +import pytz from app.config import DOMAIN, ICAL_VERSION, PRODUCT_ID from app.database.models import Event as UserEvent @@ -29,33 +30,61 @@ def create_ical_calendar(): return cal +def add_optional(user_event, data): + """Adds an optional field if it exists.""" + + if user_event.location: + data += [('location', user_event.location)] + + if user_event.content: + data += [('description', user_event.content)] + + return data + + def create_ical_event(user_event): """Creates an ical event, and adds the event information""" ievent = Event() data = [ - ('organizer', vCalAddress(user_event.owner.email)), + ('organizer', add_attendee(user_event.owner.email, organizer=True)), ('uid', generate_id(user_event)), ('dtstart', user_event.start), - ('dtstamp', datetime.now()), + ('dtstamp', datetime.now(tz=pytz.utc)), ('dtend', user_event.end), ('summary', user_event.title), ] + data = add_optional(user_event, data) + for param in data: ievent.add(*param) return ievent +def add_attendee(email, organizer=False): + """Adds an attendee to the event.""" + + attendee = vCalAddress(f'MAILTO:{email}') + if organizer: + attendee.params['partstat'] = vText('ACCEPTED') + attendee.params['role'] = vText('CHAIR') + else: + attendee.params['partstat'] = vText('NEEDS-ACTION') + attendee.params['role'] = vText('PARTICIPANT') + + return attendee + + def add_attendees(ievent, attendees: list): """Adds attendees for the event.""" - for attendee in attendees: + for email in attendees: ievent.add( 'attendee', - vCalAddress(f'MAILTO:{attendee}'), + add_attendee(email), encode=0 ) @@ -63,9 +92,8 @@ def add_attendees(ievent, attendees: list): def event_to_ical(user_event: UserEvent, attendees: List[str]) -> bytes: - """Returns an ical event, - given an "UserEvent" instance - and a list of email""" + """Returns an ical event, given an + "UserEvent" instance and a list of email.""" ical = create_ical_calendar() ievent = create_ical_event(user_event) diff --git a/requirements.txt b/requirements.txt index bf7d243a..8a7ec59d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,8 +18,8 @@ priority==1.3.0 py==1.10.0 pydantic==1.7.3 pyparsing==2.4.7 -pytest==6.2.1 -SQLAlchemy==1.3.22 +pytest==5.3.5 +SQLAlchemy==1.3.13 starlette==0.13.6 toml==0.10.2 typing-extensions==3.7.4.3 @@ -27,3 +27,4 @@ uvicorn==0.13.3 wsproto==1.0.0 zipp==3.4.0 icalendar==4.0.7 +pytz==2019.3 diff --git a/tests/conftest.py b/tests/conftest.py index a94ba693..fa15d1a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,11 +3,7 @@ import pytest from app.config import session -from app.database.database import engine, Base -from app.database.models import Event, User, Invitation - - -Base.metadata.create_all(bind=engine) +from app.database.models import Event, Invitation, User def create_model(model_class, **kw): diff --git a/tests/test_export.py b/tests/test_export.py index 3c86060f..0cccd766 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -1,7 +1,9 @@ from icalendar import vCalAddress from app.config import ICAL_VERSION, PRODUCT_ID -from app.utils.export import create_ical_calendar, create_ical_event, event_to_ical +from app.utils.export import ( + create_ical_calendar, create_ical_event, event_to_ical +) class TestExport: @@ -23,7 +25,8 @@ def test_add_attendees(self, event, user): vCalAddress(f'MAILTO:{user.email}'), encode=0 ) - assert vCalAddress(f'MAILTO:{user.email}') == ical_event.get('attendee') + attendee = vCalAddress(f'MAILTO:{user.email}') + assert attendee == ical_event.get('attendee') def test_event_to_ical(self, user, event): ical_event = event_to_ical(event, [user.email]) @@ -37,4 +40,3 @@ def does_contain(item: str) -> bool: assert does_contain(PRODUCT_ID) assert does_contain(event.owner.email) assert does_contain(event.title) - diff --git a/tests/test_share_event.py b/tests/test_share_event.py index 5666e134..c0025939 100644 --- a/tests/test_share_event.py +++ b/tests/test_share_event.py @@ -1,5 +1,7 @@ from app.config import session -from app.internal.share_event import sort_emails, send_in_app_invitation, accept, share +from app.internal.share_event import ( + accept, send_in_app_invitation, sort_emails +) from app.utils.invitation import get_all_invitations diff --git a/tests/test_user.py b/tests/test_user.py index d12939f3..db38e3a9 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -1,5 +1,5 @@ from app.config import session -from app.utils.user import get_users, does_user_exist, create_user +from app.utils.user import create_user, does_user_exist, get_users class TestUser: From 5227c119e0e8058dff0282c02feb78950807c87a Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Sat, 16 Jan 2021 17:42:40 +0200 Subject: [PATCH 012/108] add: session management --- app/config.py | 4 --- app/database/database.py | 5 ++- app/database/models.py | 3 +- app/internal/share_event.py | 29 ++++++++++------- app/main.py | 28 +++++++++++----- app/templates/requests.html | 2 +- app/utils/invitation.py | 8 +++-- app/utils/user.py | 24 ++++++++------ app/utils/utils.py | 7 ++-- test_db.db | Bin 45056 -> 0 bytes tests/conftest.py | 62 +++++++++++++++++++++++------------- tests/test_export.py | 2 +- tests/test_invitation.py | 27 +++++++++++----- tests/test_share_event.py | 24 +++++++------- tests/test_user.py | 29 ++++++++--------- tests/test_utils.py | 10 +++--- 16 files changed, 159 insertions(+), 105 deletions(-) delete mode 100644 test_db.db diff --git a/app/config.py b/app/config.py index 5dbfac18..2a9278d0 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,3 @@ -from app.database.database import SessionLocal - # general DOMAIN = 'Our-Domain' @@ -7,5 +5,3 @@ ICAL_VERSION = '2.0' PRODUCT_ID = '-//Our product id//' OPTIONAL = [] - -session = SessionLocal() diff --git a/app/database/database.py b/app/database/database.py index 43626378..54070261 100644 --- a/app/database/database.py +++ b/app/database/database.py @@ -1,15 +1,14 @@ import os from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_CONNECTION_STRING") engine = create_engine( - SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} + 'sqlite:///test.db', connect_args={"check_same_thread": False} ) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) -Base = declarative_base() +# Base = declarative_base() diff --git a/app/database/models.py b/app/database/models.py index 35633ea1..b3514198 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1,9 +1,10 @@ from datetime import datetime from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String +from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship -from app.database.database import Base +Base = declarative_base() class UserEvent(Base): diff --git a/app/internal/share_event.py b/app/internal/share_event.py index d103c35e..539772b0 100644 --- a/app/internal/share_event.py +++ b/app/internal/share_event.py @@ -1,19 +1,23 @@ from typing import List, Dict, Union -from app.config import session +from sqlalchemy.orm import Session + from app.database.models import Event, Invitation, UserEvent from app.utils.export import event_to_ical from app.utils.user import does_user_exist, get_users from app.utils.utils import save -def sort_emails(participants: List[str]) -> Dict[str, List[str]]: +def sort_emails( + participants: List[str], + session: Session +) -> Dict[str, List[str]]: """Sorts emails to registered and unregistered users.""" emails = {'registered': [], 'unregistered': []} # type: ignore for participant in participants: - if does_user_exist(email=participant): + if does_user_exist(email=participant, session=session): emails['registered'] += [participant] else: emails['unregistered'] += [participant] @@ -36,18 +40,19 @@ def send_email_invitation( def send_in_app_invitation( participants: List[str], event: Event, + session: Session ) -> Union[bool, None]: """Sends an in-app invitation for registered users.""" for participant in participants: # email is unique - recipient = get_users(email=participant)[0] + recipient = get_users(email=participant, session=session)[0] if recipient.id != event.owner.id: session.add(Invitation(recipient=recipient, event=event)) else: - # if user tries to send to himself. + # if user tries to send to themselves. session.rollback() return None @@ -55,7 +60,7 @@ def send_in_app_invitation( return True -def accept(invitation: Invitation) -> None: +def accept(invitation: Invitation, session: Session) -> None: """Accepts an invitation by creating an UserEvent association that represents participantship at the event.""" @@ -65,14 +70,16 @@ def accept(invitation: Invitation) -> None: events=invitation.event ) invitation.status = 'accepted' - save(invitation) - save(association) + save(invitation, session=session) + save(association, session=session) -def share(event: Event, participants: List[str]) -> None: +def share(event: Event, participants: List[str], session: Session) -> None: """Sends invitations to all event participants.""" - registered, unregistered = sort_emails(participants).values() + registered, unregistered = ( + sort_emails(participants, session=session).values() + ) - send_in_app_invitation(registered, event) + send_in_app_invitation(registered, event, session) send_email_invitation(unregistered, event) diff --git a/app/main.py b/app/main.py index 7adfef1e..0ff290e8 100644 --- a/app/main.py +++ b/app/main.py @@ -1,11 +1,12 @@ -from fastapi import FastAPI, Form, Request +from fastapi import Depends, FastAPI, Form, Request from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates - +from sqlalchemy.orm import Session from starlette.status import HTTP_302_FOUND -from app.database.database import Base, engine +from app.database.database import SessionLocal, engine +from app.database.models import Base from app.internal.share_event import accept from app.utils.invitation import get_all_invitations, get_invitation_by_id @@ -16,6 +17,14 @@ Base.metadata.create_all(bind=engine) +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + @app.get("/") def home(request: Request): return templates.TemplateResponse("home.html", { @@ -25,21 +34,24 @@ def home(request: Request): @app.get("/invitations") -def view_invitations(request: Request): +def view_invitations(request: Request, db: Session = Depends(get_db)): return templates.TemplateResponse("requests.html", { "request": request, # 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(), + "invitations": get_all_invitations(session=db), "message": "Hello, World!" }) @app.post("/invitations") -async def accept_invitations(invite_id: int = Form(...)): - invitation = get_invitation_by_id(invite_id) - accept(invitation) +async def accept_invitations( + invite_id: int = Form(...), + db: Session = Depends(get_db) +): + invitation = get_invitation_by_id(invite_id, session=db) + accept(invitation, db) return RedirectResponse("/invitations", status_code=HTTP_302_FOUND) diff --git a/app/templates/requests.html b/app/templates/requests.html index 3afca239..9f07600c 100644 --- a/app/templates/requests.html +++ b/app/templates/requests.html @@ -11,7 +11,7 @@

{{message}}

{% for i in invitations %}
- {{ i.event.owner.username }} - {{ i.event.title }} ({{ i.event.start }}) + {{ i.event.owner.username }} - {{ i.event.title }} ({{ i.event.start }}) ({{ i.status }})
diff --git a/app/utils/invitation.py b/app/utils/invitation.py index ad5c926c..249f6a67 100644 --- a/app/utils/invitation.py +++ b/app/utils/invitation.py @@ -1,12 +1,12 @@ from typing import List, Union from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session -from app.config import session from app.database.models import Invitation -def get_all_invitations(**param) -> List[Invitation]: +def get_all_invitations(session: Session, **param) -> List[Invitation]: """Returns all invitations filter by param.""" try: @@ -17,7 +17,9 @@ def get_all_invitations(**param) -> List[Invitation]: return invitations -def get_invitation_by_id(invitation_id: int) -> Union[Invitation, None]: +def get_invitation_by_id( + invitation_id: int, session: Session +) -> Union[Invitation, None]: """Returns a invitation by an id. if id does not exist, returns None.""" diff --git a/app/utils/user.py b/app/utils/user.py index a94bce55..9bf9fc29 100644 --- a/app/utils/user.py +++ b/app/utils/user.py @@ -1,11 +1,11 @@ from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session -from app.config import session from app.database.models import User from app.utils.utils import save -def create_user(username, password, email) -> User: +def create_user(username, password, email, session: Session) -> User: """Creates and saves a new user.""" user = User( @@ -13,11 +13,11 @@ def create_user(username, password, email) -> User: password=password, email=email, ) - save(user) + save(user, session=session) return user -def get_users(**param): +def get_users(session: Session, **param): """Returns all users filter by param.""" try: @@ -28,14 +28,18 @@ def get_users(**param): return users -def does_user_exist(*_, user_id=None, username=None, email=None): - """Returns if user exists. function can - receive one of the there parameters""" +def does_user_exist( + session: Session, + *_, user_id=None, + username=None, email=None +): + """Returns True if user exists, False otherwise. + function can receive one of the there parameters""" if user_id: - return len(get_users(id=user_id)) == 1 + return len(get_users(session=session, id=user_id)) == 1 if username: - return len(get_users(username=username)) == 1 + return len(get_users(session=session, username=username)) == 1 if email: - return len(get_users(email=email)) == 1 + return len(get_users(session=session, email=email)) == 1 return False diff --git a/app/utils/utils.py b/app/utils/utils.py index 41596318..6bfeeda2 100644 --- a/app/utils/utils.py +++ b/app/utils/utils.py @@ -1,8 +1,9 @@ -from app.config import session -from app.database.database import Base +from sqlalchemy.orm import Session +from app.database.models import Base -def save(item) -> bool: + +def save(item, session: Session) -> bool: """Commits an instance to the db. source: app.database.database.Base""" diff --git a/test_db.db b/test_db.db deleted file mode 100644 index cc291e2508a7fea2de3d3638303b921fff321925..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45056 zcmeI)-*4MQ00(f}Nt4=L=OHwiN+2$)P-C&CNv39K(nQ_N-J)d<^U{hUp~xf#86{2^ zyFXsp_5l9^k38*(7yb(nk38%@;EflACd31G=Q@eoL}5rsr2AU3bG~1```k6vIdL96 ztb3tLKJO2DPDpMDR|HWMwg?df;W^!x=pJv2G?0!@=&Lvuo)&yA6h3^htiBW!WnH-Z zt@^Y2-Q~UItxGR2EiL_^d_yC#K>z{}fB*y_009X6e+52KGTO$f_}KG1?umEO^86z& zbV9H12Q9BNcD7WtO`~a&nsvwgn4HT=8WtJLP4Zqxf3hZLwbfPe+11ebtm}>?j|G*} zdYi^}-Aq=Y$XTkx_m{-&@RR>p8I(j{>W!6&D+w$Y;l3l*GDAhhP-JTk_SH*{qh! ziBI3+=A9*ZT1`TQ#r<2volHi1Z$*_chn=T$`NK?)xE~ z#&L~oYIghWXbzwLF|fG)u}`N@a)4$HMqFmfXej!2I-{*>;(n~lM~YsAzb>4$jZ@<} z8I9?a1u?5>n)sBy#n3kdo{1l%ge#AlG5>t0H(A#0IbJswO28gCL2%q3bS5TxLCa}} z-jPeT8;!bYSkvAMcCFgOU6bTlBUvMKmNAax7w0k6duH`Mole7QYZl2DualClE4rS_ zXty;{;FiJTE?us-oWros&s)6vtulW}>(cD3C7v}!|H-iRys*p;R|G-*QT>_)u|WU= z5P$##AOHafKmY;|fB*y_@Sh5ttZlBzp&NvJQO|A{u#*D6O|bO^yHinU_j^1;e0PGj zXM5xJhU~k?EgG({8r+We3N| ziJJ)$ZTwZbzmd2>FkJuts{S-zy+dmu009U<00Izz00bZa0SG_<0^7u!aBI zbbo77;&A={m-@%+s)l|;00Izz00bZa0SG_<0uX=z1TL(ABC-1d>EZML-(T3CqGJ$% z00bZa0SG_<0uX=z1Rwx``4g~nhLIEa?*v4@Ls>4COY6nbdZ|K+#apG)t#YMM`Jl3S zvot-0eG?!mJ^K8A{wUC52tWV=5P$##AOHafKmY;|fB*#kkpTbvUs8V;)IaF~8w4N# z0SG_<0uX=z1Rwwb2tWV=7e*j0rDfpUtj|P2tWV=5P$##AOHafKmY;|fWW*7y#J0U8&13J x9)xa3I{!-mHw!l^<>F=;&;QTc$fCUvfB*y_009U<00Izz00bZafmaqt{|y+iLB#+7 diff --git a/tests/conftest.py b/tests/conftest.py index fa15d1a8..29b32946 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,51 +1,57 @@ from datetime import datetime import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker -from app.config import session -from app.database.models import Event, Invitation, User +from app.database.models import Event, Invitation, User, Base -def create_model(model_class, **kw): - instance = model_class(**kw) - session.add(instance) - session.commit() - return instance +@pytest.fixture(scope="session") +def engine(): + print("TestCase: Using sqlite database") + return create_engine('sqlite:///', echo=False) -def delete_instance(instance): - session.delete(instance) - session.commit() +@pytest.fixture(scope="session") +def session(engine): + sessionmaker_ = sessionmaker(bind=engine) + session = sessionmaker_() + Base.metadata.create_all(engine) + + yield session + + session.close() @pytest.fixture -def user() -> User: +def user(session: Session) -> User: test_user = create_model( - User, + session, User, username='test_username', password='test_password', email='test.email@gmail.com', ) yield test_user - delete_instance(test_user) + delete_instance(session, test_user) @pytest.fixture -def sender() -> User: +def sender(session: Session) -> User: sender = create_model( - User, + session, User, username='sender_email', password='sender_password', email='sender.email@gmail.com', ) yield sender - delete_instance(sender) + delete_instance(session, sender) @pytest.fixture -def event(sender: User) -> Event: +def event(sender: User, session: Session) -> Event: event = create_model( - Event, + session, Event, title='test event', start=datetime.now(), end=datetime.now(), @@ -54,13 +60,13 @@ def event(sender: User) -> Event: owner_id=sender.id, ) yield event - delete_instance(event) + delete_instance(session, event) @pytest.fixture -def invitation(event: Event, user: User) -> Event: +def invitation(event: Event, user: User, session: Session) -> Event: invitation = create_model( - Invitation, + session, Invitation, creation=datetime.now(), recipient=user, event=event, @@ -68,4 +74,16 @@ def invitation(event: Event, user: User) -> Event: recipient_id=user.id, ) yield invitation - delete_instance(invitation) + delete_instance(session, invitation) + + +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() diff --git a/tests/test_export.py b/tests/test_export.py index 0cccd766..5bdbe149 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -15,7 +15,7 @@ def test_create_ical_calendar(self): def test_create_ical_event(self, event): ical_event = create_ical_event(event) - assert ical_event.get('organizer') == event.owner.email + assert event.owner.email in ical_event.get('organizer') assert ical_event.get('summary') == event.title def test_add_attendees(self, event, user): diff --git a/tests/test_invitation.py b/tests/test_invitation.py index 942d332a..196bcaaf 100644 --- a/tests/test_invitation.py +++ b/tests/test_invitation.py @@ -3,16 +3,27 @@ class TestInvitations: - def test_get_all_invitations_success(self, invitation, event, user): - assert get_all_invitations(event=event) == [invitation] - assert get_all_invitations(recipient=user) == [invitation] + 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): - assert get_all_invitations(unknown_parameter=user) == [] - assert get_all_invitations(recipient=None) == [] + def test_get_all_invitations_failure(self, user, session): + invitations = get_all_invitations( + unknown_parameter=user, session=session) + assert invitations == [] - def test_get_invitation_by_id(self, invitation): - assert get_invitation_by_id(invitation.id) == invitation + 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_repr(self, invitation): invitation_repr = ( diff --git a/tests/test_share_event.py b/tests/test_share_event.py index c0025939..7006c524 100644 --- a/tests/test_share_event.py +++ b/tests/test_share_event.py @@ -1,4 +1,3 @@ -from app.config import session from app.internal.share_event import ( accept, send_in_app_invitation, sort_emails ) @@ -7,35 +6,38 @@ class TestShareEvent: - def test_sort_emails(self, user): + 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 ] - sorted_data = sort_emails(data) + sorted_data = sort_emails(data, session=session) assert sorted_data == { 'registered': ['test.email@gmail.com'], 'unregistered': ['not_logged_in@gmail.com'] } - def test_send_in_app_invitation_success(self, user, sender, event): - send_in_app_invitation([user.email], event) - invitation = get_all_invitations(recipient=user)[0] + def test_send_in_app_invitation_success( + self, user, sender, event, session + ): + send_in_app_invitation([user.email], event, session=session) + 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, event): - send_in_app_invitation([event.owner.email], event) - invitation = get_all_invitations(recipient=event.owner) + def test_send_in_app_invitation_failure(self, event, session): + send_in_app_invitation([event.owner.email], event, session=session) + invitation = get_all_invitations( + recipient=event.owner, session=session) assert invitation == [] def test_send_email_invitation(self): # missing pass - def test_accept(self, invitation): - accept(invitation) + def test_accept(self, invitation, session): + accept(invitation, session=session) assert invitation.status == 'accepted' diff --git a/tests/test_user.py b/tests/test_user.py index db38e3a9..d344efc1 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -1,11 +1,11 @@ -from app.config import session from app.utils.user import create_user, does_user_exist, get_users class TestUser: - def test_create_user(self): + def test_create_user(self, session): user = create_user( + session=session, username='new_test_username', password='new_test_password', email='new_test.email@gmail.com', @@ -13,24 +13,23 @@ def test_create_user(self): assert user.username == 'new_test_username' assert user.password == 'new_test_password' assert user.email == 'new_test.email@gmail.com' - print(user) session.delete(user) - def test_get_users_success(self, user): - assert get_users(username=user.username) == [user] - assert get_users(password=user.password) == [user] - assert get_users(email=user.email) == [user] + def test_get_users_success(self, user, session): + assert get_users(username=user.username, session=session) == [user] + assert get_users(password=user.password, session=session) == [user] + assert get_users(email=user.email, session=session) == [user] - def test_get_users_failure(self): - assert get_users(username='wrong username') == [] + def test_get_users_failure(self, session): + assert get_users(username='wrong username', session=session) == [] - def test_does_user_exist_success(self, user): - assert does_user_exist(username=user.username) - assert does_user_exist(user_id=user.id) - assert does_user_exist(email=user.email) + def test_does_user_exist_success(self, user, session): + assert does_user_exist(username=user.username, session=session) + assert does_user_exist(user_id=user.id, session=session) + assert does_user_exist(email=user.email, session=session) - def test_does_user_exist_failure(self): - assert not does_user_exist(username='wrong username') + def test_does_user_exist_failure(self, session): + assert not does_user_exist(username='wrong username', session=session) def test_repr(self, user): assert user.__repr__() == f'' diff --git a/tests/test_utils.py b/tests/test_utils.py index 4cc872d4..33505410 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,12 +1,14 @@ +from sqlalchemy.orm import Session + from app.utils.utils import save class TestUtils: - def test_save_success(self, user): + def test_save_success(self, user, session: Session): user.username = 'edit_username' - assert save(user) + assert save(user, session=session) - def test_save_failure(self): + def test_save_failure(self, session: Session): user = 'not a user instance' - assert not save(user) + assert not save(user, session=session) From 56877c62923919f74e823725faf1e528c457814c Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Sat, 16 Jan 2021 18:01:19 +0200 Subject: [PATCH 013/108] fix bug --- app/database/database.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/database/database.py b/app/database/database.py index 54070261..380bcce5 100644 --- a/app/database/database.py +++ b/app/database/database.py @@ -7,8 +7,6 @@ SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_CONNECTION_STRING") engine = create_engine( - 'sqlite:///test.db', connect_args={"check_same_thread": False} + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} ) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - -# Base = declarative_base() From eda3c31d1443e1073c34666ce0a033834ae3c00f Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Sat, 16 Jan 2021 22:02:11 +0200 Subject: [PATCH 014/108] split conftest file --- .../{requests.html => invitations.html} | 0 tests/event_fixture.py | 22 +++++++++++++++++++ tests/invitation_fixture.py | 21 ++++++++++++++++++ tests/user_fixture.py | 0 tests/utils.py | 0 5 files changed, 43 insertions(+) rename app/templates/{requests.html => invitations.html} (100%) create mode 100644 tests/event_fixture.py create mode 100644 tests/invitation_fixture.py create mode 100644 tests/user_fixture.py create mode 100644 tests/utils.py diff --git a/app/templates/requests.html b/app/templates/invitations.html similarity index 100% rename from app/templates/requests.html rename to app/templates/invitations.html diff --git a/tests/event_fixture.py b/tests/event_fixture.py new file mode 100644 index 00000000..eb8f8d12 --- /dev/null +++ b/tests/event_fixture.py @@ -0,0 +1,22 @@ +from datetime import datetime + +import pytest +from sqlalchemy.orm import Session + +from app.database.models import Event, User +from tests.utils import create_model, delete_instance + + +@pytest.fixture +def event(sender: User, session: Session) -> Event: + event = create_model( + session, Event, + title='test event', + start=datetime.now(), + end=datetime.now(), + content='test event', + owner=sender, + owner_id=sender.id, + ) + yield event + delete_instance(session, event) \ No newline at end of file diff --git a/tests/invitation_fixture.py b/tests/invitation_fixture.py new file mode 100644 index 00000000..569badad --- /dev/null +++ b/tests/invitation_fixture.py @@ -0,0 +1,21 @@ +from datetime import datetime + +import pytest +from sqlalchemy.orm import Session + +from app.database.models import Event, Invitation, User +from tests.utils import create_model, delete_instance + + +@pytest.fixture +def invitation(event: Event, user: User, session: Session) -> Event: + invitation = create_model( + session, Invitation, + creation=datetime.now(), + recipient=user, + event=event, + event_id=event.id, + recipient_id=user.id, + ) + yield invitation + delete_instance(session, invitation) \ No newline at end of file diff --git a/tests/user_fixture.py b/tests/user_fixture.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 00000000..e69de29b From c24830a87ca996de7a56c4298d6ea2c4ecf04624 Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Sat, 16 Jan 2021 22:32:27 +0200 Subject: [PATCH 015/108] split conftest file --- app/database/database.py | 2 +- app/main.py | 9 ++++- tests/conftest.py | 83 +++++----------------------------------- tests/user_fixture.py | 29 ++++++++++++++ tests/utils.py | 13 +++++++ 5 files changed, 59 insertions(+), 77 deletions(-) diff --git a/app/database/database.py b/app/database/database.py index 380bcce5..baf5a149 100644 --- a/app/database/database.py +++ b/app/database/database.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import sessionmaker -SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_CONNECTION_STRING") +SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_CONNECTION_STRING", "sqlite://") engine = create_engine( SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} diff --git a/app/main.py b/app/main.py index 0ff290e8..30763e61 100644 --- a/app/main.py +++ b/app/main.py @@ -1,3 +1,4 @@ +import uvicorn from fastapi import Depends, FastAPI, Form, Request from fastapi.responses import RedirectResponse from fastapi.staticfiles import StaticFiles @@ -35,7 +36,7 @@ def home(request: Request): @app.get("/invitations") def view_invitations(request: Request, db: Session = Depends(get_db)): - return templates.TemplateResponse("requests.html", { + return templates.TemplateResponse("invitations.html", { "request": request, # recipient_id should be the current user # but because we don't have one yet, @@ -65,5 +66,9 @@ def profile(request: Request): return templates.TemplateResponse("profile.html", { "request": request, "username": current_username, - "events": upcoming_events + "events": upcoming_events, }) + + +if __name__ == '__main__': + uvicorn.run(app) diff --git a/tests/conftest.py b/tests/conftest.py index 29b32946..4c4191cb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,89 +1,24 @@ -from datetime import datetime - import pytest from sqlalchemy import create_engine -from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.orm import sessionmaker + +from app.database.models import Base -from app.database.models import Event, Invitation, User, Base +pytest_plugins = [ + 'tests.user_fixture', + 'tests.event_fixture', + 'tests.invitation_fixture' +] @pytest.fixture(scope="session") def engine(): - print("TestCase: Using sqlite database") return create_engine('sqlite:///', echo=False) @pytest.fixture(scope="session") def session(engine): - sessionmaker_ = sessionmaker(bind=engine) - session = sessionmaker_() + session = sessionmaker(bind=engine)() Base.metadata.create_all(engine) - yield session - session.close() - - -@pytest.fixture -def user(session: Session) -> User: - test_user = create_model( - session, User, - username='test_username', - password='test_password', - email='test.email@gmail.com', - ) - yield test_user - delete_instance(session, test_user) - - -@pytest.fixture -def sender(session: Session) -> User: - sender = create_model( - session, User, - username='sender_email', - password='sender_password', - email='sender.email@gmail.com', - ) - yield sender - delete_instance(session, sender) - - -@pytest.fixture -def event(sender: User, session: Session) -> Event: - event = create_model( - session, Event, - title='test event', - start=datetime.now(), - end=datetime.now(), - content='test event', - owner=sender, - owner_id=sender.id, - ) - yield event - delete_instance(session, event) - - -@pytest.fixture -def invitation(event: Event, user: User, session: Session) -> Event: - invitation = create_model( - session, Invitation, - creation=datetime.now(), - recipient=user, - event=event, - event_id=event.id, - recipient_id=user.id, - ) - yield invitation - delete_instance(session, invitation) - - -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() diff --git a/tests/user_fixture.py b/tests/user_fixture.py index e69de29b..0971e034 100644 --- a/tests/user_fixture.py +++ b/tests/user_fixture.py @@ -0,0 +1,29 @@ +import pytest +from sqlalchemy.orm import Session + +from app.database.models import User +from tests.utils import create_model, delete_instance + + +@pytest.fixture +def user(session: Session) -> User: + test_user = create_model( + session, User, + username='test_username', + password='test_password', + email='test.email@gmail.com', + ) + yield test_user + delete_instance(session, test_user) + + +@pytest.fixture +def sender(session: Session) -> User: + sender = create_model( + session, User, + username='sender_email', + password='sender_password', + email='sender.email@gmail.com', + ) + yield sender + delete_instance(session, sender) \ No newline at end of file diff --git a/tests/utils.py b/tests/utils.py index e69de29b..b955fb55 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -0,0 +1,13 @@ +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() \ No newline at end of file From 69f81e1ddf9babe8fb5d751f68dfc3950d42f073 Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Sun, 17 Jan 2021 15:38:46 +0200 Subject: [PATCH 016/108] move "utils" folder into "internal" folder --- app/config.py | 3 ++ app/dependencies.py | 9 +++++ app/{utils => internal}/event.py | 0 app/{utils => internal}/invitation.py | 0 app/{utils => internal/share}/__init__.py | 0 app/{utils => internal/share}/export.py | 0 app/internal/{ => share}/share_event.py | 6 ++-- app/{utils => internal}/user.py | 2 +- app/{utils => internal}/utils.py | 0 app/main.py | 44 +++-------------------- app/routers/invitation.py | 34 ++++++++++++++++++ app/templates/invitations.html | 2 +- tests/test_export.py | 2 +- tests/test_invitation.py | 2 +- tests/test_share_event.py | 4 +-- tests/test_user.py | 2 +- tests/test_utils.py | 2 +- tests/utils.py | 2 +- 18 files changed, 63 insertions(+), 51 deletions(-) rename app/{utils => internal}/event.py (100%) rename app/{utils => internal}/invitation.py (100%) rename app/{utils => internal/share}/__init__.py (100%) rename app/{utils => internal/share}/export.py (100%) rename app/internal/{ => share}/share_event.py (93%) rename app/{utils => internal}/user.py (96%) rename app/{utils => internal}/utils.py (100%) create mode 100644 app/routers/invitation.py diff --git a/app/config.py b/app/config.py index 2a9278d0..9843749d 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,8 @@ +from fastapi.templating import Jinja2Templates + # general DOMAIN = 'Our-Domain' +templates = Jinja2Templates(directory="templates") # export ICAL_VERSION = '2.0' diff --git a/app/dependencies.py b/app/dependencies.py index e69de29b..90e3f309 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -0,0 +1,9 @@ +from app.database.database import SessionLocal + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/utils/event.py b/app/internal/event.py similarity index 100% rename from app/utils/event.py rename to app/internal/event.py diff --git a/app/utils/invitation.py b/app/internal/invitation.py similarity index 100% rename from app/utils/invitation.py rename to app/internal/invitation.py diff --git a/app/utils/__init__.py b/app/internal/share/__init__.py similarity index 100% rename from app/utils/__init__.py rename to app/internal/share/__init__.py diff --git a/app/utils/export.py b/app/internal/share/export.py similarity index 100% rename from app/utils/export.py rename to app/internal/share/export.py diff --git a/app/internal/share_event.py b/app/internal/share/share_event.py similarity index 93% rename from app/internal/share_event.py rename to app/internal/share/share_event.py index 539772b0..208e48dd 100644 --- a/app/internal/share_event.py +++ b/app/internal/share/share_event.py @@ -3,9 +3,9 @@ from sqlalchemy.orm import Session from app.database.models import Event, Invitation, UserEvent -from app.utils.export import event_to_ical -from app.utils.user import does_user_exist, get_users -from app.utils.utils import save +from app.internal.share.export import event_to_ical +from app.internal.user import does_user_exist, get_users +from app.internal.utils import save def sort_emails( diff --git a/app/utils/user.py b/app/internal/user.py similarity index 96% rename from app/utils/user.py rename to app/internal/user.py index 9bf9fc29..90f762c8 100644 --- a/app/utils/user.py +++ b/app/internal/user.py @@ -2,7 +2,7 @@ from sqlalchemy.orm import Session from app.database.models import User -from app.utils.utils import save +from app.internal.utils import save def create_user(username, password, email, session: Session) -> User: diff --git a/app/utils/utils.py b/app/internal/utils.py similarity index 100% rename from app/utils/utils.py rename to app/internal/utils.py diff --git a/app/main.py b/app/main.py index 30763e61..60bce750 100644 --- a/app/main.py +++ b/app/main.py @@ -1,31 +1,19 @@ import uvicorn -from fastapi import Depends, FastAPI, Form, Request -from fastapi.responses import RedirectResponse +from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates -from sqlalchemy.orm import Session -from starlette.status import HTTP_302_FOUND -from app.database.database import SessionLocal, engine +from app.config import templates +from app.database.database import engine from app.database.models import Base -from app.internal.share_event import accept -from app.utils.invitation import get_all_invitations, get_invitation_by_id +from app.routers import invitation app = FastAPI() app.mount("/static", StaticFiles(directory="static"), name="static") -templates = Jinja2Templates(directory="templates") +app.include_router(invitation.invitation) Base.metadata.create_all(bind=engine) -def get_db(): - db = SessionLocal() - try: - yield db - finally: - db.close() - - @app.get("/") def home(request: Request): return templates.TemplateResponse("home.html", { @@ -34,28 +22,6 @@ def home(request: Request): }) -@app.get("/invitations") -def view_invitations(request: Request, db: Session = Depends(get_db)): - return templates.TemplateResponse("invitations.html", { - "request": request, - # 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(session=db), - "message": "Hello, World!" - }) - - -@app.post("/invitations") -async def accept_invitations( - invite_id: int = Form(...), - db: Session = Depends(get_db) -): - invitation = get_invitation_by_id(invite_id, session=db) - accept(invitation, db) - return RedirectResponse("/invitations", status_code=HTTP_302_FOUND) - - @app.get("/profile") def profile(request: Request): diff --git a/app/routers/invitation.py b/app/routers/invitation.py new file mode 100644 index 00000000..6b7b9d63 --- /dev/null +++ b/app/routers/invitation.py @@ -0,0 +1,34 @@ +from fastapi import APIRouter, Depends, Form, Request +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session +from starlette.status import HTTP_302_FOUND + +from app.config import templates +from app.dependencies import get_db +from app.internal.share.share_event import accept +from app.internal.invitation import get_all_invitations, get_invitation_by_id + +invitation = APIRouter( + prefix="/invitations", + tags=["invitation"], + dependencies=[Depends(get_db)] +) + + +@invitation.get("/") +def view_invitations(request: Request, db: Session = Depends(get_db)): + return templates.TemplateResponse("invitations.html", { + "request": request, + # 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(session=db), + "message": "Hello, World!" + }) + + +@invitation.post("/") +async def accept_invitations(invite_id: int = Form(...), db: Session = Depends(get_db)): + i = get_invitation_by_id(invite_id, session=db) + accept(i, db) + return RedirectResponse("/invitations", status_code=HTTP_302_FOUND) diff --git a/app/templates/invitations.html b/app/templates/invitations.html index 9f07600c..c85c2bff 100644 --- a/app/templates/invitations.html +++ b/app/templates/invitations.html @@ -10,7 +10,7 @@

{{message}}

{% if invitations %}
{% for i in invitations %} -
+ {{ i.event.owner.username }} - {{ i.event.title }} ({{ i.event.start }}) ({{ i.status }}) diff --git a/tests/test_export.py b/tests/test_export.py index 5bdbe149..fea7366a 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -1,7 +1,7 @@ from icalendar import vCalAddress from app.config import ICAL_VERSION, PRODUCT_ID -from app.utils.export import ( +from app.internal.share.export import ( create_ical_calendar, create_ical_event, event_to_ical ) diff --git a/tests/test_invitation.py b/tests/test_invitation.py index 196bcaaf..c3099fe7 100644 --- a/tests/test_invitation.py +++ b/tests/test_invitation.py @@ -1,4 +1,4 @@ -from app.utils.invitation import get_all_invitations, get_invitation_by_id +from app.internal.invitation import get_all_invitations, get_invitation_by_id class TestInvitations: diff --git a/tests/test_share_event.py b/tests/test_share_event.py index 7006c524..e910132a 100644 --- a/tests/test_share_event.py +++ b/tests/test_share_event.py @@ -1,7 +1,7 @@ -from app.internal.share_event import ( +from app.internal.share.share_event import ( accept, send_in_app_invitation, sort_emails ) -from app.utils.invitation import get_all_invitations +from app.internal.invitation import get_all_invitations class TestShareEvent: diff --git a/tests/test_user.py b/tests/test_user.py index d344efc1..ec418ff1 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -1,4 +1,4 @@ -from app.utils.user import create_user, does_user_exist, get_users +from app.internal.user import create_user, does_user_exist, get_users class TestUser: diff --git a/tests/test_utils.py b/tests/test_utils.py index 33505410..a6164281 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,6 @@ from sqlalchemy.orm import Session -from app.utils.utils import save +from app.internal.utils import save class TestUtils: diff --git a/tests/utils.py b/tests/utils.py index b955fb55..58ffdbd0 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -10,4 +10,4 @@ def create_model(session: Session, model_class, **kw): def delete_instance(session: Session, instance): session.delete(instance) - session.commit() \ No newline at end of file + session.commit() From 1e3b912ed08acb864fd72157377ea9310899c4d0 Mon Sep 17 00:00:00 2001 From: Sagi Zaid Or Date: Sun, 17 Jan 2021 21:03:40 +0200 Subject: [PATCH 017/108] working day view with jinja2 template --- app/event.py | 34 +++++++++++++++++++ app/main.py | 22 ++++++++++++- app/static/dayview.css | 67 ++++++++++++++++++++++++++++++++++++++ app/templates/dayview.html | 47 ++++++++++++++++++++++++++ 4 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 app/event.py create mode 100644 app/static/dayview.css create mode 100644 app/templates/dayview.html diff --git a/app/event.py b/app/event.py new file mode 100644 index 00000000..d53b8208 --- /dev/null +++ b/app/event.py @@ -0,0 +1,34 @@ +from datetime import datetime + + +class Event: + def _minutes_position(self, minutes: int) -> int: + min = 0 + max = 15 + for i in range(1, 5): + if min <= minutes < max: + return i + min = max + max += 15 + + def _get_position(self, time: datetime) -> int: + return time.hour * 4 + self._minutes_position(time.minute) + 5 + + def _set_grid_position(self) -> None: + start = self._get_position(self.start_time) + end = self._get_position(self.end_time) + self.grid_position = f'{start} / {end}' + + def _set_total_time(self): + self.total_time = self.start_time.strftime("%H:%M") + ' - ' + self.end_time.strftime("%H:%M") + + def __init__(self, id: int, color: str, content: str, start_date_n_time: datetime, end_date_n_time: datetime) -> None: + self.id = id + self.color = color + self.content = content + self.start_time = start_date_n_time + self.end_time = end_date_n_time + self._set_total_time() + self._set_grid_position() + + diff --git a/app/main.py b/app/main.py index 535a690b..ad4d8e60 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,6 @@ import uvicorn +import datetime +from event import Event from fastapi.staticfiles import StaticFiles from fastapi import FastAPI, Request from fastapi.templating import Jinja2Templates @@ -19,5 +21,23 @@ def home(request: Request): "message": "Hello, World!" }) + +@app.get("/dayview") +def dayview(request: Request): + start = datetime.datetime(year=2021, month=1, day=27, hour=7, minute=13) + end = datetime.datetime(year=2021, month=1, day=27, hour=8, minute=42) + event1 = Event(id=1, color='#FFDE4D', content='do nothing', start_date_n_time=start, end_date_n_time=end) + start = datetime.datetime(year=2021, month=1, day=27, hour=9, minute=13) + end = datetime.datetime(year=2021, month=1, day=27, hour=11, minute=55) + event2 = Event(id=2, color='#EF5454', content='this line is too long for this shit and i keep on writing until there will be no more spaceeeee', start_date_n_time=start, end_date_n_time=end) + events = [event1, event2] + return templates.TemplateResponse("dayview.html", { + "request": request, + "events": events, + "MONTH": start.strftime("%B").upper(), + "DAY": start.day + }) + + if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file + uvicorn.run('main', host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/app/static/dayview.css b/app/static/dayview.css new file mode 100644 index 00000000..de058e66 --- /dev/null +++ b/app/static/dayview.css @@ -0,0 +1,67 @@ + +:root { + --theme-blue:#30465D; + --theme-yellow:#FFDE4D; + --theme-red:#EF5454; + --theme-grey:#E7E7E7; + --theme-ligth-grey:#F7F7F7; +} + +html { + font-family: 'Assistant', sans-serif; + text-align: center; +} + +#phoneframe { + width: 260px; + height: 480px; + border: 1px solid black; + box-shadow: 0px 0px 10px; + overflow: scroll; + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; +} + +/* Hide scrollbar for Chrome, Safari and Opera */ +#phoneframe::-webkit-scrollbar { + display: none; + } + +#toptab { + background-color: var(--theme-blue); +} + + +.scedual { + display: grid; + grid-template-rows: 1; +} + +.times { + 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; +} + +.eventgrid { + grid-row: 1 / -1; + grid-column: 1 / -1; + display: grid; + grid-template-rows: repeat(100, 0.375rem); + z-index: 39; +} + +.hourbar { + margin-top: -1px; +} + +.total-time { + font-size: 0.8em; +} \ No newline at end of file diff --git a/app/templates/dayview.html b/app/templates/dayview.html new file mode 100644 index 00000000..f118bf0b --- /dev/null +++ b/app/templates/dayview.html @@ -0,0 +1,47 @@ + + + + + + + + + dayview + + + +
+
+ + + + {{MONTH}} + {{DAY}} +
+
+
+ {% for i in range(24)%} +
+ {% set i = i|string() %} + {{i.zfill(2)}}:00 +
+ {% endfor %} +
+
+ {% for event in events %} +
+

{{event.content}}

+

{{event.total_time}}

+
+ {% endfor %} +
+
+ {% for i in range(25)%} +
000
+ {% endfor %} +
+
+
+ + + \ No newline at end of file From 43c57ce0a69488dae2885dd0c88e51fc63f5bebc Mon Sep 17 00:00:00 2001 From: Sagi Zaid Or Date: Sun, 17 Jan 2021 23:49:50 +0200 Subject: [PATCH 018/108] with tests for pytest --- Scripts/chardetect.exe | Bin 0 -> 103314 bytes app/event.py | 6 ++--- app/main.py | 46 +++++++++++++++++++++++++------------- app/static/dayview.css | 2 +- app/static/style.css | 4 ---- tests/test_dayview.py | 49 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 84 insertions(+), 23 deletions(-) create mode 100644 Scripts/chardetect.exe create mode 100644 tests/test_dayview.py diff --git a/Scripts/chardetect.exe b/Scripts/chardetect.exe new file mode 100644 index 0000000000000000000000000000000000000000..0272bcc23c271d22df8cb4aea8d8e13503eb0b24 GIT binary patch literal 103314 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 literal 0 HcmV?d00001 diff --git a/app/event.py b/app/event.py index d53b8208..2a0c52bc 100644 --- a/app/event.py +++ b/app/event.py @@ -22,12 +22,12 @@ def _set_grid_position(self) -> None: def _set_total_time(self): self.total_time = self.start_time.strftime("%H:%M") + ' - ' + self.end_time.strftime("%H:%M") - def __init__(self, id: int, color: str, content: str, start_date_n_time: datetime, end_date_n_time: datetime) -> None: + def __init__(self, id: int, color: str, content: str, start_datetime: str, end_datetime: str) -> None: self.id = id self.color = color self.content = content - self.start_time = start_date_n_time - self.end_time = end_date_n_time + self.start_time = datetime.strptime(start_datetime, "%d/%m/%Y %H:%M") + self.end_time = datetime.strptime(end_datetime, "%d/%m/%Y %H:%M") self._set_total_time() self._set_grid_position() diff --git a/app/main.py b/app/main.py index ccbb3616..3a29c2ed 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,7 @@ import uvicorn import datetime -from event import Event + +from app.event import Event from fastapi.staticfiles import StaticFiles from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles @@ -8,10 +9,13 @@ app = FastAPI() - -app.mount("/static", StaticFiles(directory="static"), name="static") - templates = Jinja2Templates(directory="templates") +try: + app.mount("/app/static", StaticFiles(directory="static"), name="static") +except RuntimeError: + app.mount("/app/static", StaticFiles(directory="app/static"), name="static") + templates = Jinja2Templates(directory="app/templates") + @@ -25,20 +29,32 @@ def home(request: Request): @app.get("/dayview") def dayview(request: Request): - start = datetime.datetime(year=2021, month=1, day=27, hour=7, minute=13) - end = datetime.datetime(year=2021, month=1, day=27, hour=8, minute=42) - event1 = Event(id=1, color='#FFDE4D', content='do nothing', start_date_n_time=start, end_date_n_time=end) - start = datetime.datetime(year=2021, month=1, day=27, hour=9, minute=13) - end = datetime.datetime(year=2021, month=1, day=27, hour=11, minute=55) - event2 = Event(id=2, color='#EF5454', content='this line is too long for this shit and i keep on writing until there will be no more spaceeeee', start_date_n_time=start, end_date_n_time=end) - events = [event1, event2] + event_id = 123 + color = 'red' + content = 'nothing' + start = "03/2/2021 4:05" + end = "03/2/2021 4:20" + events = [Event(id=event_id, color=color, + content=content,start_datetime=start, + end_datetime=end)] return templates.TemplateResponse("dayview.html", { "request": request, "events": events, - "MONTH": start.strftime("%B").upper(), - "DAY": start.day + "MONTH": events[0].start_time.strftime("%B").upper(), + "DAY": events[0].start_time.day + })''' + +@app.post("/dayview") +async def dayview(request: Request): + form = await request.json() + events = [Event(**event) for event in form['events']] + return templates.TemplateResponse("dayview.html", { + "request": request, + "events": events, + "MONTH": events[0].start_time.strftime("%B").upper(), + "DAY": form['day'] }) - +''' @app.get("/profile") def profile(request: Request): @@ -54,4 +70,4 @@ def profile(request: Request): }) if __name__ == "__main__": - uvicorn.run('main', host="0.0.0.0", port=8000, reload=True) \ No newline at end of file + uvicorn.run('main:app', host="0.0.0.0", port=8000, reload=True) diff --git a/app/static/dayview.css b/app/static/dayview.css index de058e66..93e09c59 100644 --- a/app/static/dayview.css +++ b/app/static/dayview.css @@ -63,5 +63,5 @@ html { } .total-time { - font-size: 0.8em; + font-size: 0.6em; } \ No newline at end of file diff --git a/app/static/style.css b/app/static/style.css index b1623ab6..12bd572c 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -1,7 +1,3 @@ -<<<<<<< HEAD -div{ - background-color: red; -======= body { background: #A1FFCE; background: -webkit-linear-gradient(to right, #FAFFD1, #A1FFCE); diff --git a/tests/test_dayview.py b/tests/test_dayview.py new file mode 100644 index 00000000..5be7c399 --- /dev/null +++ b/tests/test_dayview.py @@ -0,0 +1,49 @@ +from datetime import datetime + +import pytest + +from app.event import Event +from app.main import app +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(): + client = TestClient(app) + return client + +@pytest.fixture +def event(): + event_id = 123 + color = 'red' + content = 'nothing' + start = "03/2/2021 4:05" + end = "03/2/2021 4:20" + event = Event(id=event_id, color=color, + content=content,start_datetime=start, + end_datetime=end) + return event + + +def test_new_event(event): + assert event.id == 123 + assert event.color == 'red' + assert event.content == 'nothing' + assert event.total_time == '04:05 - 04:20' + assert event.grid_position == '22 / 23' + + +def test_dayview_html(client, event): + events = [{"id":event.id, "color":event.color, + "content":event.content, + "start_datetime":"3/2/2021 04:05", + "end_datetime":"3/2/2021 04:20", + }] + day = {"year":2021, "month":2, "day":3, "events":events} + response = client.post("/dayview", json=day) + res = response.content.decode("utf-8") + print(res) + assert 'grid-row: 22 / 23;' in res + assert '
04:05 - 04:20

' in res + From 4cc2447ecec9933ca3af7db34f4a3df344617634 Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Mon, 18 Jan 2021 17:43:02 +0200 Subject: [PATCH 019/108] feat: get weather forecast - fixes according to requested changes. --- app/routers/weather_forecast.py | 232 ++++++++++++++++++++++++++++ app/weather_forecast.py | 265 -------------------------------- tests/test_weather_forecast.py | 31 ++-- 3 files changed, 246 insertions(+), 282 deletions(-) create mode 100644 app/routers/weather_forecast.py delete mode 100644 app/weather_forecast.py diff --git a/app/routers/weather_forecast.py b/app/routers/weather_forecast.py new file mode 100644 index 00000000..e7e8aff9 --- /dev/null +++ b/app/routers/weather_forecast.py @@ -0,0 +1,232 @@ +import datetime +from dotenv import load_dotenv +from os import getenv +import requests + + +# This feature requires an API KEY - get yours free @ visual-crossing-weather.p.rapidapi.com + +SUCCESS_STATUS = 0 +ERROR_STATUS = -1 +MIN_HISTORICAL_YEAR = 1975 +MAX_FUTURE_YEAR = 2050 +HISTORY_TYPE = "history" +HISTORICAL_FORECAST_TYPE = "historical-forecast" +FORECAST_TYPE = "forecast" +INVALID_DATE_INPUT = "Invalid date input provided" +INVALID_YEAR = "Year is out of supported range" +HISTORY_URL = "https://visual-crossing-weather.p.rapidapi.com/history" +FORECAST_URL = "https://visual-crossing-weather.p.rapidapi.com/forecast" +HEADERS = {'x-rapidapi-host': "visual-crossing-weather.p.rapidapi.com"} +BASE_QUERY_STRING = {"aggregateHours": "24", "unitGroup": "metric", "dayStartTime": "00:00:01", + "contentType": "json", "dayEndTime": "23:59:59", "shortColumnNames": "True"} +HISTORICAL_AVERAGE_NUM_OF_YEARS = 3 +NO_API_RESPONSE = "No response from server" + + +def validate_date_input(requested_date): + """ date validation. + Args: + requested_date (date) - date requested for forecast. + Returns: + (bool) - validate ended in success or not. + (str) - error message. + """ + if isinstance(requested_date, datetime.date): + if MIN_HISTORICAL_YEAR <= requested_date.year <= MAX_FUTURE_YEAR: + return True, None + else: + return False, INVALID_YEAR + else: + return False, INVALID_DATE_INPUT + + +def get_data_from_weather_api(url, input_query_string): + """ get the relevant weather data by calling the "Visual Crossing Weather" API. + Args: + url (str) - API url. + input_query_string (dict) - input for the API. + Returns: + (json) - JSON data returned by the API. + (str) - error message. + """ + load_dotenv() + HEADERS['x-rapidapi-key'] = getenv('WEATHER_API_KEY') + try: + response = requests.request("GET", url, headers=HEADERS, params=input_query_string) + except requests.exceptions.RequestException: + return None, NO_API_RESPONSE + if response: + try: + return response.json()["locations"], None + except KeyError: + return None, response.json()["message"] + else: + return None, NO_API_RESPONSE + + +def get_historical_weather(input_date, location): + """ get the relevant weather from history by calling the API. + Args: + input_date (date) - date requested for forecast. + location (str) - location name. + Returns: + weather_data (json) - output weather data. + error_text (str) - error message. + """ + input_query_string = BASE_QUERY_STRING + input_query_string["startDateTime"] = input_date.isoformat() + input_query_string["endDateTime"] = (input_date + datetime.timedelta(days=1)).isoformat() + input_query_string["location"] = location + api_json, error_text = get_data_from_weather_api(HISTORY_URL, input_query_string) + location_found = list(api_json.keys())[0] + if api_json: + weather_data = {'MinTempCel': api_json[location_found]['values'][0]['mint'], + 'MaxTempCel': api_json[location_found]['values'][0]['maxt'], + 'Conditions': api_json[location_found]['values'][0]['conditions'], + 'Address': location_found} + return weather_data, None + else: + return None, error_text + + +def get_forecast_weather(input_date, location): + """ get the relevant weather forecast by calling the API. + Args: + input_date (date) - date requested for forecast. + location (str) - location name. + Returns: + weather_data (json) - output weather data. + error_text (str) - error message. + """ + input_query_string = BASE_QUERY_STRING + input_query_string["location"] = location + api_json, error_text = get_data_from_weather_api(FORECAST_URL, input_query_string) + location_found = list(api_json.keys())[0] + if api_json: + for i in range(len(api_json[location_found]['values'])): + # find relevant date from API output + if str(input_date) == api_json[location_found]['values'][i]['datetimeStr'][:10]: + weather_data = {'MinTempCel': api_json[location_found]['values'][i]['mint'], + 'MaxTempCel': api_json[location_found]['values'][i]['maxt'], + 'Conditions': api_json[location_found]['values'][i]['conditions'], + 'Address': location_found} + return weather_data, None + else: + return None, error_text + + +def get_history_relevant_year(day, month): + """ return the relevant year in order to call the get_historical_weather function with. + decided according to if date occurred this year or not. + Args: + day (int) - day part of date. + month (int) - month part of date. + Returns: + last_year (int) - relevant year. + """ + try: + relevant_date = datetime.datetime(year=datetime.datetime.now().year, month=month, day=day) + except ValueError: # only if the day & month are 29.02 and there is no such date this year + relevant_date = datetime.datetime(year=datetime.datetime.now().year, month=month, day=day - 1) + if datetime.datetime.now() > relevant_date: + last_year = datetime.datetime.now().year + else: + last_year = datetime.datetime.now().year - 1 + return last_year + + +def get_forecast_by_historical_data(day, month, location): + """ get historical average weather by calling the get_historical_weather function. + Args: + day (int) - day part of date. + month (int) - month part of date. + location (str) - location name. + Returns: + (json) - output weather data. + (str) - error message. + """ + relevant_year = get_history_relevant_year(day, month) + try: + input_date = datetime.datetime(year=relevant_year, month=month, day=day) + except ValueError: + # only if the day & month are 29.02 and there is no such date on the relevant year + input_date = datetime.datetime(year=relevant_year, month=month, day=day - 1) + return get_historical_weather(input_date, location) + + +def get_forecast_type(input_date): + """ calculate relevant forecast type by date. + Args: + input_date (date) - date requested for forecast. + Returns: + (str) - "forecast" / "history" / "historical forecast". + """ + delta = (input_date - datetime.datetime.now().date()).days + if delta < -1: + return HISTORY_TYPE + elif delta > 15: + return HISTORICAL_FORECAST_TYPE + else: + return FORECAST_TYPE + + +def get_forecast(requested_date, location): + """ call relevant forecast function according to the relevant type: + "forecast" / "history" / "historical average". + Args: + requested_date (date) - date requested for forecast. + location (str) - location name. + Returns: + weather_json (json) - output weather data. + """ + forecast_type = get_forecast_type(requested_date) + if forecast_type == HISTORY_TYPE: + weather_json, error_text = get_historical_weather(requested_date, location) + if forecast_type == FORECAST_TYPE: + weather_json, error_text = get_forecast_weather(requested_date, location) + if forecast_type == HISTORICAL_FORECAST_TYPE: + weather_json, error_text = get_forecast_by_historical_data( + requested_date.day, requested_date.month, location) + if weather_json: + weather_json['ForecastType'] = forecast_type + return weather_json, error_text + + +def get_weather_data(requested_date, location): + """ get weather data for date & location - main function. + Args: + requested_date (date) - date requested for forecast. + location (str) - location name. + Returns: dictionary with the following entries: + Status - success / failure. + ErrorDescription - error description (relevant only in case of error). + MinTempCel - minimum degrees in Celsius. + MaxTempCel - maximum degrees in Celsius. + MinTempFar - minimum degrees in Fahrenheit. + MaxTempFar - maximum degrees in Fahrenheit. + ForecastType: + "forecast" - relevant for the upcoming 15 days. + "history" - historical data. + "historical average" - average of the last 3 years on that date. + relevant for future dates (more then forecast). + Address - The location found by the service. + """ + output = {} + requested_date = datetime.date(requested_date.year, requested_date.month, requested_date.day) + valid_input, error_text = validate_date_input(requested_date) + if valid_input: + weather_json, error_text = get_forecast(requested_date, location) + if error_text: + output["Status"] = ERROR_STATUS + output["ErrorDescription"] = error_text + else: + output["Status"] = SUCCESS_STATUS + output["ErrorDescription"] = None + output["MinTempFar"] = round((weather_json['MinTempCel'] * 9/5) + 32) + output["MaxTempFar"] = round((weather_json['MaxTempCel'] * 9/5) + 32) + output.update(weather_json) + else: + output["Status"] = ERROR_STATUS + output["ErrorDescription"] = error_text + return output diff --git a/app/weather_forecast.py b/app/weather_forecast.py deleted file mode 100644 index 0966f9e5..00000000 --- a/app/weather_forecast.py +++ /dev/null @@ -1,265 +0,0 @@ -from datetime import datetime, timedelta -from dotenv import load_dotenv -from os import getenv -import requests - - -""" This feature requires an API KEY - get yours free @ visual-crossing-weather.p.rapidapi.com """ - -SUCCESS_STATUS = 0 -ERROR_STATUS = -1 -HISTORY_TYPE = "history" -HISTORICAL_AVERAGE_TYPE = "historical-average" -FORECAST_TYPE = "forecast" -HISTORY_URL = "https://visual-crossing-weather.p.rapidapi.com/history" -FORECAST_URL = "https://visual-crossing-weather.p.rapidapi.com/forecast" -HEADERS = { - 'x-rapidapi-host': "visual-crossing-weather.p.rapidapi.com" - } -BASE_QUERY_STRING = {"aggregateHours": "24", "unitGroup": "metric", "dayStartTime": "00:00:01", - "contentType": "json", "dayEndTime": "23:59:59", "shortColumnNames": "True"} -HISTORICAL_AVERAGE_NUM_OF_YEARS = 3 -OUTPUT = {"Status": SUCCESS_STATUS, "ErrorDescription": None, "MinTempCel": None, "MaxTempCel": None, - "MinTempFar": None, "MaxTempFar": None, "Conditions": None, "ForecastType": None} - - -def validate_date_input(day, month, year): - """ date validation. - Args: - day (int / str) - day part of date. - month (int / str) - month part of date. - year (int / str) - year part of date. - Returns: - (bool) - validate ended in success or not. - day (int) - day part of date. - month (int) - month part of date. - year (int) - year part of date. - """ - try: - day = int(day) - month = int(month) - year = int(year) - except ValueError: - return False, day, month, year - if 1975 <= year <= 2050: - try: - datetime(year=year, month=month, day=day) - except ValueError: - return False, day, month, year - else: - return False, day, month, year - return True, day, month, year - - -def get_data_from_api(url, input_query_string): - """ get the relevant weather data by calling the "Visual Crossing Weather" API. - Args: - url (str) - API url. - input_query_string (dict) - input for the API. - Returns: - success_in_get_weather_data (bool) - did the API call ended in success or failure (location not found etc). - response_json (json dict) - relevant part (data / error) of the JSON returned by the API. - """ - load_dotenv() - HEADERS['x-rapidapi-key'] = getenv('WEATHER_API_KEY') - success_in_get_weather_data = True - response = requests.request("GET", url, headers=HEADERS, params=input_query_string) - try: - response_json = response.json()["locations"] - except KeyError: - success_in_get_weather_data = False - response_json = response.json() - return success_in_get_weather_data, response_json - - -def get_historical_weather(input_date, location): - """ get the relevant weather from history by calling the API. - Args: - input_date (date) - day part of date. - location (str) - location name. - Returns: - (int) - minimum degrees in Celsius. - (int) - maximum degrees in Celsius. - (str) - weather conditions. - (str) - location / error description. - """ - input_query_string = BASE_QUERY_STRING - input_query_string["startDateTime"] = input_date.isoformat() - input_query_string["endDateTime"] = (input_date + timedelta(days=1)).isoformat() - input_query_string["location"] = location - success_in_get_weather_data, api_json = get_data_from_api(HISTORY_URL, input_query_string) - if success_in_get_weather_data: - for item in api_json: - # print("historical:", api_json[item]['values'][0]['mint'], api_json[item]['values'][0]['maxt']) - min_temp = api_json[item]['values'][0]['mint'] - max_temp = api_json[item]['values'][0]['maxt'] - conditions = api_json[item]['values'][0]['conditions'] - return min_temp, max_temp, conditions, api_json[item]['address'] - else: - return None, None, None, api_json['message'] - - -def get_forecast_weather(input_date, location): - """ get the relevant weather forecast by calling the API. - Args: - input_date (date) - day part of date. - location (str) - location name. - Returns: - (int) - minimum degrees in Celsius. - (int) - maximum degrees in Celsius. - (str) - weather conditions. - (str) - location / error description. - """ - input_query_string = BASE_QUERY_STRING - input_query_string["location"] = location - success_in_get_weather_data, api_json = get_data_from_api(FORECAST_URL, input_query_string) - if success_in_get_weather_data: - for item in api_json: - for i in range(len(api_json[item]['values'])): - if input_date == datetime.fromisoformat(api_json[item]['values'][i]['datetimeStr'][:-6]): - min_temp = api_json[item]['values'][i]['mint'] - max_temp = api_json[item]['values'][i]['maxt'] - conditions = api_json[item]['values'][i]['conditions'] - return min_temp, max_temp, conditions, api_json[item]['address'] - else: - return None, None, None, api_json['message'] - - -def get_relevant_years_for_historical_average(day, month): - """ get a list for relevant years to call the get_historical_weather function - according to if date occurred this year or not. - Args: - day (int) - day part of date. - month (int) - month part of date. - Returns: - (list) - relevant years range. - """ - if datetime.now() > datetime(year=datetime.now().year, month=month, day=day): - last_year = datetime.now().year - else: - last_year = datetime.now().year - 1 - return list(range(last_year, last_year - HISTORICAL_AVERAGE_NUM_OF_YEARS, -1)) - - -def get_historical_average_weather(day, month, location): - """ get historical average weather by calling the get_historical_weather function - several times and calculate average. - Args: - day (int) - day part of date. - month (int) - month part of date. - location (str) - location name. - Returns: - (int) - minimum average degrees in Celsius. - (int) - maximum average degrees in Celsius. - (str) - location / error description. - """ - sum_min = 0 - sum_max = 0 - if day == 29 and month == 2: - day = 28 - relevant_years = (get_relevant_years_for_historical_average(day, month)) - for relevant_year in relevant_years: - input_date = datetime(year=relevant_year, month=month, day=day) - min_temp, max_temp, conditions, description = get_historical_weather(input_date, location) - if min_temp is not None: - sum_min += min_temp - sum_max += max_temp - if min_temp is not None: - return sum_min / HISTORICAL_AVERAGE_NUM_OF_YEARS, sum_max / HISTORICAL_AVERAGE_NUM_OF_YEARS, description - else: - return None, None, description - - -def calculate_forecast_type(input_date): - """ calculate relevant forecast type by date. - Args: - input_date (date) - day part of date. - Returns: - output_type (str) - "forecast" / "history" / "historical average". - """ - delta = (input_date - datetime.now()).days - if delta < -1: - output_type = HISTORY_TYPE - elif delta > 15: - output_type = HISTORICAL_AVERAGE_TYPE - else: - output_type = FORECAST_TYPE - return output_type - - -def get_forecast(day, month, year, location): - """ call relevant forecast function according to the relevant type: - "forecast" / "history" / "historical average". - Args: - day (int) - day part of date. - month (int) - month part of date. - year (int) - year part of date. - location (str) - location name. - Returns: - ForecastType (str): - "forecast" - relevant for the upcoming 15 days. - "history" - historical data. - "historical average" - average of the last 3 years on that date. - relevant for future dates (more then forecast). - min_temp (int) - minimum degrees in Celsius. - max_temp (int) - maximum degrees in Celsius. - conditions (str) - weather conditions. - Description (str) - location / error description. - """ - input_date = datetime(year=year, month=month, day=day) - forecast_type = calculate_forecast_type(input_date) - if forecast_type == HISTORY_TYPE: - min_temp, max_temp, conditions, description = get_historical_weather(input_date, location) - if forecast_type == FORECAST_TYPE: - min_temp, max_temp, conditions, description = get_forecast_weather(input_date, location) - if forecast_type == HISTORICAL_AVERAGE_TYPE: - min_temp, max_temp, description = get_historical_average_weather(day, month, location) - conditions = "" - return forecast_type, min_temp, max_temp, conditions, description - - -def get_weather_data(day, month, year, location): - """ get weather data for date & location - main function. - Args: - day (int / str) - day part of date. - month (int / str) - month part of date. - year (int / str) - year part of date. - location (str) - location name. - Returns: dictionary with the following entries: - Status - success / failure. - ErrorDescription - error description (relevant only in case of error). - MinTempCel - minimum degrees in Celsius. - MaxTempCel - maximum degrees in Celsius. - MinTempFar - minimum degrees in Fahrenheit. - MaxTempFar - maximum degrees in Fahrenheit. - ForecastType: - "forecast" - relevant for the upcoming 15 days. - "history" - historical data. - "historical average" - average of the last 3 years on that date. - relevant for future dates (more then forecast). - Address - The location found by the service. - """ - output = OUTPUT - valid_input, day, month, year = validate_date_input(day, month, year) - if valid_input: - forecast_type, min_temp, max_temp, conditions, description = get_forecast(day, month, year, location) - if min_temp is None: - output["Status"] = ERROR_STATUS - output["ErrorDescription"] = description - else: - output["Status"] = SUCCESS_STATUS - output["MinTempCel"] = round(min_temp) - output["MaxTempCel"] = round(max_temp) - output["MinTempFar"] = round((min_temp * 9/5) + 32) - output["MaxTempFar"] = round((max_temp * 9/5) + 32) - output["Conditions"] = conditions - output["ForecastType"] = forecast_type - output["Address"] = description - else: - output["Status"] = ERROR_STATUS - output["ErrorDescription"] = "Invalid date input provided" - return output - - -if __name__ == "__main__": - print(get_weather_data("29", "02", 2024, "tel aviv")) diff --git a/tests/test_weather_forecast.py b/tests/test_weather_forecast.py index ccc4d079..77973214 100644 --- a/tests/test_weather_forecast.py +++ b/tests/test_weather_forecast.py @@ -1,30 +1,27 @@ -from datetime import datetime, timedelta +import datetime import pytest -from app.weather_forecast import get_weather_data +from app.routers.weather_forecast import get_weather_data DATA_GET_WEATHER = [ - pytest.param(4, "d", 2020, "tel aviv", 0, marks=pytest.mark.xfail, id="ivalid input type"), - pytest.param(4, 4, 2020, "tel aviv", 0, id="basic historical test"), - pytest.param(4, 4, 2070, "tel aviv", 0, marks=pytest.mark.xfail, id="year out of range"), - pytest.param(1, 1, 2030, "tel aviv", 0, id="basic historical forecast test - prior in current year"), - pytest.param(31, 12, 2030, "tel aviv", 0, id="basic historical forecast test - future"), - pytest.param(15, 1, 2020, "neo", 0, marks=pytest.mark.xfail, id="location not found test"), - pytest.param(32, 1, 2020, "tel aviv", 0, marks=pytest.mark.xfail, id="invalid date"), - pytest.param(29, 2, 2024, "tel aviv", 0, id="basic historical forecast test"), + pytest.param(2020, "tel aviv", 0, marks=pytest.mark.xfail, id="invalid input type"), + pytest.param(datetime.datetime(day=4, month=4, year=2070), "tel aviv", 0, marks=pytest.mark.xfail, id="year out of range"), + pytest.param(datetime.datetime(day=4, month=4, year=2020), "tel aviv", 0, id="basic historical test"), + pytest.param(datetime.datetime(day=1, month=1, year=2030), "tel aviv", 0, id="basic historical forecast test - prior in current year"), + pytest.param(datetime.datetime(day=31, month=12, year=2030), "tel aviv", 0, id="basic historical forecast test - future"), + pytest.param(datetime.datetime(day=29, month=2, year=2024), "tel aviv", 0, id="basic historical forecast test"), + pytest.param(datetime.datetime(day=15, month=1, year=2020), "neo", 0, marks=pytest.mark.xfail, id="location not found test"), ] -@pytest.mark.parametrize('day, month, year, location, expected', DATA_GET_WEATHER) -def test_get_weather_data(day, month, year, location, expected): - output = get_weather_data(day, month, year, location) +@pytest.mark.parametrize('requested_date, location, expected', DATA_GET_WEATHER) +def test_get_weather_data(requested_date, location, expected): + output = get_weather_data(requested_date, location) assert output['Status'] == expected def test_get_forecast_weather_data(): - temp_date = datetime.now() + timedelta(days=1) - output = get_weather_data(temp_date.day, temp_date.month, temp_date.year, "tel aviv") + temp_date = datetime.datetime.now() + datetime.timedelta(days=2) + output = get_weather_data(temp_date, "tel aviv") assert output['Status'] == 0 - -# pytest.param(15, 1, 2021, "tel aviv", 0, id="basic forecast test"), From 0dc1ae63abd031cc80a7f346644ea38b4ab036eb Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Mon, 18 Jan 2021 18:22:59 +0200 Subject: [PATCH 020/108] change file structure --- app/internal/event.py | 0 app/internal/invitation.py | 26 ------------------- app/internal/share/__init__.py | 0 app/{internal/share => routers}/export.py | 0 .../share/share_event.py => routers/share.py} | 4 +-- app/{internal => routers}/user.py | 0 6 files changed, 2 insertions(+), 28 deletions(-) delete mode 100644 app/internal/event.py delete mode 100644 app/internal/invitation.py delete mode 100644 app/internal/share/__init__.py rename app/{internal/share => routers}/export.py (100%) rename app/{internal/share/share_event.py => routers/share.py} (95%) rename app/{internal => routers}/user.py (100%) diff --git a/app/internal/event.py b/app/internal/event.py deleted file mode 100644 index e69de29b..00000000 diff --git a/app/internal/invitation.py b/app/internal/invitation.py deleted file mode 100644 index 249f6a67..00000000 --- a/app/internal/invitation.py +++ /dev/null @@ -1,26 +0,0 @@ -from typing import List, Union - -from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.orm import Session - -from app.database.models import Invitation - - -def get_all_invitations(session: Session, **param) -> List[Invitation]: - """Returns all invitations filter by param.""" - - try: - invitations = list(session.query(Invitation).filter_by(**param)) - except SQLAlchemyError: - return [] - else: - return invitations - - -def get_invitation_by_id( - invitation_id: int, session: Session -) -> Union[Invitation, None]: - """Returns a 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/share/__init__.py b/app/internal/share/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/app/internal/share/export.py b/app/routers/export.py similarity index 100% rename from app/internal/share/export.py rename to app/routers/export.py diff --git a/app/internal/share/share_event.py b/app/routers/share.py similarity index 95% rename from app/internal/share/share_event.py rename to app/routers/share.py index 208e48dd..374ea8f6 100644 --- a/app/internal/share/share_event.py +++ b/app/routers/share.py @@ -3,8 +3,8 @@ from sqlalchemy.orm import Session from app.database.models import Event, Invitation, UserEvent -from app.internal.share.export import event_to_ical -from app.internal.user import does_user_exist, get_users +from app.routers.export import event_to_ical +from app.routers.user import does_user_exist, get_users from app.internal.utils import save diff --git a/app/internal/user.py b/app/routers/user.py similarity index 100% rename from app/internal/user.py rename to app/routers/user.py From 249523f9c85ae85b020dc9402d5faaa721086141 Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Mon, 18 Jan 2021 19:02:14 +0200 Subject: [PATCH 021/108] feat: get weather forecast - fixes according to requested changes. --- app/routers/weather_forecast.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/routers/weather_forecast.py b/app/routers/weather_forecast.py index e7e8aff9..88d3ed7a 100644 --- a/app/routers/weather_forecast.py +++ b/app/routers/weather_forecast.py @@ -1,6 +1,6 @@ import datetime from dotenv import load_dotenv -from os import getenv +import os import requests @@ -51,7 +51,7 @@ def get_data_from_weather_api(url, input_query_string): (str) - error message. """ load_dotenv() - HEADERS['x-rapidapi-key'] = getenv('WEATHER_API_KEY') + HEADERS['x-rapidapi-key'] = os.getenv('WEATHER_API_KEY') try: response = requests.request("GET", url, headers=HEADERS, params=input_query_string) except requests.exceptions.RequestException: From b0a8cc4979a345bb113cfd0a6d9f39a36d2072a4 Mon Sep 17 00:00:00 2001 From: Sagi Zaid Or Date: Mon, 18 Jan 2021 21:21:23 +0200 Subject: [PATCH 022/108] first functioning day-view html, css and router --- .gitignore | 3 + app/dependencies.py | 6 ++ app/main.py | 50 ++++------------ app/{event.py => routers/dayview.py} | 23 ++++++++ app/static/dayview.css | 7 ++- app/static/images/icons/close_sidebar.svg | 1 + app/templates/dayview.html | 70 +++++++++++++---------- tests/test_dayview.py | 22 ++++++- 8 files changed, 109 insertions(+), 73 deletions(-) rename app/{event.py => routers/dayview.py} (62%) create mode 100644 app/static/images/icons/close_sidebar.svg diff --git a/.gitignore b/.gitignore index 8764969f..58464adc 100644 --- a/.gitignore +++ b/.gitignore @@ -143,3 +143,6 @@ Scripts/pythonw.exe Scripts/uvicorn.exe pyvenv.cfg .gitignore +Scripts/coverage3.exe +Scripts/coverage.exe +Scripts/coverage-3.7.exe diff --git a/app/dependencies.py b/app/dependencies.py index e69de29b..07c20bc6 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -0,0 +1,6 @@ +import os + + +APP_PATH = os.path.dirname(os.path.realpath(__file__)) +STATIC_PATH = os.path.join(APP_PATH, "static") +TEMPLATES_PATH = os.path.join(APP_PATH, "templates") \ No newline at end of file diff --git a/app/main.py b/app/main.py index 3a29c2ed..0eb1fe01 100644 --- a/app/main.py +++ b/app/main.py @@ -1,22 +1,22 @@ import uvicorn -import datetime +import os -from app.event import Event -from fastapi.staticfiles import StaticFiles from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates +from app.routers import dayview + app = FastAPI() -templates = Jinja2Templates(directory="templates") -try: - app.mount("/app/static", StaticFiles(directory="static"), name="static") -except RuntimeError: - app.mount("/app/static", StaticFiles(directory="app/static"), name="static") - templates = Jinja2Templates(directory="app/templates") +app_path = os.path.dirname(os.path.realpath(__file__)) +static_path = os.path.join(app_path, "static") +templates_path = os.path.join(app_path, "templates") +app.mount("/static", StaticFiles(directory=static_path), name="static") +templates = Jinja2Templates(directory=templates_path) + @app.get("/") @@ -27,35 +27,6 @@ def home(request: Request): }) -@app.get("/dayview") -def dayview(request: Request): - event_id = 123 - color = 'red' - content = 'nothing' - start = "03/2/2021 4:05" - end = "03/2/2021 4:20" - events = [Event(id=event_id, color=color, - content=content,start_datetime=start, - end_datetime=end)] - return templates.TemplateResponse("dayview.html", { - "request": request, - "events": events, - "MONTH": events[0].start_time.strftime("%B").upper(), - "DAY": events[0].start_time.day - })''' - -@app.post("/dayview") -async def dayview(request: Request): - form = await request.json() - events = [Event(**event) for event in form['events']] - return templates.TemplateResponse("dayview.html", { - "request": request, - "events": events, - "MONTH": events[0].start_time.strftime("%B").upper(), - "DAY": form['day'] - }) -''' - @app.get("/profile") def profile(request: Request): @@ -69,5 +40,8 @@ def profile(request: Request): "events": upcouming_events }) +app.include_router(dayview.router) + + if __name__ == "__main__": uvicorn.run('main:app', host="0.0.0.0", port=8000, reload=True) diff --git a/app/event.py b/app/routers/dayview.py similarity index 62% rename from app/event.py rename to app/routers/dayview.py index 2a0c52bc..a187a4ff 100644 --- a/app/event.py +++ b/app/routers/dayview.py @@ -1,5 +1,16 @@ from datetime import datetime +from fastapi import APIRouter, Request +from fastapi.templating import Jinja2Templates + +from app.dependencies import TEMPLATES_PATH + + +templates = Jinja2Templates(directory=TEMPLATES_PATH) + + +router = APIRouter() + class Event: def _minutes_position(self, minutes: int) -> int: @@ -20,6 +31,8 @@ def _set_grid_position(self) -> None: self.grid_position = f'{start} / {end}' def _set_total_time(self): + length = self.end_time - self.start_time + self.length = length.seconds / 60 self.total_time = self.start_time.strftime("%H:%M") + ' - ' + self.end_time.strftime("%H:%M") def __init__(self, id: int, color: str, content: str, start_datetime: str, end_datetime: str) -> None: @@ -32,3 +45,13 @@ def __init__(self, id: int, color: str, content: str, start_datetime: str, end_d self._set_grid_position() +@router.post("/dayview") +async def dayview(request: Request): + form = await request.json() + events = [Event(**event) for event in form['events']] + return templates.TemplateResponse("dayview.html", { + "request": request, + "events": events, + "MONTH": events[0].start_time.strftime("%B").upper(), + "DAY": form['day'] + }) \ No newline at end of file diff --git a/app/static/dayview.css b/app/static/dayview.css index 93e09c59..c261f512 100644 --- a/app/static/dayview.css +++ b/app/static/dayview.css @@ -62,6 +62,11 @@ html { margin-top: -1px; } +.event { + font-size: 1rem; +} + .total-time { - font-size: 0.6em; + font-size: 0.4rem; + line-height: 1rem; } \ No newline at end of file diff --git a/app/static/images/icons/close_sidebar.svg b/app/static/images/icons/close_sidebar.svg new file mode 100644 index 00000000..6f7085b4 --- /dev/null +++ b/app/static/images/icons/close_sidebar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/dayview.html b/app/templates/dayview.html index f118bf0b..125b97da 100644 --- a/app/templates/dayview.html +++ b/app/templates/dayview.html @@ -9,39 +9,47 @@ dayview - -
-
- - - - {{MONTH}} - {{DAY}} -
-
-
- {% for i in range(24)%} -
- {% set i = i|string() %} - {{i.zfill(2)}}:00 -
- {% endfor %} -
-
- {% for event in events %} -
-

{{event.content}}

+
+ + {{MONTH}} + {{DAY}} +
+
+
+ {% for i in range(24)%} +
+ {% set i = i|string() %} + {{i.zfill(2)}}:00 +
+ {% endfor %} +
+
+ {% for event in events %} + {% set totaltime = 'visible'%} + {% if event.length < 60 %} + {% set size = '0.6em' %} + {% set totaltime = 'invisible'%} + {% if event.length < 45 %} + {% set size = '0.4em' %} + {% if event.length < 30 %} + {% set size = '0.1em; line-height: 4em' %} + {% endif %} + {% endif %} + {% endif %} +
+

{{event.content}}

+ {% if totaltime == 'visible' %}

{{event.total_time}}

-
- {% endfor %} -
-
- {% for i in range(25)%} -
000
- {% endfor %} -
+ {% endif %} +
+ {% endfor %} +
+
+ {% for i in range(25)%} +
000
+ {% endfor %}
-
+
\ No newline at end of file diff --git a/tests/test_dayview.py b/tests/test_dayview.py index 5be7c399..b4fc0527 100644 --- a/tests/test_dayview.py +++ b/tests/test_dayview.py @@ -2,8 +2,8 @@ import pytest -from app.event import Event from app.main import app +from app.routers.dayview import Event from fastapi.testclient import TestClient @@ -31,6 +31,7 @@ def test_new_event(event): assert event.content == 'nothing' assert event.total_time == '04:05 - 04:20' assert event.grid_position == '22 / 23' + assert event.length == 15 def test_dayview_html(client, event): @@ -42,8 +43,23 @@ def test_dayview_html(client, event): day = {"year":2021, "month":2, "day":3, "events":events} response = client.post("/dayview", json=day) res = response.content.decode("utf-8") - print(res) assert 'grid-row: 22 / 23;' in res assert '
04:05 - 04:20

' in res + +def test_few_events_at_once(client, event): + events = [{"id": "21", + "color":"navy", + "start_datetime": "3/2/2021 04:10", + "end_datetime": "3/2/2021 06:50", + "content": "nothing to do all day"}, + {"id": "24", + "color":"navy", + "start_datetime": "3/2/2021 07:10", + "end_datetime": "3/2/2021 09:50", + "content": "nothing to do all day"},] + day = {"year":2021, "month":2, "day":3, "events":events} + response = client.post("/dayview", json=day) + res = response.content.decode("utf-8") + assert '
Date: Mon, 18 Jan 2021 21:30:50 +0200 Subject: [PATCH 023/108] feat: Basic responsive calender day view page Added dayview template with css and router. An tempreroy event class with, processing function for the day view to work. --- app/routers/dayview.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/routers/dayview.py b/app/routers/dayview.py index a187a4ff..e604868b 100644 --- a/app/routers/dayview.py +++ b/app/routers/dayview.py @@ -12,6 +12,7 @@ router = APIRouter() +#inner class of the router, for the jinja page to process the json class Event: def _minutes_position(self, minutes: int) -> int: min = 0 From 3c3251319841e81d5dd42ab09d53074ea4c4ad3c Mon Sep 17 00:00:00 2001 From: Sagi Zaid Or Date: Tue, 19 Jan 2021 11:46:33 +0200 Subject: [PATCH 024/108] feat: Basic responsive calender day view page Added dayview template with css and router. An tempreroy event class with, processing function for the day view to work. fixed after taking notes. --- .gitignore | 19 ++----------------- app/routers/dayview.py | 36 ++++++++++++++++++++---------------- app/static/style.css | 1 - tests/conftest.py | 10 ++++++++++ tests/test_dayview.py | 7 ------- 5 files changed, 32 insertions(+), 41 deletions(-) create mode 100644 tests/conftest.py diff --git a/.gitignore b/.gitignore index 58464adc..09347fc7 100644 --- a/.gitignore +++ b/.gitignore @@ -127,22 +127,7 @@ dmypy.json # Pyre type checker .pyre/ -Scripts/activate -Scripts/activate.bat -Scripts/Activate.ps1 -Scripts/deactivate.bat -Scripts/easy_install-3.7.exe -Scripts/easy_install.exe -Scripts/pip.exe -Scripts/pip3.7.exe -Scripts/pip3.exe -Scripts/py.test.exe -Scripts/pytest.exe -Scripts/python.exe -Scripts/pythonw.exe -Scripts/uvicorn.exe +Scripts/* pyvenv.cfg .gitignore -Scripts/coverage3.exe -Scripts/coverage.exe -Scripts/coverage-3.7.exe + diff --git a/app/routers/dayview.py b/app/routers/dayview.py index e604868b..a023bb8c 100644 --- a/app/routers/dayview.py +++ b/app/routers/dayview.py @@ -14,17 +14,30 @@ #inner class of the router, for the jinja page to process the json class Event: + GRID_BAR_QUARTER = 1 + FULL_GRID_BAR = 4 + MIN_MINUTS = 0 + MAX_MINUTS = 15 + BASE_GRID_BAR = 5 + + def __init__(self, id: int, color: str, content: str, start_datetime: str, end_datetime: str) -> None: + self.id = id + self.color = color + self.content = content + self.start_time = datetime.strptime(start_datetime, "%d/%m/%Y %H:%M") + self.end_time = datetime.strptime(end_datetime, "%d/%m/%Y %H:%M") + self._set_total_time() + self._set_grid_position() + def _minutes_position(self, minutes: int) -> int: - min = 0 - max = 15 - for i in range(1, 5): - if min <= minutes < max: + for i in range(self.GRID_BAR_QUARTER, self.FULL_GRID_BAR + 1): + if self.MIN_MINUTS <= minutes < self.MAX_MINUTS: return i - min = max - max += 15 + self.MIN_MINUTS = self.MAX_MINUTS + self.MAX_MINUTS += 15 def _get_position(self, time: datetime) -> int: - return time.hour * 4 + self._minutes_position(time.minute) + 5 + return time.hour * self.FULL_GRID_BAR + self._minutes_position(time.minute) + self.BASE_GRID_BAR def _set_grid_position(self) -> None: start = self._get_position(self.start_time) @@ -36,15 +49,6 @@ def _set_total_time(self): self.length = length.seconds / 60 self.total_time = self.start_time.strftime("%H:%M") + ' - ' + self.end_time.strftime("%H:%M") - def __init__(self, id: int, color: str, content: str, start_datetime: str, end_datetime: str) -> None: - self.id = id - self.color = color - self.content = content - self.start_time = datetime.strptime(start_datetime, "%d/%m/%Y %H:%M") - self.end_time = datetime.strptime(end_datetime, "%d/%m/%Y %H:%M") - self._set_total_time() - self._set_grid_position() - @router.post("/dayview") async def dayview(request: Request): diff --git a/app/static/style.css b/app/static/style.css index 12bd572c..917603a3 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -26,5 +26,4 @@ body { .feature { border: none; ->>>>>>> c37057286939c87703a0e2a871dc26a2dd4972f6 } \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..6c020f68 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +import pytest + +from app.main import app +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(): + client = TestClient(app) + return client \ No newline at end of file diff --git a/tests/test_dayview.py b/tests/test_dayview.py index b4fc0527..2935ade4 100644 --- a/tests/test_dayview.py +++ b/tests/test_dayview.py @@ -2,16 +2,9 @@ import pytest -from app.main import app from app.routers.dayview import Event -from fastapi.testclient import TestClient -@pytest.fixture -def client(): - client = TestClient(app) - return client - @pytest.fixture def event(): event_id = 123 From 1cbda2eca30657e0f0fbd5995faa6dcc9eb406be Mon Sep 17 00:00:00 2001 From: Sagi Zaid Or Date: Tue, 19 Jan 2021 13:13:05 +0200 Subject: [PATCH 025/108] fix lint now for sure --- app/routers/dayview.py | 14 ++++++++++---- tests/test_dayview.py | 40 +++++++++++++++++++--------------------- 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/app/routers/dayview.py b/app/routers/dayview.py index a023bb8c..da985f23 100644 --- a/app/routers/dayview.py +++ b/app/routers/dayview.py @@ -12,7 +12,7 @@ router = APIRouter() -#inner class of the router, for the jinja page to process the json +# inner class of the router, for the jinja page to process the json class Event: GRID_BAR_QUARTER = 1 FULL_GRID_BAR = 4 @@ -20,7 +20,8 @@ class Event: MAX_MINUTS = 15 BASE_GRID_BAR = 5 - def __init__(self, id: int, color: str, content: str, start_datetime: str, end_datetime: str) -> None: + def __init__(self, id: int, color: str, content: str, + start_datetime: str, end_datetime: str) -> None: self.id = id self.color = color self.content = content @@ -37,7 +38,9 @@ def _minutes_position(self, minutes: int) -> int: self.MAX_MINUTS += 15 def _get_position(self, time: datetime) -> int: - return time.hour * self.FULL_GRID_BAR + self._minutes_position(time.minute) + self.BASE_GRID_BAR + grid_hour_position = time.hour * self.FULL_GRID_BAR + grid_minutes_modifier = self._minutes_position(time.minute) + return grid_hour_position + grid_minutes_modifier + self.BASE_GRID_BAR def _set_grid_position(self) -> None: start = self._get_position(self.start_time) @@ -47,7 +50,10 @@ def _set_grid_position(self) -> None: def _set_total_time(self): length = self.end_time - self.start_time self.length = length.seconds / 60 - self.total_time = self.start_time.strftime("%H:%M") + ' - ' + self.end_time.strftime("%H:%M") + + start_time_str = self.start_time.strftime("%H:%M") + end_time_str = self.end_time.strftime("%H:%M") + self.total_time = ' '.join([start_time_str, '-', end_time_str]) @router.post("/dayview") diff --git a/tests/test_dayview.py b/tests/test_dayview.py index 2935ade4..81b43322 100644 --- a/tests/test_dayview.py +++ b/tests/test_dayview.py @@ -1,5 +1,3 @@ -from datetime import datetime - import pytest from app.routers.dayview import Event @@ -13,10 +11,10 @@ def event(): start = "03/2/2021 4:05" end = "03/2/2021 4:20" event = Event(id=event_id, color=color, - content=content,start_datetime=start, + content=content, start_datetime=start, end_datetime=end) return event - + def test_new_event(event): assert event.id == 123 @@ -28,31 +26,31 @@ def test_new_event(event): def test_dayview_html(client, event): - events = [{"id":event.id, "color":event.color, - "content":event.content, - "start_datetime":"3/2/2021 04:05", - "end_datetime":"3/2/2021 04:20", + events = [{"id": event.id, "color": event.color, + "content" :event.content, + "start_datetime": "3/2/2021 04:05", + "end_datetime": "3/2/2021 04:20", }] - day = {"year":2021, "month":2, "day":3, "events":events} + day = {"year": 2021, "month": 2, "day": 3, "events": events} response = client.post("/dayview", json=day) - res = response.content.decode("utf-8") + res = response.content.decode("utf-8") assert 'grid-row: 22 / 23;' in res - assert '
Date: Tue, 19 Jan 2021 13:20:47 +0200 Subject: [PATCH 026/108] more lint --- app/main.py | 3 +-- app/routers/dayview.py | 4 ++-- tests/test_dayview.py | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/main.py b/app/main.py index 8f8dd755..763904b1 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,3 @@ -import os - from fastapi import FastAPI, Request from fastapi.staticfiles import StaticFiles @@ -19,6 +17,7 @@ app.include_router(profile.router) app.include_router(event.router) app.include_router(agenda.router) +app.include_router(dayview.router) @app.get("/") diff --git a/app/routers/dayview.py b/app/routers/dayview.py index da985f23..a45b1bbe 100644 --- a/app/routers/dayview.py +++ b/app/routers/dayview.py @@ -53,7 +53,7 @@ def _set_total_time(self): start_time_str = self.start_time.strftime("%H:%M") end_time_str = self.end_time.strftime("%H:%M") - self.total_time = ' '.join([start_time_str, '-', end_time_str]) + self.total_time = ' '.join([start_time_str, '-', end_time_str]) @router.post("/dayview") @@ -65,4 +65,4 @@ async def dayview(request: Request): "events": events, "MONTH": events[0].start_time.strftime("%B").upper(), "DAY": form['day'] - }) \ No newline at end of file + }) diff --git a/tests/test_dayview.py b/tests/test_dayview.py index 81b43322..e417909b 100644 --- a/tests/test_dayview.py +++ b/tests/test_dayview.py @@ -27,7 +27,7 @@ def test_new_event(event): def test_dayview_html(client, event): events = [{"id": event.id, "color": event.color, - "content" :event.content, + "content": event.content, "start_datetime": "3/2/2021 04:05", "end_datetime": "3/2/2021 04:20", }] From a6b0a270b517ff38fbdb96d3c0f1650756085eca Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Tue, 19 Jan 2021 20:48:12 +0200 Subject: [PATCH 027/108] feat: get weather forecast - fix requirements.txt --- requirements.txt | 30 ------------------------------ 1 file changed, 30 deletions(-) delete mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index d9fd4136..00000000 --- a/requirements.txt +++ /dev/null @@ -1,30 +0,0 @@ -atomicwrites==1.4.0 -attrs==20.3.0 -click==7.1.2 -colorama==0.4.4 -fastapi==0.63.0 -h11==0.12.0 -h2==4.0.0 -hpack==4.0.0 -hyperframe==6.0.0 -importlib-metadata==3.3.0 -iniconfig==1.1.1 -Jinja2==2.11.2 -MarkupSafe==1.1.1 -packaging==20.8 -pluggy==0.13.1 -priority==1.3.0 -py==1.10.0 -pydantic==1.7.3 -pyparsing==2.4.7 -pytest==6.2.1 -SQLAlchemy==1.3.22 -starlette==0.13.6 -toml==0.10.2 -typing-extensions==3.7.4.3 -uvicorn==0.13.3 -wsproto==1.0.0 -zipp==3.4.0 - -requests~=2.25.1 -python-dotenv~=0.15.0 \ No newline at end of file From 3883d5efb32f1b5706ec4d72e40b26b06de7aac9 Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Tue, 19 Jan 2021 22:43:41 +0200 Subject: [PATCH 028/108] feat: enable invited users to view events --- app/config.py.example | 7 +++++ app/database/database.py | 9 ++++++ app/dependencies.py | 12 +------ app/internal/agenda_events.py | 28 ++++++++++------- app/internal/utils.py | 40 +++++++++++++++++++++++- app/main.py | 2 +- app/routers/agenda.py | 5 +-- app/routers/event.py | 21 ++++++++++++- app/routers/invitation.py | 34 ++------------------ app/routers/profile.py | 3 +- app/routers/share.py | 6 ++-- app/templates/base.html | 5 ++- tests/event_fixture.py | 59 +++++++++++------------------------ tests/test_agenda_route.py | 4 +-- tests/user_fixture.py | 2 +- 15 files changed, 129 insertions(+), 108 deletions(-) diff --git a/app/config.py.example b/app/config.py.example index b4c8ccf2..82b4e79e 100644 --- a/app/config.py.example +++ b/app/config.py.example @@ -1,4 +1,6 @@ # flake8: noqa +# general +DOMAIN = 'Our-Domain' # DATABASE @@ -8,3 +10,8 @@ DEVELOPMENT_DATABASE_STRING = "sqlite:///./dev.db" MEDIA_DIRECTORY = 'media' PICTURE_EXTENSION = '.png' AVATAR_SIZE = (120, 120) + +# export +ICAL_VERSION = '2.0' +PRODUCT_ID = '-//Our product id//' +OPTIONAL = [] diff --git a/app/database/database.py b/app/database/database.py index bbfc1321..631a3593 100644 --- a/app/database/database.py +++ b/app/database/database.py @@ -13,4 +13,13 @@ SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} ) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/dependencies.py b/app/dependencies.py index 7c8290da..79ae18c5 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -3,7 +3,7 @@ from fastapi.templating import Jinja2Templates from app import config -from app.database.database import SessionLocal, Base, engine + APP_PATH = os.path.dirname(os.path.realpath(__file__)) MEDIA_PATH = os.path.join(APP_PATH, config.MEDIA_DIRECTORY) @@ -11,13 +11,3 @@ TEMPLATES_PATH = os.path.join(APP_PATH, "templates") templates = Jinja2Templates(directory=TEMPLATES_PATH) - - -# Dependency -def get_db(): - Base.metadata.create_all(bind=engine) - db = SessionLocal() - try: - yield db - finally: - db.close() diff --git a/app/internal/agenda_events.py b/app/internal/agenda_events.py index f065a4ef..ae14023b 100644 --- a/app/internal/agenda_events.py +++ b/app/internal/agenda_events.py @@ -1,11 +1,12 @@ from datetime import date, timedelta from typing import List, Optional +import arrow +from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from app.database.models import Event -import arrow -from sqlalchemy.exc import SQLAlchemyError +from app.internal.utils import get_user_events, sort_events_by_date, filter_dates def get_events_per_dates( @@ -13,20 +14,23 @@ def get_events_per_dates( user_id: int, start: Optional[date], end: Optional[date] - ) -> List[Event]: - """Read from the db. Return a list of all the user events between - the relevant dates.""" +) -> List[Event]: + """Read from the db. Return a list of all + the user events between the relevant dates.""" + if start > end: return [] try: - events = ( - session.query(Event).filter(Event.owner_id == user_id) - .filter(Event.start.between(start, end + timedelta(days=1))) - .order_by(Event.start).all() + filter_dates( + sort_events_by_date( + get_user_events(session, user_id) + ), + start, + end, ) - except SQLAlchemyError as e: - print(e) + ) + except SQLAlchemyError: return [] else: return events @@ -54,5 +58,5 @@ def get_time_delta_string(start: date, end: date) -> str: granularity = build_arrow_delta_granularity(diff) duration_string = arrow_end.humanize( arrow_start, only_distance=True, granularity=granularity - ) + ) return duration_string diff --git a/app/internal/utils.py b/app/internal/utils.py index 6bfeeda2..11d3dcac 100644 --- a/app/internal/utils.py +++ b/app/internal/utils.py @@ -1,6 +1,9 @@ +import datetime +from typing import List + from sqlalchemy.orm import Session -from app.database.models import Base +from app.database.models import Base, Event, UserEvent def save(item, session: Session) -> bool: @@ -12,3 +15,38 @@ def save(item, session: Session) -> bool: session.commit() return True return False + + +def get_user_events(session: Session, user_id: int) -> List[Event]: + """Returns all events that the + user participants in.""" + + user_event = ( + session.query(UserEvent) + .filter(UserEvent.user_id == user_id) + .all() + ) + return [ue.events for ue in user_event] + + +def sort_events_by_date(events: List[Event]) -> List[Event]: + """Sorts the events by the start of the event.""" + + events.sort(key=lambda event: event.start) + return events + + +def filter_dates(events: List[Event], start: datetime, end: datetime) -> List[Event]: + time_frame_events = [ + event for event in events + if start <= event.start.date() <= end + ] + return time_frame_events + + +def create_model(session: Session, model_class, **kw): + + instance = model_class(**kw) + session.add(instance) + session.commit() + return instance diff --git a/app/main.py b/app/main.py index 1a483f3b..852696d6 100644 --- a/app/main.py +++ b/app/main.py @@ -24,4 +24,4 @@ async def home(request: Request): return templates.TemplateResponse("home.html", { "request": request, "message": "Hello, World!" - }) \ No newline at end of file + }) diff --git a/app/routers/agenda.py b/app/routers/agenda.py index 67130b32..cf57eaee 100644 --- a/app/routers/agenda.py +++ b/app/routers/agenda.py @@ -6,7 +6,8 @@ from sqlalchemy.orm import Session from starlette.templating import _TemplateResponse -from app.dependencies import templates, get_db +from app.database.database import get_db +from app.dependencies import templates from app.internal import agenda_events router = APIRouter() @@ -17,7 +18,7 @@ def calc_dates_range_for_agenda( end: Optional[date], days: Optional[int] ) -> Tuple[date, date]: - """Create start and end dates eccording to the parameters in the page.""" + """Create start and end dates according to the parameters in the page.""" if days is not None: start = date.today() end = start + timedelta(days=days) diff --git a/app/routers/event.py b/app/routers/event.py index 2408d7df..9af311d8 100644 --- a/app/routers/event.py +++ b/app/routers/event.py @@ -1,7 +1,8 @@ from fastapi import APIRouter, Request +from app.database.models import Event, UserEvent from app.dependencies import templates - +from app.internal.utils import save, create_model router = APIRouter( prefix="/event", @@ -13,3 +14,21 @@ @router.get("/edit") async def eventedit(request: Request): return templates.TemplateResponse("eventedit.html", {"request": request}) + + +def create_event(db, title, start, end, content, owner_id): + + event = create_model( + db, Event, + title=title, + start=start, + end=end, + content=content, + owner_id=owner_id, + ) + association = UserEvent( + user_id=owner_id, + event_id=event.id + ) + save(association, session=db) + return event diff --git a/app/routers/invitation.py b/app/routers/invitation.py index 88f47c12..0286a36f 100644 --- a/app/routers/invitation.py +++ b/app/routers/invitation.py @@ -1,4 +1,3 @@ -from datetime import datetime from typing import List, Union from fastapi import APIRouter, Depends, Form, Request @@ -8,12 +7,11 @@ from starlette.status import HTTP_302_FOUND from starlette.templating import Jinja2Templates +from app.database.database import get_db from app.database.models import Invitation -from app.dependencies import get_db from app.routers.share import accept -from app.database.models import User, Event -from app.internal.utils import save -from app.routers.share import share + + templates = Jinja2Templates(directory="app/templates") router = APIRouter( @@ -25,7 +23,6 @@ @router.get("/") def view_invitations(request: Request, db: Session = Depends(get_db)): - create_data(db) return templates.TemplateResponse("invitations.html", { "request": request, # recipient_id should be the current user @@ -60,28 +57,3 @@ def get_invitation_by_id( if id does not exist, returns None.""" return session.query(Invitation).filter_by(id=invitation_id).first() - - -def create_data(db): - user1 = User(username="user1", email="email1@gmail.com", password="123456") - save(user1, db) - user2 = User(username="user2", email="email2@gmail.com", password="123456") - save(user2, db) - me = User(username="Idan", email="Idan@gmail.com", password="123456") - save(me, db) - event1 = Event(title="a very big event", - content="content", - start=datetime.now(), - end=datetime.now(), - owner_id=user1.id, - owner=user1) - save(event1, db) - event2 = Event(title="a very small event", - content="content", - start=datetime.now(), - end=datetime.now(), - owner_id=user2.id, - owner=user2) - save(event2, db) - share(event1, ['Idan@gmail.com'], db) - share(event2, ['Idan@gmail.com'], db) \ No newline at end of file diff --git a/app/routers/profile.py b/app/routers/profile.py index 9fc0983c..daa8f318 100644 --- a/app/routers/profile.py +++ b/app/routers/profile.py @@ -6,8 +6,9 @@ from PIL import Image from app import config +from app.database.database import get_db from app.database.models import User -from app.dependencies import MEDIA_PATH, templates, get_db +from app.dependencies import MEDIA_PATH, templates PICTURE_EXTENSION = config.PICTURE_EXTENSION PICTURE_SIZE = config.AVATAR_SIZE diff --git a/app/routers/share.py b/app/routers/share.py index 374ea8f6..f6351a03 100644 --- a/app/routers/share.py +++ b/app/routers/share.py @@ -47,7 +47,7 @@ def send_in_app_invitation( for participant in participants: # email is unique recipient = get_users(email=participant, session=session)[0] - + print(recipient) if recipient.id != event.owner.id: session.add(Invitation(recipient=recipient, event=event)) @@ -66,8 +66,8 @@ def accept(invitation: Invitation, session: Session) -> None: participantship at the event.""" association = UserEvent( - participants=invitation.recipient, - events=invitation.event + user_id=invitation.recipient.id, + event_id=invitation.event.id ) invitation.status = 'accepted' save(invitation, session=session) diff --git a/app/templates/base.html b/app/templates/base.html index ddc549db..97360bd6 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -37,7 +37,10 @@ +
diff --git a/tests/event_fixture.py b/tests/event_fixture.py index a3c56d2e..3d8bd3bd 100644 --- a/tests/event_fixture.py +++ b/tests/event_fixture.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import Session from app.database.models import Event, User -from tests.utils import create_model, delete_instance +from app.routers.event import create_event today_date = datetime.today().replace(hour=0, minute=0, second=0) @@ -12,120 +12,97 @@ @pytest.fixture def event(sender: User, session: Session) -> Event: - event = create_model( - session, Event, + return create_event( + db=session, title='today_event', start=today_date, end=today_date, content='test event', - owner=sender, owner_id=sender.id, ) - yield event - delete_instance(session, event) @pytest.fixture def today_event(sender: User, session: Session) -> Event: - event = create_model( - session, Event, + return create_event( + db=session, title='event 1', start=today_date + timedelta(hours=7), end=today_date + timedelta(hours=9), content='test event', - owner=sender, owner_id=sender.id, ) - yield event - delete_instance(session, event) @pytest.fixture def today_event_2(sender: User, session: Session) -> Event: - event = create_model( - session, Event, + return create_event( + db=session, title='event 2', start=today_date + timedelta(hours=3), end=today_date + timedelta(days=2, hours=3), content='test event', - owner=sender, owner_id=sender.id, ) - yield event - delete_instance(session, event) @pytest.fixture def yesterday_event(sender: User, session: Session) -> Event: - event = create_model( - session, Event, + return create_event( + db=session, title='event 3', start=today_date - timedelta(hours=8), end=today_date, content='test event', - owner=sender, owner_id=sender.id, ) - yield event - delete_instance(session, event) @pytest.fixture def next_week_event(sender: User, session: Session) -> Event: - event = create_model( - session, Event, + return create_event( + db=session, title='event 4', start=today_date + timedelta(days=7, hours=2), end=today_date + timedelta(days=7, hours=4), content='test event', - owner=sender, owner_id=sender.id, ) - yield event - delete_instance(session, event) @pytest.fixture def next_month_event(sender: User, session: Session) -> Event: - event = create_model( - session, Event, + return create_event( + db=session, title='event 5', start=today_date + timedelta(days=20, hours=4), end=today_date + timedelta(days=20, hours=6), content='test event', - owner=sender, owner_id=sender.id, ) - yield event - delete_instance(session, event) @pytest.fixture def old_event(sender: User, session: Session) -> Event: - event = create_model( - session, Event, + return create_event( + db=session, title='event 6', start=today_date - timedelta(days=5), end=today_date, content='test event', - owner=sender, owner_id=sender.id, ) - yield event - delete_instance(session, event) @pytest.fixture def user_event(user: User, session: Session) -> Event: """Only this event is created by "user" and not "sender".""" - event = create_model( - session, Event, + + return create_event( + db=session, title='event 7', start=today_date - timedelta(days=5), end=today_date, content='test event', - owner=user, owner_id=user.id, ) - yield event - delete_instance(session, event) \ No newline at end of file diff --git a/tests/test_agenda_route.py b/tests/test_agenda_route.py index f58e2e44..f7ca1d75 100644 --- a/tests/test_agenda_route.py +++ b/tests/test_agenda_route.py @@ -5,7 +5,7 @@ class TestAgenda: """In the test we are receiving event fixtures - so they will load into the database""" + as parameters so they will load into the database""" AGENDA = "/agenda" AGENDA_7_DAYS = "/agenda?days=7" @@ -99,7 +99,7 @@ def test_no_show_events_user_2( today_event_2, yesterday_event, next_week_event, next_month_event, old_event ): - # sender is just a different user + # "user" is just a different event creator resp = client.get(TestAgenda.AGENDA) assert resp.status_code == status.HTTP_200_OK assert b"event 7" not in resp.content diff --git a/tests/user_fixture.py b/tests/user_fixture.py index 0971e034..20c25ac7 100644 --- a/tests/user_fixture.py +++ b/tests/user_fixture.py @@ -26,4 +26,4 @@ def sender(session: Session) -> User: email='sender.email@gmail.com', ) yield sender - delete_instance(session, sender) \ No newline at end of file + delete_instance(session, sender) From 6add7841f3bcfe92d146f7b7d0d327f588738993 Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Tue, 19 Jan 2021 23:51:58 +0200 Subject: [PATCH 029/108] feat: flake8 changes --- app/internal/agenda_events.py | 22 ++++++++++++++++++++-- app/internal/utils.py | 26 +++++--------------------- app/main.py | 2 +- app/routers/invitation.py | 9 +++++++-- app/routers/profile.py | 6 +++--- app/routers/share.py | 2 +- tests/invitation_fixture.py | 2 +- 7 files changed, 38 insertions(+), 31 deletions(-) diff --git a/app/internal/agenda_events.py b/app/internal/agenda_events.py index ae14023b..0db8304a 100644 --- a/app/internal/agenda_events.py +++ b/app/internal/agenda_events.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Session from app.database.models import Event -from app.internal.utils import get_user_events, sort_events_by_date, filter_dates +from app.internal.utils import get_all_user_events def get_events_per_dates( @@ -24,7 +24,7 @@ def get_events_per_dates( events = ( filter_dates( sort_events_by_date( - get_user_events(session, user_id) + get_all_user_events(session, user_id) ), start, end, @@ -60,3 +60,21 @@ def get_time_delta_string(start: date, end: date) -> str: arrow_start, only_distance=True, granularity=granularity ) return duration_string + + +def sort_events_by_date(events: List[Event]) -> List[Event]: + """Sorts the events by the start of the event.""" + + events.sort(key=lambda event: event.start) + return events + + +def filter_dates( + events: List[Event], start: Optional[date], + end: Optional[date]) -> List[Event]: + """filter events by a time frame.""" + + return [ + event for event in events + if start <= event.start.date() <= end + ] diff --git a/app/internal/utils.py b/app/internal/utils.py index 11d3dcac..d70c7dbb 100644 --- a/app/internal/utils.py +++ b/app/internal/utils.py @@ -1,4 +1,3 @@ -import datetime from typing import List from sqlalchemy.orm import Session @@ -17,34 +16,19 @@ def save(item, session: Session) -> bool: return False -def get_user_events(session: Session, user_id: int) -> List[Event]: - """Returns all events that the - user participants in.""" +def get_all_user_events(session: Session, user_id: int) -> List[Event]: + """Returns all events that the user participants in.""" - user_event = ( + associations = ( session.query(UserEvent) .filter(UserEvent.user_id == user_id) .all() ) - return [ue.events for ue in user_event] - - -def sort_events_by_date(events: List[Event]) -> List[Event]: - """Sorts the events by the start of the event.""" - - events.sort(key=lambda event: event.start) - return events - - -def filter_dates(events: List[Event], start: datetime, end: datetime) -> List[Event]: - time_frame_events = [ - event for event in events - if start <= event.start.date() <= end - ] - return time_frame_events + return [association.events for association in associations] def create_model(session: Session, model_class, **kw): + """Creates and saves a db model.""" instance = model_class(**kw) session.add(instance) diff --git a/app/main.py b/app/main.py index 852696d6..97980a95 100644 --- a/app/main.py +++ b/app/main.py @@ -23,5 +23,5 @@ async def home(request: Request): return templates.TemplateResponse("home.html", { "request": request, - "message": "Hello, World!" + "message": "Hello, World!", }) diff --git a/app/routers/invitation.py b/app/routers/invitation.py index 0286a36f..11fed3bc 100644 --- a/app/routers/invitation.py +++ b/app/routers/invitation.py @@ -25,6 +25,7 @@ def view_invitations(request: Request, db: Session = Depends(get_db)): return templates.TemplateResponse("invitations.html", { "request": request, + # TODO: create current user # recipient_id should be the current user # but because we don't have one yet, # "get_all_invitations" returns all invitations @@ -33,10 +34,14 @@ def view_invitations(request: Request, db: Session = Depends(get_db)): @router.post("/") -async def accept_invitations(invite_id: int = Form(...), db: Session = Depends(get_db)): +async def accept_invitations( + invite_id: int = Form(...), + db: Session = Depends(get_db) +): i = get_invitation_by_id(invite_id, session=db) accept(i, db) - return RedirectResponse("/", status_code=HTTP_302_FOUND) + url = router.url_path_for("view_invitations") + return RedirectResponse(url=url, status_code=HTTP_302_FOUND) def get_all_invitations(session: Session, **param) -> List[Invitation]: diff --git a/app/routers/profile.py b/app/routers/profile.py index daa8f318..bb856747 100644 --- a/app/routers/profile.py +++ b/app/routers/profile.py @@ -25,7 +25,7 @@ def get_placeholder_user(): username='new_user', email='my@email.po', password='1a2s3d4f5g6', - full_name='My Name' + full_name='My Name', ) @@ -36,7 +36,7 @@ async def profile( new_user=Depends(get_placeholder_user)): # Get relevant data from database - upcouming_events = range(5) + upcoming_events = range(5) user = session.query(User).filter_by(id=1).first() if not user: session.add(new_user) @@ -48,7 +48,7 @@ async def profile( return templates.TemplateResponse("profile.html", { "request": request, "user": user, - "events": upcouming_events + "events": upcoming_events, }) diff --git a/app/routers/share.py b/app/routers/share.py index f6351a03..5b27643b 100644 --- a/app/routers/share.py +++ b/app/routers/share.py @@ -33,7 +33,7 @@ def send_email_invitation( ical_invitation = event_to_ical(event, participants) for participant in participants: - # sends an email + # TODO: send email pass diff --git a/tests/invitation_fixture.py b/tests/invitation_fixture.py index 569badad..9015381d 100644 --- a/tests/invitation_fixture.py +++ b/tests/invitation_fixture.py @@ -18,4 +18,4 @@ def invitation(event: Event, user: User, session: Session) -> Event: recipient_id=user.id, ) yield invitation - delete_instance(session, invitation) \ No newline at end of file + delete_instance(session, invitation) From cccd7b7a0fdbf1b179931e6c57b9fb210801892a Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Tue, 19 Jan 2021 23:54:51 +0200 Subject: [PATCH 030/108] fix: requirements bug --- dev.db-journal | Bin 0 -> 12824 bytes requirements.txt | 1 - 2 files changed, 1 deletion(-) create mode 100644 dev.db-journal diff --git a/dev.db-journal b/dev.db-journal new file mode 100644 index 0000000000000000000000000000000000000000..c5fb75d8572948d2a7ccff6540e683a05d5cc277 GIT binary patch literal 12824 zcmeI2Uu)A)6u@(n^iMLk2qJM$-mQoY%sDHv(UY+|Q z`Z4?pV<3XaK7c-mpdg5f58ivz+csNELGWRCE;RStbI$#9e&@8IAt&!*Kk>`TdqEH+ z=jhE0>Bj^?B;7^wf%YE|P*f76tbG!+-`aQWD-oap0U!VbfB+Bx0zd!=00AHX1b_e# z00QTjKukhNmg!ZJ=v5RYNl`k_eUjrOGFkf|Xuq^?+Gip_0|Gz*2mk>f00e*l5C8%| z00;m9AOHl;CxMtO3ko970B9%0MB0fmA{~DYfYw&?Zs6eUMzd}Q_%3-91R>!8#)2^M zkpxZN58+MOB9hEUq(^=75Rt5L^Zv=W_CrvWFT%u@cC5XcxH2wYcs+JJ)>4;=5*iQy z0zd!=00AHX1c1Q5B=A^`>$g&9*Y&E-zPn#`y|x?Jf!px>vRmyNjb)2wrexxrHE%w` zXFPGi!hPO2?N*J0>qkv_itd}qPbueO)G1z;g;_-{ee^X-Vt}E4Ed2Ms=uDGcGPBF17`w~nES$bQgJ%sxH4G`P-_w!6GU4ym k$lvv{-3l6PTn@?KVV!J Date: Wed, 20 Jan 2021 00:17:13 +0200 Subject: [PATCH 031/108] fix: requirements bug --- requirements.txt | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index f901b19a..7648cc9e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,7 @@ h11==0.12.0 h2==4.0.0 hpack==4.0.0 hyperframe==6.0.0 +icalendar==4.0.7 idna==2.10 importlib-metadata==3.3.0 iniconfig==1.1.1 @@ -24,12 +25,12 @@ priority==1.3.0 py==1.10.0 pydantic==1.7.3 pyparsing==2.4.7 -SQLAlchemy==1.3.13 pytest==6.2.1 pytest-cov==2.10.1 python-dateutil==2.8.1 python-dotenv==0.15.0 python-multipart==0.0.5 +pytz==2020.5 PyYAML==5.3.1 requests==2.25.1 six==1.15.0 @@ -43,5 +44,3 @@ watchgod==0.6 websockets==8.1 wsproto==1.0.0 zipp==3.4.0 -icalendar==4.0.7 -pytz==2019.3 From bdd4d8b6733cfc75073c7fe6c058c6a97b2d4f1e Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Wed, 20 Jan 2021 00:25:03 +0200 Subject: [PATCH 032/108] feat: flake8 changes --- app/routers/share.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/routers/share.py b/app/routers/share.py index 5b27643b..0fce2992 100644 --- a/app/routers/share.py +++ b/app/routers/share.py @@ -31,8 +31,8 @@ def send_email_invitation( ): """Sends an email with an invitation.""" - ical_invitation = event_to_ical(event, participants) - for participant in participants: + ical_invitation = event_to_ical(event, participants) # noqa: F841 + for _ in participants: # TODO: send email pass From d629a2539cf0b42c778ec3e533d53b6d169236f1 Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Wed, 20 Jan 2021 01:51:03 +0200 Subject: [PATCH 033/108] add: tests --- app/routers/share.py | 2 +- dev.db-journal | Bin 12824 -> 0 bytes tests/test_invitation.py | 6 ++++++ tests/test_share_event.py | 15 +++++++++++---- tests/test_user.py | 3 ++- 5 files changed, 20 insertions(+), 6 deletions(-) delete mode 100644 dev.db-journal diff --git a/app/routers/share.py b/app/routers/share.py index 0fce2992..4ea00846 100644 --- a/app/routers/share.py +++ b/app/routers/share.py @@ -47,7 +47,7 @@ def send_in_app_invitation( for participant in participants: # email is unique recipient = get_users(email=participant, session=session)[0] - print(recipient) + if recipient.id != event.owner.id: session.add(Invitation(recipient=recipient, event=event)) diff --git a/dev.db-journal b/dev.db-journal deleted file mode 100644 index c5fb75d8572948d2a7ccff6540e683a05d5cc277..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12824 zcmeI2Uu)A)6u@(n^iMLk2qJM$-mQoY%sDHv(UY+|Q z`Z4?pV<3XaK7c-mpdg5f58ivz+csNELGWRCE;RStbI$#9e&@8IAt&!*Kk>`TdqEH+ z=jhE0>Bj^?B;7^wf%YE|P*f76tbG!+-`aQWD-oap0U!VbfB+Bx0zd!=00AHX1b_e# z00QTjKukhNmg!ZJ=v5RYNl`k_eUjrOGFkf|Xuq^?+Gip_0|Gz*2mk>f00e*l5C8%| z00;m9AOHl;CxMtO3ko970B9%0MB0fmA{~DYfYw&?Zs6eUMzd}Q_%3-91R>!8#)2^M zkpxZN58+MOB9hEUq(^=75Rt5L^Zv=W_CrvWFT%u@cC5XcxH2wYcs+JJ)>4;=5*iQy z0zd!=00AHX1c1Q5B=A^`>$g&9*Y&E-zPn#`y|x?Jf!px>vRmyNjb)2wrexxrHE%w` zXFPGi!hPO2?N*J0>qkv_itd}qPbueO)G1z;g;_-{ee^X-Vt}E4Ed2Ms=uDGcGPBF17`w~nES$bQgJ%sxH4G`P-_w!6GU4ym k$lvv{-3l6PTn@?KVV!J Date: Wed, 20 Jan 2021 01:52:22 +0200 Subject: [PATCH 034/108] feat: flake8 changes --- tests/test_share_event.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_share_event.py b/tests/test_share_event.py index 36af8c00..191252ca 100644 --- a/tests/test_share_event.py +++ b/tests/test_share_event.py @@ -9,7 +9,9 @@ class TestShareEvent: def test_share(self, user, event, session): participants = [user.email] share(event, participants, session) - invitations = get_all_invitations(session=session, recipient_id=user.id) + invitations = get_all_invitations( + session=session, recipient_id=user.id + ) assert invitations != [] def test_sort_emails(self, user, session): From 0bcbff84ca6317aba0d9973777d5e03be0ef1bb4 Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Wed, 20 Jan 2021 10:04:25 +0200 Subject: [PATCH 035/108] add: tests --- tests/association_fixture.py | 13 +++++++++++++ tests/conftest.py | 3 ++- tests/test_association.py | 9 +++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 tests/association_fixture.py create mode 100644 tests/test_association.py diff --git a/tests/association_fixture.py b/tests/association_fixture.py new file mode 100644 index 00000000..5b7207b3 --- /dev/null +++ b/tests/association_fixture.py @@ -0,0 +1,13 @@ +import pytest +from sqlalchemy.orm import Session + +from app.database.models import Event, UserEvent + + +@pytest.fixture +def association(event: Event, session: Session) -> UserEvent: + return ( + session.query(UserEvent) + .filter(UserEvent.event_id == event.id) + ).first() + diff --git a/tests/conftest.py b/tests/conftest.py index 898d0742..7ec1eed8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,8 @@ pytest_plugins = [ 'tests.user_fixture', 'tests.event_fixture', - 'tests.invitation_fixture' + 'tests.invitation_fixture', + 'tests.association_fixture', ] SQLALCHEMY_TEST_DATABASE_URL = "sqlite:///./test.db" diff --git a/tests/test_association.py b/tests/test_association.py new file mode 100644 index 00000000..741f0931 --- /dev/null +++ b/tests/test_association.py @@ -0,0 +1,9 @@ +class TestAssociation: + def test_association_data(self, association, event): + assert association.events == event + + def test_repr(self, association): + assert ( + association.__repr__() + == f'') From 94626029deac196a1de40de83879171cb8efb02b Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Wed, 20 Jan 2021 10:06:48 +0200 Subject: [PATCH 036/108] feat: flake8 changes --- tests/association_fixture.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/association_fixture.py b/tests/association_fixture.py index 5b7207b3..92c845c2 100644 --- a/tests/association_fixture.py +++ b/tests/association_fixture.py @@ -10,4 +10,3 @@ def association(event: Event, session: Session) -> UserEvent: session.query(UserEvent) .filter(UserEvent.event_id == event.id) ).first() - From 053b15696e46e598e9a0db520f6fcb920058163d Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Wed, 20 Jan 2021 14:02:13 +0200 Subject: [PATCH 037/108] edit: file structure --- app/internal/events.py | 0 tests/test_home.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 app/internal/events.py create mode 100644 tests/test_home.py diff --git a/app/internal/events.py b/app/internal/events.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_home.py b/tests/test_home.py new file mode 100644 index 00000000..e69de29b From 7684719d03f1473a13a777ae49616c8451ff33bc Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Wed, 20 Jan 2021 14:08:01 +0200 Subject: [PATCH 038/108] edit: file structure --- app/internal/agenda_events.py | 16 +++++----------- app/internal/events.py | 12 ++++++++++++ app/internal/utils.py | 15 +-------------- app/routers/export.py | 4 ++-- app/routers/user.py | 17 +++++++++++++++-- tests/test_home.py | 6 ++++++ 6 files changed, 41 insertions(+), 29 deletions(-) diff --git a/app/internal/agenda_events.py b/app/internal/agenda_events.py index 0db8304a..248c6188 100644 --- a/app/internal/agenda_events.py +++ b/app/internal/agenda_events.py @@ -6,7 +6,8 @@ from sqlalchemy.orm import Session from app.database.models import Event -from app.internal.utils import get_all_user_events +from app.internal.events import sort_by_date +from app.routers.user import get_all_user_events def get_events_per_dates( @@ -23,7 +24,7 @@ def get_events_per_dates( try: events = ( filter_dates( - sort_events_by_date( + sort_by_date( get_all_user_events(session, user_id) ), start, @@ -62,19 +63,12 @@ def get_time_delta_string(start: date, end: date) -> str: return duration_string -def sort_events_by_date(events: List[Event]) -> List[Event]: - """Sorts the events by the start of the event.""" - - events.sort(key=lambda event: event.start) - return events - - def filter_dates( events: List[Event], start: Optional[date], end: Optional[date]) -> List[Event]: """filter events by a time frame.""" - return [ + yield from ( event for event in events if start <= event.start.date() <= end - ] + ) diff --git a/app/internal/events.py b/app/internal/events.py index e69de29b..3f5686d4 100644 --- a/app/internal/events.py +++ b/app/internal/events.py @@ -0,0 +1,12 @@ +from operator import attrgetter +from typing import List + +from app.database.models import Event + + +def sort_by_date(events: List[Event]) -> List[Event]: + """Sorts the events by the start of the event.""" + + temp = events.copy() + temp.sort(key=attrgetter('start')) + return temp diff --git a/app/internal/utils.py b/app/internal/utils.py index d70c7dbb..3a72c611 100644 --- a/app/internal/utils.py +++ b/app/internal/utils.py @@ -1,8 +1,6 @@ -from typing import List - from sqlalchemy.orm import Session -from app.database.models import Base, Event, UserEvent +from app.database.models import Base def save(item, session: Session) -> bool: @@ -16,17 +14,6 @@ def save(item, session: Session) -> bool: return False -def get_all_user_events(session: Session, user_id: int) -> List[Event]: - """Returns all events that the user participants in.""" - - associations = ( - session.query(UserEvent) - .filter(UserEvent.user_id == user_id) - .all() - ) - return [association.events for association in associations] - - def create_model(session: Session, model_class, **kw): """Creates and saves a db model.""" diff --git a/app/routers/export.py b/app/routers/export.py index 9006a1a8..5ebe8580 100644 --- a/app/routers/export.py +++ b/app/routers/export.py @@ -34,10 +34,10 @@ def add_optional(user_event, data): """Adds an optional field if it exists.""" if user_event.location: - data += [('location', user_event.location)] + data.append(('location', user_event.location)) if user_event.content: - data += [('description', user_event.content)] + data.append(('description', user_event.content)) return data diff --git a/app/routers/user.py b/app/routers/user.py index 90f762c8..12a3b8f5 100644 --- a/app/routers/user.py +++ b/app/routers/user.py @@ -1,7 +1,9 @@ +from typing import List + from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session -from app.database.models import User +from app.database.models import User, UserEvent, Event from app.internal.utils import save @@ -30,7 +32,7 @@ def get_users(session: Session, **param): def does_user_exist( session: Session, - *_, user_id=None, + *, user_id=None, username=None, email=None ): """Returns True if user exists, False otherwise. @@ -43,3 +45,14 @@ def does_user_exist( if email: return len(get_users(session=session, email=email)) == 1 return False + + +def get_all_user_events(session: Session, user_id: int) -> List[Event]: + """Returns all events that the user participants in.""" + + associations = ( + session.query(UserEvent) + .filter(UserEvent.user_id == user_id) + .all() + ) + return [association.events for association in associations] diff --git a/tests/test_home.py b/tests/test_home.py index e69de29b..fc0b6772 100644 --- a/tests/test_home.py +++ b/tests/test_home.py @@ -0,0 +1,6 @@ +class TestHome: + URL = "/" + + def test_get_page(self, client): + resp = client.get(self.URL) + assert resp.status_code == 200 From fa2fe19aafd07cee0a4e539d5a226b08dd41e0af Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Wed, 20 Jan 2021 17:13:13 +0200 Subject: [PATCH 039/108] feat: get weather forecast - fix changes & add cache support --- app/config.py.example | 3 +- app/routers/weather_forecast.py | 51 +++++++++++++++++++++------------ 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/app/config.py.example b/app/config.py.example index 66cbebec..11763783 100644 --- a/app/config.py.example +++ b/app/config.py.example @@ -10,5 +10,4 @@ PICTURE_EXTENSION = '.png' AVATAR_SIZE = (120, 120) # API-KEYS -WEATHER_API_KEY= -ASTRONOMY_API_KEY= +WEATHER_API_KEY=os.getenv('WEATHER_API_KEY') diff --git a/app/routers/weather_forecast.py b/app/routers/weather_forecast.py index 88d3ed7a..c5c25e38 100644 --- a/app/routers/weather_forecast.py +++ b/app/routers/weather_forecast.py @@ -1,8 +1,10 @@ import datetime -from dotenv import load_dotenv -import os +import frozendict +import functools import requests +from app import config + # This feature requires an API KEY - get yours free @ visual-crossing-weather.p.rapidapi.com @@ -37,10 +39,23 @@ def validate_date_input(requested_date): return True, None else: return False, INVALID_YEAR - else: - return False, INVALID_DATE_INPUT + return False, INVALID_DATE_INPUT + + +def freezeargs(func): + """Transform mutable dictionary into immutable + Credit to 'fast_cen' from 'stackoverflow' + """ + @functools.wraps(func) + def wrapped(*args, **kwargs): + args = tuple([frozendict.frozendict(arg) if isinstance(arg, dict) else arg for arg in args]) + kwargs = {k: frozendict.frozendict(v) if isinstance(v, dict) else v for k, v in kwargs.items()} + return func(*args, **kwargs) + return wrapped +@freezeargs +@functools.lru_cache(maxsize=128, typed=False) def get_data_from_weather_api(url, input_query_string): """ get the relevant weather data by calling the "Visual Crossing Weather" API. Args: @@ -50,13 +65,12 @@ def get_data_from_weather_api(url, input_query_string): (json) - JSON data returned by the API. (str) - error message. """ - load_dotenv() - HEADERS['x-rapidapi-key'] = os.getenv('WEATHER_API_KEY') + HEADERS['x-rapidapi-key'] = config.WEATHER_API_KEY try: response = requests.request("GET", url, headers=HEADERS, params=input_query_string) except requests.exceptions.RequestException: return None, NO_API_RESPONSE - if response: + if response.ok: try: return response.json()["locations"], None except KeyError: @@ -86,8 +100,7 @@ def get_historical_weather(input_date, location): 'Conditions': api_json[location_found]['values'][0]['conditions'], 'Address': location_found} return weather_data, None - else: - return None, error_text + return None, error_text def get_forecast_weather(input_date, location): @@ -103,17 +116,16 @@ def get_forecast_weather(input_date, location): input_query_string["location"] = location api_json, error_text = get_data_from_weather_api(FORECAST_URL, input_query_string) location_found = list(api_json.keys())[0] - if api_json: - for i in range(len(api_json[location_found]['values'])): - # find relevant date from API output - if str(input_date) == api_json[location_found]['values'][i]['datetimeStr'][:10]: - weather_data = {'MinTempCel': api_json[location_found]['values'][i]['mint'], - 'MaxTempCel': api_json[location_found]['values'][i]['maxt'], - 'Conditions': api_json[location_found]['values'][i]['conditions'], - 'Address': location_found} - return weather_data, None - else: + if not api_json: return None, error_text + for i in range(len(api_json[location_found]['values'])): + # find relevant date from API output + if str(input_date) == api_json[location_found]['values'][i]['datetimeStr'][:10]: + weather_data = {'MinTempCel': api_json[location_found]['values'][i]['mint'], + 'MaxTempCel': api_json[location_found]['values'][i]['maxt'], + 'Conditions': api_json[location_found]['values'][i]['conditions'], + 'Address': location_found} + return weather_data, None def get_history_relevant_year(day, month): @@ -179,6 +191,7 @@ def get_forecast(requested_date, location): location (str) - location name. Returns: weather_json (json) - output weather data. + error_text (str) - error message. """ forecast_type = get_forecast_type(requested_date) if forecast_type == HISTORY_TYPE: From 12b28818fef76650905a244a6a294f6b60a0a161 Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Wed, 20 Jan 2021 17:48:37 +0200 Subject: [PATCH 040/108] feat: get weather forecast - fix changes & add cache support --- app/routers/weather_forecast.py | 90 +++++++++++++++++++++------------ tests/test_weather_forecast.py | 24 ++++++--- 2 files changed, 73 insertions(+), 41 deletions(-) diff --git a/app/routers/weather_forecast.py b/app/routers/weather_forecast.py index c5c25e38..5b68b604 100644 --- a/app/routers/weather_forecast.py +++ b/app/routers/weather_forecast.py @@ -6,7 +6,8 @@ from app import config -# This feature requires an API KEY - get yours free @ visual-crossing-weather.p.rapidapi.com +# This feature requires an API KEY +# get yours free @ visual-crossing-weather.p.rapidapi.com SUCCESS_STATUS = 0 ERROR_STATUS = -1 @@ -20,8 +21,9 @@ HISTORY_URL = "https://visual-crossing-weather.p.rapidapi.com/history" FORECAST_URL = "https://visual-crossing-weather.p.rapidapi.com/forecast" HEADERS = {'x-rapidapi-host': "visual-crossing-weather.p.rapidapi.com"} -BASE_QUERY_STRING = {"aggregateHours": "24", "unitGroup": "metric", "dayStartTime": "00:00:01", - "contentType": "json", "dayEndTime": "23:59:59", "shortColumnNames": "True"} +BASE_QUERY_STRING = {"aggregateHours": "24", "unitGroup": "metric", + "dayStartTime": "00:00:01", "contentType": "json", + "dayEndTime": "23:59:59", "shortColumnNames": "True"} HISTORICAL_AVERAGE_NUM_OF_YEARS = 3 NO_API_RESPONSE = "No response from server" @@ -48,8 +50,10 @@ def freezeargs(func): """ @functools.wraps(func) def wrapped(*args, **kwargs): - args = tuple([frozendict.frozendict(arg) if isinstance(arg, dict) else arg for arg in args]) - kwargs = {k: frozendict.frozendict(v) if isinstance(v, dict) else v for k, v in kwargs.items()} + args = tuple([frozendict.frozendict(arg) + if isinstance(arg, dict) else arg for arg in args]) + kwargs = {k: frozendict.frozendict(v) if isinstance(v, dict) + else v for k, v in kwargs.items()} return func(*args, **kwargs) return wrapped @@ -57,7 +61,7 @@ def wrapped(*args, **kwargs): @freezeargs @functools.lru_cache(maxsize=128, typed=False) def get_data_from_weather_api(url, input_query_string): - """ get the relevant weather data by calling the "Visual Crossing Weather" API. + """ get relevant weather data by calling "Visual Crossing Weather" API. Args: url (str) - API url. input_query_string (dict) - input for the API. @@ -67,7 +71,8 @@ def get_data_from_weather_api(url, input_query_string): """ HEADERS['x-rapidapi-key'] = config.WEATHER_API_KEY try: - response = requests.request("GET", url, headers=HEADERS, params=input_query_string) + response = requests.request("GET", url, + headers=HEADERS, params=input_query_string) except requests.exceptions.RequestException: return None, NO_API_RESPONSE if response.ok: @@ -90,15 +95,18 @@ def get_historical_weather(input_date, location): """ input_query_string = BASE_QUERY_STRING input_query_string["startDateTime"] = input_date.isoformat() - input_query_string["endDateTime"] = (input_date + datetime.timedelta(days=1)).isoformat() + input_query_string["endDateTime"] =\ + (input_date + datetime.timedelta(days=1)).isoformat() input_query_string["location"] = location - api_json, error_text = get_data_from_weather_api(HISTORY_URL, input_query_string) + api_json, error_text =\ + get_data_from_weather_api(HISTORY_URL, input_query_string) location_found = list(api_json.keys())[0] if api_json: - weather_data = {'MinTempCel': api_json[location_found]['values'][0]['mint'], - 'MaxTempCel': api_json[location_found]['values'][0]['maxt'], - 'Conditions': api_json[location_found]['values'][0]['conditions'], - 'Address': location_found} + weather_data = { + 'MinTempCel': api_json[location_found]['values'][0]['mint'], + 'MaxTempCel': api_json[location_found]['values'][0]['maxt'], + 'Conditions': api_json[location_found]['values'][0]['conditions'], + 'Address': location_found} return weather_data, None return None, error_text @@ -114,22 +122,26 @@ def get_forecast_weather(input_date, location): """ input_query_string = BASE_QUERY_STRING input_query_string["location"] = location - api_json, error_text = get_data_from_weather_api(FORECAST_URL, input_query_string) + api_json, error_text = get_data_from_weather_api(FORECAST_URL, + input_query_string) location_found = list(api_json.keys())[0] if not api_json: return None, error_text for i in range(len(api_json[location_found]['values'])): # find relevant date from API output - if str(input_date) == api_json[location_found]['values'][i]['datetimeStr'][:10]: - weather_data = {'MinTempCel': api_json[location_found]['values'][i]['mint'], - 'MaxTempCel': api_json[location_found]['values'][i]['maxt'], - 'Conditions': api_json[location_found]['values'][i]['conditions'], - 'Address': location_found} + if str(input_date) ==\ + api_json[location_found]['values'][i]['datetimeStr'][:10]: + weather_data = { + 'MinTempCel': api_json[location_found]['values'][i]['mint'], + 'MaxTempCel': api_json[location_found]['values'][i]['maxt'], + 'Conditions': api_json[location_found]['values'][i]['conditions'], + 'Address': location_found} return weather_data, None def get_history_relevant_year(day, month): - """ return the relevant year in order to call the get_historical_weather function with. + """ return the relevant year in order to call the + get_historical_weather function with. decided according to if date occurred this year or not. Args: day (int) - day part of date. @@ -138,9 +150,12 @@ def get_history_relevant_year(day, month): last_year (int) - relevant year. """ try: - relevant_date = datetime.datetime(year=datetime.datetime.now().year, month=month, day=day) - except ValueError: # only if the day & month are 29.02 and there is no such date this year - relevant_date = datetime.datetime(year=datetime.datetime.now().year, month=month, day=day - 1) + relevant_date = datetime.datetime(year=datetime.datetime.now().year, + month=month, day=day) + except ValueError: + # only if day & month are 29.02 and there is no such date this year + relevant_date = datetime.datetime(year=datetime.datetime.now().year, + month=month, day=day - 1) if datetime.datetime.now() > relevant_date: last_year = datetime.datetime.now().year else: @@ -149,7 +164,8 @@ def get_history_relevant_year(day, month): def get_forecast_by_historical_data(day, month, location): - """ get historical average weather by calling the get_historical_weather function. + """ get historical average weather by calling the + get_historical_weather function. Args: day (int) - day part of date. month (int) - month part of date. @@ -160,10 +176,13 @@ def get_forecast_by_historical_data(day, month, location): """ relevant_year = get_history_relevant_year(day, month) try: - input_date = datetime.datetime(year=relevant_year, month=month, day=day) + input_date = datetime.datetime(year=relevant_year, month=month, + day=day) except ValueError: - # only if the day & month are 29.02 and there is no such date on the relevant year - input_date = datetime.datetime(year=relevant_year, month=month, day=day - 1) + # only if day & month are 29.02 and there is no such date + # on the relevant year + input_date = datetime.datetime(year=relevant_year, month=month, + day=day - 1) return get_historical_weather(input_date, location) @@ -195,9 +214,11 @@ def get_forecast(requested_date, location): """ forecast_type = get_forecast_type(requested_date) if forecast_type == HISTORY_TYPE: - weather_json, error_text = get_historical_weather(requested_date, location) + weather_json, error_text = get_historical_weather(requested_date, + location) if forecast_type == FORECAST_TYPE: - weather_json, error_text = get_forecast_weather(requested_date, location) + weather_json, error_text = get_forecast_weather(requested_date, + location) if forecast_type == HISTORICAL_FORECAST_TYPE: weather_json, error_text = get_forecast_by_historical_data( requested_date.day, requested_date.month, location) @@ -222,11 +243,12 @@ def get_weather_data(requested_date, location): "forecast" - relevant for the upcoming 15 days. "history" - historical data. "historical average" - average of the last 3 years on that date. - relevant for future dates (more then forecast). + relevant for future dates (more then forecast). Address - The location found by the service. """ output = {} - requested_date = datetime.date(requested_date.year, requested_date.month, requested_date.day) + requested_date = datetime.date(requested_date.year, requested_date.month, + requested_date.day) valid_input, error_text = validate_date_input(requested_date) if valid_input: weather_json, error_text = get_forecast(requested_date, location) @@ -236,8 +258,10 @@ def get_weather_data(requested_date, location): else: output["Status"] = SUCCESS_STATUS output["ErrorDescription"] = None - output["MinTempFar"] = round((weather_json['MinTempCel'] * 9/5) + 32) - output["MaxTempFar"] = round((weather_json['MaxTempCel'] * 9/5) + 32) + output["MinTempFar"] = round((weather_json['MinTempCel'] * 9/5) + + 32) + output["MaxTempFar"] = round((weather_json['MaxTempCel'] * 9/5) + + 32) output.update(weather_json) else: output["Status"] = ERROR_STATUS diff --git a/tests/test_weather_forecast.py b/tests/test_weather_forecast.py index 77973214..ba8e3066 100644 --- a/tests/test_weather_forecast.py +++ b/tests/test_weather_forecast.py @@ -5,17 +5,25 @@ DATA_GET_WEATHER = [ - pytest.param(2020, "tel aviv", 0, marks=pytest.mark.xfail, id="invalid input type"), - pytest.param(datetime.datetime(day=4, month=4, year=2070), "tel aviv", 0, marks=pytest.mark.xfail, id="year out of range"), - pytest.param(datetime.datetime(day=4, month=4, year=2020), "tel aviv", 0, id="basic historical test"), - pytest.param(datetime.datetime(day=1, month=1, year=2030), "tel aviv", 0, id="basic historical forecast test - prior in current year"), - pytest.param(datetime.datetime(day=31, month=12, year=2030), "tel aviv", 0, id="basic historical forecast test - future"), - pytest.param(datetime.datetime(day=29, month=2, year=2024), "tel aviv", 0, id="basic historical forecast test"), - pytest.param(datetime.datetime(day=15, month=1, year=2020), "neo", 0, marks=pytest.mark.xfail, id="location not found test"), + pytest.param(2020, "tel aviv", 0, marks=pytest.mark.xfail, + id="invalid input type"), + pytest.param(datetime.datetime(day=4, month=4, year=2070), "tel aviv", 0, + marks=pytest.mark.xfail, id="year out of range"), + pytest.param(datetime.datetime(day=4, month=4, year=2020), + "tel aviv", 0, id="basic historical test"), + pytest.param(datetime.datetime(day=1, month=1, year=2030), "tel aviv", 0, + id="basic historical forecast test - prior in current year"), + pytest.param(datetime.datetime(day=31, month=12, year=2030), + "tel aviv", 0, id="basic historical forecast test - future"), + pytest.param(datetime.datetime(day=29, month=2, year=2024), "tel aviv", + 0, id="basic historical forecast test"), + pytest.param(datetime.datetime(day=15, month=1, year=2020), "neo", 0, + marks=pytest.mark.xfail, id="location not found test"), ] -@pytest.mark.parametrize('requested_date, location, expected', DATA_GET_WEATHER) +@pytest.mark.parametrize('requested_date, location, expected', + DATA_GET_WEATHER) def test_get_weather_data(requested_date, location, expected): output = get_weather_data(requested_date, location) assert output['Status'] == expected From b998fc50b2483a3adc4580855e61e7620de062fc Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Wed, 20 Jan 2021 18:00:45 +0200 Subject: [PATCH 041/108] feat: get weather forecast - fix changes & add cache support --- app/routers/weather_forecast.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/routers/weather_forecast.py b/app/routers/weather_forecast.py index 5b68b604..17f54af5 100644 --- a/app/routers/weather_forecast.py +++ b/app/routers/weather_forecast.py @@ -52,8 +52,8 @@ def freezeargs(func): def wrapped(*args, **kwargs): args = tuple([frozendict.frozendict(arg) if isinstance(arg, dict) else arg for arg in args]) - kwargs = {k: frozendict.frozendict(v) if isinstance(v, dict) - else v for k, v in kwargs.items()} + kwargs = {k: frozendict.frozendict(v) if isinstance(v, dict) else v + for k, v in kwargs.items()} return func(*args, **kwargs) return wrapped @@ -134,7 +134,8 @@ def get_forecast_weather(input_date, location): weather_data = { 'MinTempCel': api_json[location_found]['values'][i]['mint'], 'MaxTempCel': api_json[location_found]['values'][i]['maxt'], - 'Conditions': api_json[location_found]['values'][i]['conditions'], + 'Conditions': + api_json[location_found]['values'][i]['conditions'], 'Address': location_found} return weather_data, None From 8b97c29e71ea365cd0e5ab364936dce5df7b6d7c Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Wed, 20 Jan 2021 19:37:01 +0200 Subject: [PATCH 042/108] feat: add route tests --- app/config.py | 1 - app/database/models.py | 2 +- app/internal/utils.py | 3 +- app/routers/agenda.py | 6 ++-- app/routers/event.py | 1 + app/routers/share.py | 6 ++-- tests/client_fixture.py | 60 ++++++++++++++++++++++++++++++++++++++ tests/conftest.py | 39 +++---------------------- tests/test_agenda_route.py | 29 +++++++++--------- tests/test_app.py | 9 ++++++ tests/test_home.py | 12 ++++---- tests/test_invitation.py | 10 +++++-- tests/test_share_event.py | 1 - tests/user_fixture.py | 2 +- 14 files changed, 112 insertions(+), 69 deletions(-) create mode 100644 tests/client_fixture.py diff --git a/app/config.py b/app/config.py index 442b2df1..ef583d4e 100644 --- a/app/config.py +++ b/app/config.py @@ -13,4 +13,3 @@ # export ICAL_VERSION = '2.0' PRODUCT_ID = '-//Our product id//' -OPTIONAL = [] diff --git a/app/database/models.py b/app/database/models.py index f6e609cc..a8a8f9a0 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -30,7 +30,7 @@ class User(Base): full_name = Column(String) description = Column(String, default="Happy new user!") avatar = Column(String, default="profile.png") - is_active = Column(Boolean, default=True) + is_active = Column(Boolean, default=False) events = relationship("UserEvent", back_populates="participants") diff --git a/app/internal/utils.py b/app/internal/utils.py index 3a72c611..90a647b8 100644 --- a/app/internal/utils.py +++ b/app/internal/utils.py @@ -18,6 +18,5 @@ def create_model(session: Session, model_class, **kw): """Creates and saves a db model.""" instance = model_class(**kw) - session.add(instance) - session.commit() + save(instance, session) return instance diff --git a/app/routers/agenda.py b/app/routers/agenda.py index cf57eaee..35a032e0 100644 --- a/app/routers/agenda.py +++ b/app/routers/agenda.py @@ -16,7 +16,7 @@ def calc_dates_range_for_agenda( start: Optional[date], end: Optional[date], - days: Optional[int] + days: Optional[int], ) -> Tuple[date, date]: """Create start and end dates according to the parameters in the page.""" if days is not None: @@ -34,7 +34,7 @@ def agenda( db: Session = Depends(get_db), start_date: Optional[date] = None, end_date: Optional[date] = None, - days: Optional[int] = None + days: Optional[int] = None, ) -> _TemplateResponse: """Route for the agenda page, using dates range or exact amount of days.""" @@ -57,5 +57,5 @@ def agenda( "request": request, "events": events, "start_date": start_date, - "end_date": end_date + "end_date": end_date, }) diff --git a/app/routers/event.py b/app/routers/event.py index 17a8aa8a..d24e3df0 100644 --- a/app/routers/event.py +++ b/app/routers/event.py @@ -24,6 +24,7 @@ async def eventview(request: Request, id: int): def create_event(db, title, start, end, content, owner_id): + """Creates an event and an association.""" event = create_model( db, Event, diff --git a/app/routers/share.py b/app/routers/share.py index 4ea00846..5328a5a3 100644 --- a/app/routers/share.py +++ b/app/routers/share.py @@ -10,7 +10,7 @@ def sort_emails( participants: List[str], - session: Session + session: Session, ) -> Dict[str, List[str]]: """Sorts emails to registered and unregistered users.""" @@ -18,9 +18,9 @@ def sort_emails( for participant in participants: if does_user_exist(email=participant, session=session): - emails['registered'] += [participant] + emails['registered'].append(participant) else: - emails['unregistered'] += [participant] + emails['unregistered'].append(participant) return emails diff --git a/tests/client_fixture.py b/tests/client_fixture.py new file mode 100644 index 00000000..c99b5fe5 --- /dev/null +++ b/tests/client_fixture.py @@ -0,0 +1,60 @@ +from fastapi.testclient import TestClient +import pytest + +from app.database.models import User +from app.main import app +from app.database.database import Base +from app.routers import profile, agenda, invitation +from tests.conftest import test_engine, get_test_db + + +@pytest.fixture(scope="session") +def client(): + return TestClient(app) + + +@pytest.fixture(scope="session") +def agenda_test_client(): + Base.metadata.create_all(bind=test_engine) + app.dependency_overrides[agenda.get_db] = get_test_db + + with TestClient(app) as client: + yield client + + app.dependency_overrides = {} + Base.metadata.drop_all(bind=test_engine) + + +@pytest.fixture(scope="session") +def invitation_test_client(): + Base.metadata.create_all(bind=test_engine) + app.dependency_overrides[invitation.get_db] = get_test_db + + with TestClient(app) as client: + yield client + + app.dependency_overrides = {} + Base.metadata.drop_all(bind=test_engine) + + +@pytest.fixture(scope="session") +def profile_test_client(): + Base.metadata.create_all(bind=test_engine) + app.dependency_overrides[profile.get_db] = get_test_db + app.dependency_overrides[ + profile.get_placeholder_user] = get_test_placeholder_user + + with TestClient(app) as client: + yield client + + app.dependency_overrides = {} + Base.metadata.drop_all(bind=test_engine) + + +def get_test_placeholder_user(): + return User( + username='fake_user', + email='fake@mail.fake', + password='123456fake', + full_name='FakeName' + ) diff --git a/tests/conftest.py b/tests/conftest.py index 7ec1eed8..08754a56 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,19 +1,15 @@ -from fastapi.testclient import TestClient import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -from app.main import app -from app.database.database import Base, SessionLocal, engine -from app.database.models import User -from app.routers import profile - +from app.database.database import Base pytest_plugins = [ 'tests.user_fixture', 'tests.event_fixture', 'tests.invitation_fixture', 'tests.association_fixture', + 'tests.client_fixture', ] SQLALCHEMY_TEST_DATABASE_URL = "sqlite:///./test.db" @@ -29,37 +25,10 @@ def get_test_db(): return TestingSessionLocal() -@pytest.fixture(scope="session") -def client(): - return TestClient(app) - - @pytest.fixture def session(): - Base.metadata.create_all(bind=engine) - session = SessionLocal() + Base.metadata.create_all(bind=test_engine) + session = get_test_db() yield session session.close() - Base.metadata.drop_all(bind=engine) - - -def get_test_placeholder_user(): - return User( - username='fake_user', - email='fake@mail.fake', - password='123456fake', - full_name='FakeName' - ) - - -@pytest.fixture -def profile_test_client(): Base.metadata.drop_all(bind=test_engine) - Base.metadata.create_all(bind=test_engine) - app.dependency_overrides[profile.get_db] = get_test_db - app.dependency_overrides[ - profile.get_placeholder_user] = get_test_placeholder_user - - with TestClient(app) as client: - yield client - app.dependency_overrides = {} diff --git a/tests/test_agenda_route.py b/tests/test_agenda_route.py index f7ca1d75..c877a4e8 100644 --- a/tests/test_agenda_route.py +++ b/tests/test_agenda_route.py @@ -15,17 +15,18 @@ class TestAgenda: today_date = datetime.today().replace(hour=0, minute=0, second=0) @staticmethod - def test_agenda_page_no_arguments_when_no_today_events(client, session): - resp = client.get(TestAgenda.AGENDA) + def test_agenda_page_no_arguments_when_no_today_events( + agenda_test_client, session): + resp = agenda_test_client.get(TestAgenda.AGENDA) assert resp.status_code == status.HTTP_200_OK assert TestAgenda.NO_EVENTS in resp.content def test_agenda_page_no_arguments_when_today_events_exist( - self, client, session, sender, today_event, + self, agenda_test_client, session, sender, today_event, today_event_2, yesterday_event, next_week_event, next_month_event, old_event ): - resp = client.get(TestAgenda.AGENDA) + resp = agenda_test_client.get(TestAgenda.AGENDA) assert resp.status_code == status.HTTP_200_OK assert b"event 1" in resp.content assert b"event 2" in resp.content @@ -36,11 +37,11 @@ def test_agenda_page_no_arguments_when_today_events_exist( @staticmethod def test_agenda_per_7_days( - client, session, sender, today_event, + agenda_test_client, session, sender, today_event, today_event_2, yesterday_event, next_week_event, next_month_event, old_event ): - resp = client.get(TestAgenda.AGENDA_7_DAYS) + resp = agenda_test_client.get(TestAgenda.AGENDA_7_DAYS) today = date.today().strftime("%d/%m/%Y") assert resp.status_code == status.HTTP_200_OK assert bytes(today, 'utf-8') in resp.content @@ -53,11 +54,11 @@ def test_agenda_per_7_days( @staticmethod def test_agenda_per_30_days( - client, session, sender, today_event, + agenda_test_client, session, sender, today_event, today_event_2, yesterday_event, next_week_event, next_month_event, old_event ): - resp = client.get(TestAgenda.AGENDA_30_DAYS) + resp = agenda_test_client.get(TestAgenda.AGENDA_30_DAYS) today = date.today().strftime("%d/%m/%Y") assert resp.status_code == status.HTTP_200_OK assert bytes(today, 'utf-8') in resp.content @@ -69,13 +70,13 @@ def test_agenda_per_30_days( assert b"event 6" not in resp.content def test_agenda_between_two_dates( - self, client, session, sender, today_event, + self, agenda_test_client, session, sender, today_event, today_event_2, yesterday_event, next_week_event, next_month_event, old_event ): start_date = (self.today_date + timedelta(days=8, hours=4)).date() end_date = (self.today_date + timedelta(days=32, hours=4)).date() - resp = client.get( + resp = agenda_test_client.get( f"/agenda?start_date={start_date}&end_date={end_date}") assert resp.status_code == status.HTTP_200_OK assert b"event 1" not in resp.content @@ -85,21 +86,21 @@ def test_agenda_between_two_dates( assert b"event 5" in resp.content assert b"event 6" not in resp.content - def test_agenda_start_bigger_than_end(self, client): + def test_agenda_start_bigger_than_end(self, agenda_test_client): start_date = self.today_date.date() end_date = (self.today_date - timedelta(days=2)).date() - resp = client.get( + resp = agenda_test_client.get( f"/agenda?start_date={start_date}&end_date={end_date}") assert resp.status_code == status.HTTP_200_OK assert TestAgenda.INVALID_DATES in resp.content @staticmethod def test_no_show_events_user_2( - client, session, sender, today_event, + agenda_test_client, session, sender, today_event, today_event_2, yesterday_event, next_week_event, next_month_event, old_event ): # "user" is just a different event creator - resp = client.get(TestAgenda.AGENDA) + resp = agenda_test_client.get(TestAgenda.AGENDA) assert resp.status_code == status.HTTP_200_OK assert b"event 7" not in resp.content diff --git a/tests/test_app.py b/tests/test_app.py index e69de29b..2a08e499 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -0,0 +1,9 @@ +from sqlalchemy.orm import Session + +from app.database.database import get_db + + +class TestApp: + + def test_get_db(self): + assert isinstance(next(get_db()), Session) diff --git a/tests/test_home.py b/tests/test_home.py index fc0b6772..63ed38e8 100644 --- a/tests/test_home.py +++ b/tests/test_home.py @@ -1,6 +1,6 @@ -class TestHome: - URL = "/" - - def test_get_page(self, client): - resp = client.get(self.URL) - assert resp.status_code == 200 +# class TestHome: +# URL = "/" +# +# def test_get_page(self, client): +# resp = client.get(self.URL) +# assert resp.status_code == 200 diff --git a/tests/test_invitation.py b/tests/test_invitation.py index 163ef5f6..fa4b2133 100644 --- a/tests/test_invitation.py +++ b/tests/test_invitation.py @@ -3,12 +3,18 @@ class TestInvitations: NO_INVITATIONS = b"You don't have any invitations." + URL = "/invitations" - def test_view_no_invitations(self, client): - resp = client.get("/invitations") + def test_view_no_invitations(self, invitation_test_client): + resp = invitation_test_client.get(self.URL) assert resp.status_code == 200 assert self.NO_INVITATIONS in resp.content + def test_accept_invitations(self, invitation, invitation_test_client): + resp = invitation_test_client.post( + self.URL, data={"invite_id ": invitation.id}) + assert resp.status_code == 307 + def test_get_all_invitations_success( self, invitation, event, user, session ): diff --git a/tests/test_share_event.py b/tests/test_share_event.py index 191252ca..b85d5a81 100644 --- a/tests/test_share_event.py +++ b/tests/test_share_event.py @@ -37,7 +37,6 @@ def test_send_in_app_invitation_success( session.delete(invitation) def test_send_in_app_invitation_failure(self, event, session): - send_in_app_invitation([event.owner.email], event, session=session) invitation = get_all_invitations( recipient=event.owner, session=session) assert invitation == [] diff --git a/tests/user_fixture.py b/tests/user_fixture.py index 20c25ac7..526cc10f 100644 --- a/tests/user_fixture.py +++ b/tests/user_fixture.py @@ -21,7 +21,7 @@ def user(session: Session) -> User: def sender(session: Session) -> User: sender = create_model( session, User, - username='sender_email', + username='sender_username', password='sender_password', email='sender.email@gmail.com', ) From f035f215a5598bb16c782566de6a05b531bbb5e7 Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Wed, 20 Jan 2021 20:09:03 +0200 Subject: [PATCH 043/108] feat: get weather forecast - fix changes & add cache support --- app/config.py.example | 5 ++++- app/routers/weather_forecast.py | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/config.py.example b/app/config.py.example index 11763783..9bb03845 100644 --- a/app/config.py.example +++ b/app/config.py.example @@ -1,3 +1,6 @@ +import os + + # flake8: noqa @@ -10,4 +13,4 @@ PICTURE_EXTENSION = '.png' AVATAR_SIZE = (120, 120) # API-KEYS -WEATHER_API_KEY=os.getenv('WEATHER_API_KEY') +WEATHER_API_KEY = os.getenv('WEATHER_API_KEY') diff --git a/app/routers/weather_forecast.py b/app/routers/weather_forecast.py index 17f54af5..af354358 100644 --- a/app/routers/weather_forecast.py +++ b/app/routers/weather_forecast.py @@ -1,4 +1,6 @@ import datetime +import os + import frozendict import functools import requests @@ -268,3 +270,6 @@ def get_weather_data(requested_date, location): output["Status"] = ERROR_STATUS output["ErrorDescription"] = error_text return output + + +print(os.getenv()) \ No newline at end of file From 5f8efc8917a37d2d3014f3f8a6ebd70e0846cfc2 Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Wed, 20 Jan 2021 20:11:52 +0200 Subject: [PATCH 044/108] feat: get weather forecast - fix changes & add cache support --- app/routers/weather_forecast.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/routers/weather_forecast.py b/app/routers/weather_forecast.py index af354358..17f54af5 100644 --- a/app/routers/weather_forecast.py +++ b/app/routers/weather_forecast.py @@ -1,6 +1,4 @@ import datetime -import os - import frozendict import functools import requests @@ -270,6 +268,3 @@ def get_weather_data(requested_date, location): output["Status"] = ERROR_STATUS output["ErrorDescription"] = error_text return output - - -print(os.getenv()) \ No newline at end of file From ba09ff01536248957b42c56c2f7f1498636d85e7 Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Thu, 21 Jan 2021 02:02:55 +0200 Subject: [PATCH 045/108] fix: test bug --- app/config.py.example | 2 -- app/internal/agenda_events.py | 23 ++++++---------- app/routers/event.py | 3 +- app/routers/invitation.py | 14 ++++++---- tests/event_fixture.py | 17 ++---------- tests/test_agenda_internal.py | 52 ++++++++++++++++++++++++++--------- tests/test_home.py | 12 ++++---- tests/test_invitation.py | 11 +++++--- tests/test_share_event.py | 8 +++--- tests/test_user.py | 1 + 10 files changed, 79 insertions(+), 64 deletions(-) diff --git a/app/config.py.example b/app/config.py.example index 82b4e79e..ef583d4e 100644 --- a/app/config.py.example +++ b/app/config.py.example @@ -1,4 +1,3 @@ -# flake8: noqa # general DOMAIN = 'Our-Domain' @@ -14,4 +13,3 @@ AVATAR_SIZE = (120, 120) # export ICAL_VERSION = '2.0' PRODUCT_ID = '-//Our product id//' -OPTIONAL = [] diff --git a/app/internal/agenda_events.py b/app/internal/agenda_events.py index 248c6188..052cf8b0 100644 --- a/app/internal/agenda_events.py +++ b/app/internal/agenda_events.py @@ -2,7 +2,6 @@ from typing import List, Optional import arrow -from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session from app.database.models import Event @@ -21,20 +20,16 @@ def get_events_per_dates( if start > end: return [] - try: - events = ( - filter_dates( - sort_by_date( - get_all_user_events(session, user_id) - ), - start, - end, - ) + + return ( + filter_dates( + sort_by_date( + get_all_user_events(session, user_id) + ), + start, + end, ) - except SQLAlchemyError: - return [] - else: - return events + ) def build_arrow_delta_granularity(diff: timedelta) -> List[str]: diff --git a/app/routers/event.py b/app/routers/event.py index d24e3df0..8571d510 100644 --- a/app/routers/event.py +++ b/app/routers/event.py @@ -23,7 +23,7 @@ async def eventview(request: Request, id: int): {"request": request, "event_id": id}) -def create_event(db, title, start, end, content, owner_id): +def create_event(db, title, start, end, owner_id, content=None, location=None): """Creates an event and an association.""" event = create_model( @@ -33,6 +33,7 @@ def create_event(db, title, start, end, content, owner_id): end=end, content=content, owner_id=owner_id, + location=location, ) create_model( db, UserEvent, diff --git a/app/routers/invitation.py b/app/routers/invitation.py index 11fed3bc..4a4e9491 100644 --- a/app/routers/invitation.py +++ b/app/routers/invitation.py @@ -1,6 +1,6 @@ from typing import List, Union -from fastapi import APIRouter, Depends, Form, Request +from fastapi import APIRouter, Depends, Request from fastapi.responses import RedirectResponse from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.orm import Session @@ -35,11 +35,15 @@ def view_invitations(request: Request, db: Session = Depends(get_db)): @router.post("/") async def accept_invitations( - invite_id: int = Form(...), - db: Session = Depends(get_db) + request: Request, + db: Session = Depends(get_db) ): - i = get_invitation_by_id(invite_id, session=db) - accept(i, db) + data = await request.form() + invite_id = list(data.values())[0] + + invitation = get_invitation_by_id(invite_id, session=db) + accept(invitation, db) + url = router.url_path_for("view_invitations") return RedirectResponse(url=url, status_code=HTTP_302_FOUND) diff --git a/tests/event_fixture.py b/tests/event_fixture.py index 3d8bd3bd..eef02e02 100644 --- a/tests/event_fixture.py +++ b/tests/event_fixture.py @@ -14,11 +14,12 @@ def event(sender: User, session: Session) -> Event: return create_event( db=session, - title='today_event', + title='event', start=today_date, end=today_date, content='test event', owner_id=sender.id, + location="Some random location", ) @@ -92,17 +93,3 @@ def old_event(sender: User, session: Session) -> Event: content='test event', owner_id=sender.id, ) - - -@pytest.fixture -def user_event(user: User, session: Session) -> Event: - """Only this event is created by "user" and not "sender".""" - - return create_event( - db=session, - title='event 7', - start=today_date - timedelta(days=5), - end=today_date, - content='test event', - owner_id=user.id, - ) diff --git a/tests/test_agenda_internal.py b/tests/test_agenda_internal.py index 173a01d1..b6e3eb21 100644 --- a/tests/test_agenda_internal.py +++ b/tests/test_agenda_internal.py @@ -1,20 +1,46 @@ -from datetime import datetime +from datetime import datetime, date from app.internal import agenda_events import pytest -START = datetime(2021, 11, 1, 8, 00, 00) +from app.internal.agenda_events import get_events_per_dates -dates = [ - (START, datetime(2021, 11, 3, 8, 00, 0), '2 days'), - (START, datetime(2021, 11, 3, 10, 30, 0), '2 days 2 hours and 30 minutes'), - (START, datetime(2021, 11, 1, 8, 30, 0), '30 minutes'), - (START, datetime(2021, 11, 1, 10, 00, 0), '2 hours'), - (START, datetime(2021, 11, 1, 10, 30, 0), '2 hours and 30 minutes'), - (START, datetime(2021, 11, 2, 10, 00, 0), 'a day and 2 hours'), -] +class TestAgenda: + START = datetime(2021, 11, 1, 8, 00, 00) + dates = [ + (START, datetime(2021, 11, 3, 8, 00, 0), + '2 days'), + (START, datetime(2021, 11, 3, 10, 30, 0), + '2 days 2 hours and 30 minutes'), + (START, datetime(2021, 11, 1, 8, 30, 0), + '30 minutes'), + (START, datetime(2021, 11, 1, 10, 00, 0), + '2 hours'), + (START, datetime(2021, 11, 1, 10, 30, 0), + '2 hours and 30 minutes'), + (START, datetime(2021, 11, 2, 10, 00, 0), + 'a day and 2 hours'), + ] -@pytest.mark.parametrize('start, end, diff', dates) -def test_get_time_delta_string(start, end, diff): - assert agenda_events.get_time_delta_string(start, end) == diff + @pytest.mark.parametrize('start, end, diff', dates) + def test_get_time_delta_string(self, start, end, diff): + assert agenda_events.get_time_delta_string(start, end) == diff + + def test_get_events_per_dates_success(self, today_event, session): + events = get_events_per_dates( + session=session, + user_id=today_event.owner_id, + start=today_event.start.date(), + end=today_event.end.date(), + ) + assert list(events) == [today_event] + + def test_get_events_per_dates_failure(self, yesterday_event, session): + events = get_events_per_dates( + session=session, + user_id=yesterday_event.owner_id, + start=date.today(), + end=date.today(), + ) + assert list(events) == [] diff --git a/tests/test_home.py b/tests/test_home.py index 63ed38e8..fc0b6772 100644 --- a/tests/test_home.py +++ b/tests/test_home.py @@ -1,6 +1,6 @@ -# class TestHome: -# URL = "/" -# -# def test_get_page(self, client): -# resp = client.get(self.URL) -# assert resp.status_code == 200 +class TestHome: + URL = "/" + + def test_get_page(self, client): + resp = client.get(self.URL) + assert resp.status_code == 200 diff --git a/tests/test_invitation.py b/tests/test_invitation.py index fa4b2133..68b55d88 100644 --- a/tests/test_invitation.py +++ b/tests/test_invitation.py @@ -3,17 +3,20 @@ class TestInvitations: NO_INVITATIONS = b"You don't have any invitations." - URL = "/invitations" + URL = "/invitations/" def test_view_no_invitations(self, invitation_test_client): resp = invitation_test_client.get(self.URL) assert resp.status_code == 200 assert self.NO_INVITATIONS in resp.content - def test_accept_invitations(self, invitation, invitation_test_client): + def test_accept_invitations( + self, user, invitation, + invitation_test_client): + invitation = {"invite_id ": invitation.id} resp = invitation_test_client.post( - self.URL, data={"invite_id ": invitation.id}) - assert resp.status_code == 307 + self.URL, data=invitation) + assert resp.status_code == 302 def test_get_all_invitations_success( self, invitation, event, user, session diff --git a/tests/test_share_event.py b/tests/test_share_event.py index b85d5a81..15a43bd5 100644 --- a/tests/test_share_event.py +++ b/tests/test_share_event.py @@ -36,10 +36,10 @@ def test_send_in_app_invitation_success( assert invitation.recipient == user session.delete(invitation) - def test_send_in_app_invitation_failure(self, event, session): - invitation = get_all_invitations( - recipient=event.owner, session=session) - assert invitation == [] + def test_send_in_app_invitation_failure( + self, user, sender, event, session): + assert (send_in_app_invitation( + [sender.email], event, session=session) is None) def test_send_email_invitation(self, user, event): send_email_invitation([user.email], event) diff --git a/tests/test_user.py b/tests/test_user.py index 7f50e8a8..9e2a9a84 100644 --- a/tests/test_user.py +++ b/tests/test_user.py @@ -32,6 +32,7 @@ def test_does_user_exist_success(self, user, session): def test_does_user_exist_failure(self, 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'' From 3400fe975d63664557f4972a831dd33e43c5d0eb Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Thu, 21 Jan 2021 08:13:06 +0200 Subject: [PATCH 046/108] remove: config.py --- app/config.py | 31 ------------------------------- 1 file changed, 31 deletions(-) delete mode 100644 app/config.py diff --git a/app/config.py b/app/config.py deleted file mode 100644 index 9063b9b7..00000000 --- a/app/config.py +++ /dev/null @@ -1,31 +0,0 @@ -import os - -from fastapi_mail import ConnectionConfig -# flake8: noqa - -# general -DOMAIN = 'Our-Domain' - -# DATABASE -DEVELOPMENT_DATABASE_STRING = "sqlite:///./dev.db" - -# MEDIA -MEDIA_DIRECTORY = 'media' -PICTURE_EXTENSION = '.png' -AVATAR_SIZE = (120, 120) - -# export -ICAL_VERSION = '2.0' -PRODUCT_ID = '-//Our product id//' - -# email -email_conf = ConnectionConfig( - MAIL_USERNAME=os.getenv("MAIL_USERNAME") or "user", - MAIL_PASSWORD=os.getenv("MAIL_PASSWORD") or "password", - MAIL_FROM=os.getenv("MAIL_FROM") or "a@a.com", - MAIL_PORT=587, - MAIL_SERVER="smtp.gmail.com", - MAIL_TLS=True, - MAIL_SSL=False, - USE_CREDENTIALS=True, -) From eb5b7dc69eb6bd0de955445a72f52cf09df493bd Mon Sep 17 00:00:00 2001 From: i Date: Wed, 20 Jan 2021 21:02:40 +0200 Subject: [PATCH 047/108] Add categories table --- app/database/models.py | 37 +++++++++++++++++++++++++++++++++++-- app/main.py | 14 ++++++++------ app/routers/agenda.py | 2 +- 3 files changed, 44 insertions(+), 9 deletions(-) diff --git a/app/database/models.py b/app/database/models.py index 0c92ae94..69a6b8e0 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1,7 +1,8 @@ -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, UniqueConstraint from sqlalchemy.orm import relationship -from .database import Base +from app.database.database import Base +from sqlalchemy.orm import Session class User(Base): @@ -30,5 +31,37 @@ class Event(Base): start = Column(DateTime, nullable=False) end = Column(DateTime, nullable=False) owner_id = Column(Integer, ForeignKey("users.id")) + category_id = Column(Integer, ForeignKey("categories.id")) owner = relationship("User", back_populates="events") + + +class Category(Base): + __tablename__ = "categories" + + __table_args__ = ( + UniqueConstraint('user_id', 'name', 'color'), + ) + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + color = Column(String, nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + @staticmethod + def create(db_session: Session, name: str, color: str, user_id: int): + print(Base.metadata.tables.keys()) + try: + category = Category(name=name, color=color, user_id=user_id) + db_session.add(category) + db_session.flush() + db_session.commit() + db_session.refresh(category) + return category + except Exception as e: + raise e + + def to_dict(self): + return {c.name: getattr(self, c.name) for c in self.__table__.columns} + + def __repr__(self): + return f'' diff --git a/app/main.py b/app/main.py index 07861586..13f4c43b 100644 --- a/app/main.py +++ b/app/main.py @@ -7,17 +7,19 @@ MEDIA_PATH, STATIC_PATH, templates) from app.routers import agenda, event, profile, email - models.Base.metadata.create_all(bind=engine) -app = FastAPI() +app = FastAPI(debug=True) app.mount("/static", StaticFiles(directory=STATIC_PATH), name="static") app.mount("/media", StaticFiles(directory=MEDIA_PATH), name="media") -app.include_router(profile.router) -app.include_router(event.router) -app.include_router(agenda.router) -app.include_router(email.router) +routers_to_include = [ + agenda.router, + event.router, + profile.router, +] +for router in routers_to_include: + app.include_router(router) @app.get("/") diff --git a/app/routers/agenda.py b/app/routers/agenda.py index f8fd532b..53c57df9 100644 --- a/app/routers/agenda.py +++ b/app/routers/agenda.py @@ -19,7 +19,7 @@ def calc_dates_range_for_agenda( end: Optional[date], days: Optional[int] ) -> Tuple[date, date]: - """Create start and end dates eccording to the parameters in the page.""" + """Create start and end dates according to the parameters in the page.""" if days is not None: start = date.today() end = start + timedelta(days=days) From 2312caa9bce151e8922efb1540acad674456a0ab Mon Sep 17 00:00:00 2001 From: i Date: Wed, 20 Jan 2021 23:22:49 +0200 Subject: [PATCH 048/108] Add router for categories --- app/database/models.py | 1 - app/main.py | 3 +- app/routers/categories.py | 69 +++++++++++++++++++++++++++++++++++++++ schema.md | 2 ++ 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 app/routers/categories.py diff --git a/app/database/models.py b/app/database/models.py index 69a6b8e0..b1db3eb5 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -49,7 +49,6 @@ class Category(Base): @staticmethod def create(db_session: Session, name: str, color: str, user_id: int): - print(Base.metadata.tables.keys()) try: category = Category(name=name, color=color, user_id=user_id) db_session.add(category) diff --git a/app/main.py b/app/main.py index 13f4c43b..cdebc273 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ from app.database.database import engine from app.dependencies import ( MEDIA_PATH, STATIC_PATH, templates) -from app.routers import agenda, event, profile, email +from app.routers import agenda, event, profile, categories models.Base.metadata.create_all(bind=engine) @@ -15,6 +15,7 @@ routers_to_include = [ agenda.router, + categories.router, event.router, profile.router, ] diff --git a/app/routers/categories.py b/app/routers/categories.py new file mode 100644 index 00000000..6501de58 --- /dev/null +++ b/app/routers/categories.py @@ -0,0 +1,69 @@ +from typing import Dict, List + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel +from sqlalchemy.exc import IntegrityError, SQLAlchemyError +from sqlalchemy.orm import Session +from starlette import status + +from app.database.database import get_db +from app.database.models import Category + +router = APIRouter( + prefix="/categories", + tags=["categories"], +) + + +class CategoryModel(BaseModel): + name: str + color: str + user_id: int + + +# TODO(issue#29): get user_id from session +@router.get("/") +def get_categories(request: Request, db_session: Session = Depends(get_db)) -> List[Category]: + if validate_request_params(request): + 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 unhallowed params.") + + +# TODO(issue#29): get user_id from session +@router.post("/") +async def set_category(category: CategoryModel, db_session: Session = Depends(get_db)) -> Dict: + try: + cat = Category.create(db_session, name=category.name, color=category.color, user_id=category.user_id) + except IntegrityError: + db_session.rollback() + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail=f"category is already exists for {category.user_id} user.") + else: + return {"category": cat.to_dict()} + + +def validate_request_params(request: Request) -> bool: + """ + request contains not more than user_id, name, color and not less than user_id: + Intersection must contain at least user_id. + Union must not contain fields other than user_id, name, color. + + """ + all_fields = set(CategoryModel.schema()["required"]) + union_set = set(request.keys()).union(all_fields) + intersection_set = set(request.keys()).intersection(all_fields) + return union_set == all_fields and {"user_id"} in intersection_set + + +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() + except SQLAlchemyError: + return [] + else: + return categories diff --git a/schema.md b/schema.md index 58140f95..443cd9a8 100644 --- a/schema.md +++ b/schema.md @@ -13,6 +13,8 @@ │ ├── admin.py │ ├── routers │ ├── __init__.py +│ ├── agenda.py +│ ├── categories.py │ ├── profile.py │ ├── media │ ├── example.png From f1463a4c4797f76c348a88a32df07f6c23191041 Mon Sep 17 00:00:00 2001 From: Sagi Zaid Or Date: Thu, 21 Jan 2021 21:08:47 +0200 Subject: [PATCH 049/108] --- .gitignore | 7 +- app/database/models.py | 1 + app/media/fake_user.png | Bin 0 -> 3556 bytes app/routers/dayview.py | 106 ++++++++++++++------- app/static/dayview.css | 39 ++++---- app/static/images/icons/pencil.svg | 1 + app/static/images/icons/trash-can.svg | 1 + app/templates/dayview.html | 24 +++-- tests/test_dayview.py | 128 ++++++++++++++++---------- 9 files changed, 198 insertions(+), 109 deletions(-) create mode 100644 app/media/fake_user.png create mode 100644 app/static/images/icons/pencil.svg create mode 100644 app/static/images/icons/trash-can.svg diff --git a/.gitignore b/.gitignore index 102175e3..0fdacf96 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ dev.db test.db config.py +.vscode/settings.json # Byte-compiled / optimized / DLL files __pycache__/ @@ -113,6 +114,8 @@ venv/ ENV/ env.bak/ venv.bak/ +Scripts/* +pyvenv.cfg # Spyder project settings .spyderproject @@ -131,7 +134,5 @@ dmypy.json # Pyre type checker .pyre/ -Scripts/* -pyvenv.cfg -.gitignore +app/routers/stam diff --git a/app/database/models.py b/app/database/models.py index 0c92ae94..63c10476 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -30,5 +30,6 @@ class Event(Base): start = Column(DateTime, nullable=False) end = Column(DateTime, nullable=False) owner_id = Column(Integer, ForeignKey("users.id")) + color = Column(String, nullable=True) owner = relationship("User", back_populates="events") diff --git a/app/media/fake_user.png b/app/media/fake_user.png new file mode 100644 index 0000000000000000000000000000000000000000..bd856aaa02ed8fc75fb310971827e4734b8785d5 GIT binary patch literal 3556 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 literal 0 HcmV?d00001 diff --git a/app/routers/dayview.py b/app/routers/dayview.py index a45b1bbe..94654935 100644 --- a/app/routers/dayview.py +++ b/app/routers/dayview.py @@ -1,8 +1,12 @@ -from datetime import datetime +from datetime import datetime, timedelta +from typing import Optional -from fastapi import APIRouter, Request +from fastapi import APIRouter, Depends, Request from fastapi.templating import Jinja2Templates +from sqlalchemy import and_, or_ +from app.database.database import get_db +from app.database.models import Event, User from app.dependencies import TEMPLATES_PATH @@ -12,57 +16,97 @@ router = APIRouter() -# inner class of the router, for the jinja page to process the json -class Event: +class DivAttributes: GRID_BAR_QUARTER = 1 FULL_GRID_BAR = 4 - MIN_MINUTS = 0 - MAX_MINUTS = 15 + MIN_MINUTES = 0 + MAX_MINUTES = 15 BASE_GRID_BAR = 5 - - def __init__(self, id: int, color: str, content: str, - start_datetime: str, end_datetime: str) -> None: - self.id = id - self.color = color - self.content = content - self.start_time = datetime.strptime(start_datetime, "%d/%m/%Y %H:%M") - self.end_time = datetime.strptime(end_datetime, "%d/%m/%Y %H:%M") + FIRST_GRID_BAR = 1 + LAST_GRID_BAR = 101 + + def __init__(self, event: Event, day: Optional[datetime] = False) -> None: + self.start_time = event.start + self.end_time = event.end + self.day = day + self._check_if_multiday_event() + self.color = self._check_color(event.color) self._set_total_time() self._set_grid_position() + def _check_color(self, color: str) -> str: + if color is None: + return 'grey' + else: + return color + def _minutes_position(self, minutes: int) -> int: for i in range(self.GRID_BAR_QUARTER, self.FULL_GRID_BAR + 1): - if self.MIN_MINUTS <= minutes < self.MAX_MINUTS: + if self.MIN_MINUTES < minutes <= self.MAX_MINUTES: return i - self.MIN_MINUTS = self.MAX_MINUTS - self.MAX_MINUTS += 15 + self.MIN_MINUTES = self.MAX_MINUTES + self.MAX_MINUTES += 15 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) -> None: - start = self._get_position(self.start_time) - end = self._get_position(self.end_time) + if self.start_multiday: + start = self.FIRST_GRID_BAR + else: + start = self._get_position(self.start_time) + if self.end_multiday: + end = self.LAST_GRID_BAR + else: + end = self._get_position(self.end_time) self.grid_position = f'{start} / {end}' - def _set_total_time(self): + def _get_time_format(self): + format = "%H:%M" + multiday_format = "%d/%m %H:%M" + for multiday in [self.start_multiday, self.end_multiday]: + if multiday: + yield multiday_format + else: + yield format + + def _set_total_time(self) -> None: length = self.end_time - self.start_time self.length = length.seconds / 60 - - start_time_str = self.start_time.strftime("%H:%M") - end_time_str = self.end_time.strftime("%H:%M") + format_gen = self._get_time_format() + start_time_str = self.start_time.strftime(next(format_gen)) + end_time_str = self.end_time.strftime(next(format_gen)) self.total_time = ' '.join([start_time_str, '-', end_time_str]) - -@router.post("/dayview") -async def dayview(request: Request): - form = await request.json() - events = [Event(**event) for event in form['events']] + def _check_if_multiday_event(self) -> None: + self.start_multiday, self.end_multiday = False, False + if self.day: + if self.start_time < self.day: + self.start_multiday = True + self.day += timedelta(hours=24) + if self.day <= self.end_time: + self.end_multiday = True + + +@router.get('/day/{date}') +async def dayview(request: Request, date: str, db_session=Depends(get_db)): + + # temporary fake user until there will be login session + user = db_session.query(User).filter_by(username='test1').first() + day = datetime.strptime(date, '%d-%m-%Y') + events = db_session.query(Event).filter( + Event.owner_id == user.id).filter( + or_(Event.start >= day, + Event.end < day, + and_(Event.start < day, day < Event.end))) + events_n_divAttr = [(event, DivAttributes(event, day)) for event in events] return templates.TemplateResponse("dayview.html", { "request": request, - "events": events, - "MONTH": events[0].start_time.strftime("%B").upper(), - "DAY": form['day'] + "events": events_n_divAttr, + "MONTH": day.strftime("%B").upper(), + "DAY": day.day }) diff --git a/app/static/dayview.css b/app/static/dayview.css index c261f512..9331ba6f 100644 --- a/app/static/dayview.css +++ b/app/static/dayview.css @@ -12,21 +12,6 @@ html { text-align: center; } -#phoneframe { - width: 260px; - height: 480px; - border: 1px solid black; - box-shadow: 0px 0px 10px; - overflow: scroll; - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; -} - -/* Hide scrollbar for Chrome, Safari and Opera */ -#phoneframe::-webkit-scrollbar { - display: none; - } - #toptab { background-color: var(--theme-blue); } @@ -69,4 +54,26 @@ html { .total-time { font-size: 0.4rem; line-height: 1rem; -} \ No newline at end of file +} + +.actiongrid { + grid-row: 1 / -1; + grid-column: 1 / -1; + display: grid; + grid-template-rows: repeat(100, 0.375rem); + z-index: 42; +} + +.action-icon { + visibility: hidden; +} + +.action-continer:hover { + border-top: 1px dashed var(--theme-grey); + border-bottom: 1px dashed var(--theme-grey); + transition: 0.3; +} + +.action-continer:hover .action-icon { + visibility: visible; +} diff --git a/app/static/images/icons/pencil.svg b/app/static/images/icons/pencil.svg new file mode 100644 index 00000000..7b1ccd37 --- /dev/null +++ b/app/static/images/icons/pencil.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/static/images/icons/trash-can.svg b/app/static/images/icons/trash-can.svg new file mode 100644 index 00000000..7bdadb8a --- /dev/null +++ b/app/static/images/icons/trash-can.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/templates/dayview.html b/app/templates/dayview.html index 125b97da..fb5f5ada 100644 --- a/app/templates/dayview.html +++ b/app/templates/dayview.html @@ -24,29 +24,37 @@ {% endfor %}
- {% for event in events %} + {% for event, attr in events %} {% set totaltime = 'visible'%} - {% if event.length < 60 %} + {% if attr.length < 60 %} {% set size = '0.6em' %} {% set totaltime = 'invisible'%} - {% if event.length < 45 %} + {% if attr.length < 45 %} {% set size = '0.4em' %} - {% if event.length < 30 %} + {% if attr.length < 30 %} {% set size = '0.1em; line-height: 4em' %} {% endif %} {% endif %} {% endif %} -
-

{{event.content}}

+
+

{{ event.title }}

{% if totaltime == 'visible' %} -

{{event.total_time}}

+

{{attr.total_time}}

{% endif %}
{% endfor %}
{% for i in range(25)%} -
000
+
---
+ {% endfor %} +
+
+ {% for event, attr in events %} +
+ + +
{% endfor %}
diff --git a/tests/test_dayview.py b/tests/test_dayview.py index e417909b..e0f42c7c 100644 --- a/tests/test_dayview.py +++ b/tests/test_dayview.py @@ -1,56 +1,82 @@ +from datetime import datetime, timedelta + +from bs4 import BeautifulSoup import pytest -from app.routers.dayview import Event +from app.database.models import Event, User +from app.routers.dayview import DivAttributes + + +@pytest.fixture +def user(): + return User(username='test1', email='user@email.com', + password='1a2b3c4e5f', full_name='test me') + + +@pytest.fixture +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) + + +@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') @pytest.fixture -def event(): - event_id = 123 - color = 'red' - content = 'nothing' - start = "03/2/2021 4:05" - end = "03/2/2021 4:20" - event = Event(id=event_id, color=color, - content=content, start_datetime=start, - end_datetime=end) - return event - - -def test_new_event(event): - assert event.id == 123 - assert event.color == 'red' - assert event.content == 'nothing' - assert event.total_time == '04:05 - 04:20' - assert event.grid_position == '22 / 23' - assert event.length == 15 - - -def test_dayview_html(client, event): - events = [{"id": event.id, "color": event.color, - "content": event.content, - "start_datetime": "3/2/2021 04:05", - "end_datetime": "3/2/2021 04:20", - }] - day = {"year": 2021, "month": 2, "day": 3, "events": events} - response = client.post("/dayview", json=day) - res = response.content.decode("utf-8") - assert 'grid-row: 22 / 23;' in res - assert '
Date: Thu, 21 Jan 2021 21:16:45 +0200 Subject: [PATCH 050/108] after all notes and started use the orm --- app/routers/dayview.py | 4 ++-- app/templates/dayview.html | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/routers/dayview.py b/app/routers/dayview.py index 94654935..a8d9e6db 100644 --- a/app/routers/dayview.py +++ b/app/routers/dayview.py @@ -107,6 +107,6 @@ async def dayview(request: Request, date: str, db_session=Depends(get_db)): return templates.TemplateResponse("dayview.html", { "request": request, "events": events_n_divAttr, - "MONTH": day.strftime("%B").upper(), - "DAY": day.day + "month": day.strftime("%B").upper(), + "day": day.day }) diff --git a/app/templates/dayview.html b/app/templates/dayview.html index fb5f5ada..02972234 100644 --- a/app/templates/dayview.html +++ b/app/templates/dayview.html @@ -11,8 +11,8 @@
- {{MONTH}} - {{DAY}} + {{month}} + {{day}}
From 2f57ab6241c1268ccc6f7e2b9bb5c332fa0c60ed Mon Sep 17 00:00:00 2001 From: i Date: Thu, 21 Jan 2021 23:49:25 +0200 Subject: [PATCH 051/108] Add tests + schema --- app/main.py | 3 +- app/routers/categories.py | 21 +++++----- schema.md | 29 ++++++++++---- tests/conftest.py | 49 ++++++++--------------- tests/db_entities.py | 38 ++++++++++++++++++ tests/test_categories.py | 81 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 169 insertions(+), 52 deletions(-) create mode 100644 tests/db_entities.py create mode 100644 tests/test_categories.py diff --git a/app/main.py b/app/main.py index cdebc273..e0b17f40 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ from app.database.database import engine from app.dependencies import ( MEDIA_PATH, STATIC_PATH, templates) -from app.routers import agenda, event, profile, categories +from app.routers import agenda, categories, email, event, profile models.Base.metadata.create_all(bind=engine) @@ -16,6 +16,7 @@ routers_to_include = [ agenda.router, categories.router, + email.router, event.router, profile.router, ] diff --git a/app/routers/categories.py b/app/routers/categories.py index 6501de58..f7fd738e 100644 --- a/app/routers/categories.py +++ b/app/routers/categories.py @@ -5,6 +5,7 @@ from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.orm import Session from starlette import status +from starlette.datastructures import ImmutableMultiDict from app.database.database import get_db from app.database.models import Category @@ -21,17 +22,17 @@ class CategoryModel(BaseModel): user_id: int -# TODO(issue#29): get user_id from session +# TODO(issue#29): get current user_id from session @router.get("/") def get_categories(request: Request, db_session: Session = Depends(get_db)) -> List[Category]: - if validate_request_params(request): + 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 unhallowed params.") + detail=f"Request {request.query_params} contains unallowed params.") -# TODO(issue#29): get user_id from session +# TODO(issue#29): get current user_id from session @router.post("/") async def set_category(category: CategoryModel, db_session: Session = Depends(get_db)) -> Dict: try: @@ -44,17 +45,17 @@ async def set_category(category: CategoryModel, db_session: Session = Depends(ge return {"category": cat.to_dict()} -def validate_request_params(request: Request) -> bool: +def validate_request_params(query_params: ImmutableMultiDict) -> bool: """ - request contains not more than user_id, name, color and not less than user_id: + request.query_params contains not more than user_id, name, color and not less than user_id: Intersection must contain at least user_id. Union must not contain fields other than user_id, name, color. - """ all_fields = set(CategoryModel.schema()["required"]) - union_set = set(request.keys()).union(all_fields) - intersection_set = set(request.keys()).intersection(all_fields) - return union_set == all_fields and {"user_id"} in intersection_set + request_params = set(query_params) + union_set = request_params.union(all_fields) + intersection_set = request_params.intersection(all_fields) + return union_set == all_fields and "user_id" in intersection_set def get_user_categories(db_session: Session, user_id: int, **params) -> List[Category]: diff --git a/schema.md b/schema.md index 443cd9a8..a0d2120f 100644 --- a/schema.md +++ b/schema.md @@ -11,17 +11,26 @@ │ ├── internal │ ├── __init__.py │ ├── admin.py +│ ├── agenda_events.py +│ ├── email.py +│ ├── media +│ ├── example.png +│ ├── fake_user.png +│ ├── profile.png │ ├── routers │ ├── __init__.py │ ├── agenda.py │ ├── categories.py +│ ├── email.py +│ ├── event.py │ ├── profile.py -│ ├── media -│ ├── example.png -│ ├── profile.png │ ├── static -│ ├── style.css +│ ├── event +│ ├── eventedit.css +│ ├── eventview.css +│ ├── agenda_style.css │ ├── popover.js +│ ├── style.css │ ├── templates │ ├── base.html │ ├── home.html @@ -31,6 +40,12 @@ ├── schema.md └── tests ├── __init__.py - └── conftest.py - └── test_profile.py - └── test_app.py \ No newline at end of file + ├── conftest.py + ├── db_entities.py + ├── test_agenda_internal.py + ├── test_agenda_route.py + ├── test_app.py + ├── test_categories.py + ├── test_email.py + ├── test_event.py + └── test_profile.py \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 0631b61d..2f4f7e4d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,15 +1,12 @@ -import datetime - import pytest -from app.database.database import Base, SessionLocal, engine -from app.database.models import Event, User -from app.main import app -from app.routers import profile -from faker import Faker from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -pytest_plugins = "smtpdfix" + +from app.database.database import Base, engine, SessionLocal +from app.database.models import User +from app.main import app +from app.routers import profile SQLALCHEMY_TEST_DATABASE_URL = "sqlite:///./test.db" @@ -24,9 +21,16 @@ def get_test_db(): return TestingSessionLocal() +pytest_plugins = [ + 'tests.db_entities', + 'smtpdfix' +] + + @pytest.fixture -def client(): - return TestClient(app) +def client() -> TestClient: + with TestClient(app) as c: + yield c @pytest.fixture @@ -34,34 +38,11 @@ def session(): Base.metadata.create_all(bind=engine) session = SessionLocal() yield session + session.rollback() session.close() Base.metadata.drop_all(bind=engine) -@pytest.fixture -def user(session): - faker = Faker() - user1 = User(username=faker.first_name(), email=faker.email()) - session.add(user1) - session.commit() - yield user1 - session.delete(user1) - session.commit() - - -@pytest.fixture -def event(session, user): - event1 = Event( - title="Test Email", content="Test TEXT", - start=datetime.datetime.now(), - end=datetime.datetime.now(), owner_id=user.id) - session.add(event1) - session.commit() - yield event1 - session.delete(event1) - session.commit() - - def get_test_placeholder_user(): return User( username='fake_user', diff --git a/tests/db_entities.py b/tests/db_entities.py new file mode 100644 index 00000000..8c4afbe6 --- /dev/null +++ b/tests/db_entities.py @@ -0,0 +1,38 @@ +import datetime + +import pytest +from faker import Faker + +from app.database.models import User, Event, Category + + +@pytest.fixture +def user(session): + faker = Faker() + user1 = User(username=faker.first_name(), email=faker.email()) + session.add(user1) + session.commit() + yield user1 + session.delete(user1) + session.commit() + + +@pytest.fixture +def event(session, user): + event1 = Event( + title="Test Email", content="Test TEXT", + start=datetime.datetime.now(), + end=datetime.datetime.now(), owner_id=user.id) + session.add(event1) + session.commit() + yield event1 + session.delete(event1) + session.commit() + + +@pytest.fixture +def category(session, user): + category = Category.create(session, name="Guitar Lesson", color="121212", user_id=user.id) + yield category + session.delete(category) + session.commit() diff --git a/tests/test_categories.py b/tests/test_categories.py new file mode 100644 index 00000000..a2322703 --- /dev/null +++ b/tests/test_categories.py @@ -0,0 +1,81 @@ +import pytest +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 + + +class TestCategories: + CATEGORY_ALREADY_EXISTS_MESSAGE = "category is already exists for {0} user." + UNALLOWED_PARAMS = "contains unallowed params" + + @staticmethod + def test_creating_new_category(client, user): + response = client.post("/categories/", json={"user_id": user.id, "name": "Foo", "color": "eecc11"}) + assert response.status_code == status.HTTP_200_OK + assert {"user_id": user.id, "name": "Foo", "color": "eecc11"}.items() <= response.json()['category'].items() + + @staticmethod + def test_creating_not_unique_category_failed(client, user, category): + response = client.post("/categories/", json={"user_id": user.id, "name": "Guitar Lesson", "color": "121212"}) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["detail"] == TestCategories.CATEGORY_ALREADY_EXISTS_MESSAGE.format(user.id) + + @staticmethod + def test_create_event_with_category(category): + 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 + + @staticmethod + def test_get_user_categories(client, category): + response = client.get(f"/categories/?user_id={category.user_id}&name={category.name}&color={category.color}") + assert response.status_code == status.HTTP_200_OK + assert response.json() == [ + {"user_id": category.user_id, "color": "121212", "name": "Guitar Lesson", "id": category.id}] + + @staticmethod + def test_get_category_by_name(client, user, category): + response = client.get(f"/categories/?user_id={category.user_id}&name={category.name}") + assert response.status_code == status.HTTP_200_OK + assert response.json() == [ + {"user_id": category.user_id, "color": "121212", "name": "Guitar Lesson", "id": category.id}] + + @staticmethod + def test_get_category_by_color(client, user, category): + response = client.get(f"/categories/?user_id={category.user_id}&color={category.color}") + assert response.status_code == status.HTTP_200_OK + assert response.json() == [ + {"user_id": category.user_id, "color": "121212", "name": "Guitar Lesson", "id": category.id}] + + @staticmethod + def test_get_category_bad_request(client): + response = client.get(f"/categories/") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert TestCategories.UNALLOWED_PARAMS in response.json()["detail"] + + @staticmethod + def test_repr(category): + 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 + def test_get_categories_logic_succeeded(session, user, category): + assert get_user_categories(session, category.user_id) == [category] + + @staticmethod + @pytest.mark.parametrize('params, expected_result', [ + (ImmutableMultiDict([('user_id', ''), ('name', ''), ('color', '')]), True), + (ImmutableMultiDict([('user_id', ''), ('name', '')]), True), + (ImmutableMultiDict([('user_id', ''), ('color', '')]), True), + (ImmutableMultiDict([('user_id', '')]), True), + (ImmutableMultiDict([('name', ''), ('color', '')]), False), + (ImmutableMultiDict([]), False), + (ImmutableMultiDict([('user_id', ''), ('name', ''), ('color', ''), ('bad_param', '')]), False), + ]) + def test_validate_request_params(params, expected_result): + assert validate_request_params(params) == expected_result From e4e2314b3b8fddd4cceb31ce33f7043af60bd992 Mon Sep 17 00:00:00 2001 From: i Date: Thu, 21 Jan 2021 23:56:34 +0200 Subject: [PATCH 052/108] Remove debug --- app/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index e0b17f40..43cf889a 100644 --- a/app/main.py +++ b/app/main.py @@ -9,7 +9,7 @@ models.Base.metadata.create_all(bind=engine) -app = FastAPI(debug=True) +app = FastAPI() app.mount("/static", StaticFiles(directory=STATIC_PATH), name="static") app.mount("/media", StaticFiles(directory=MEDIA_PATH), name="media") From 101b659ea57036ad9b8e5c395ae42350deac9771 Mon Sep 17 00:00:00 2001 From: i Date: Fri, 22 Jan 2021 00:10:42 +0200 Subject: [PATCH 053/108] Linter --- app/database/models.py | 5 +++-- app/routers/categories.py | 20 ++++++++++++----- tests/db_entities.py | 3 ++- tests/test_categories.py | 47 ++++++++++++++++++++++++++------------- 4 files changed, 50 insertions(+), 25 deletions(-) diff --git a/app/database/models.py b/app/database/models.py index b1db3eb5..991708a6 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1,8 +1,9 @@ -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, UniqueConstraint +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, \ + UniqueConstraint +from sqlalchemy.orm import Session from sqlalchemy.orm import relationship from app.database.database import Base -from sqlalchemy.orm import Session class User(Base): diff --git a/app/routers/categories.py b/app/routers/categories.py index f7fd738e..a12db0dd 100644 --- a/app/routers/categories.py +++ b/app/routers/categories.py @@ -24,19 +24,25 @@ class CategoryModel(BaseModel): # TODO(issue#29): get current user_id from session @router.get("/") -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 unallowed params.") + detail=f"Request {request.query_params} contains " + f"unallowed params.") # TODO(issue#29): get current user_id from session @router.post("/") -async def set_category(category: CategoryModel, db_session: Session = Depends(get_db)) -> Dict: +async def set_category(category: CategoryModel, + db_session: Session = Depends(get_db)) -> Dict: try: - cat = Category.create(db_session, name=category.name, color=category.color, user_id=category.user_id) + cat = Category.create(db_session, + name=category.name, + color=category.color, + user_id=category.user_id) except IntegrityError: db_session.rollback() raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, @@ -58,12 +64,14 @@ def validate_request_params(query_params: ImmutableMultiDict) -> bool: return union_set == all_fields and "user_id" in intersection_set -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: diff --git a/tests/db_entities.py b/tests/db_entities.py index 8c4afbe6..e6fac60a 100644 --- a/tests/db_entities.py +++ b/tests/db_entities.py @@ -32,7 +32,8 @@ def event(session, user): @pytest.fixture def category(session, user): - category = Category.create(session, name="Guitar Lesson", color="121212", user_id=user.id) + category = Category.create(session, name="Guitar Lesson", + color="121212", user_id=user.id) yield category session.delete(category) session.commit() diff --git a/tests/test_categories.py b/tests/test_categories.py index a2322703..6e0ac37e 100644 --- a/tests/test_categories.py +++ b/tests/test_categories.py @@ -7,47 +7,58 @@ class TestCategories: - CATEGORY_ALREADY_EXISTS_MESSAGE = "category is already exists for {0} user." + CATEGORY_ALREADY_EXISTS_MSG = "category is already exists for" UNALLOWED_PARAMS = "contains unallowed params" @staticmethod def test_creating_new_category(client, user): - response = client.post("/categories/", json={"user_id": user.id, "name": "Foo", "color": "eecc11"}) + response = client.post("/categories/", json={"user_id": user.id, + "name": "Foo", "color": "eecc11"}) assert response.status_code == status.HTTP_200_OK - assert {"user_id": user.id, "name": "Foo", "color": "eecc11"}.items() <= response.json()['category'].items() + assert {"user_id": user.id, "name": "Foo", "color": "eecc11"}.items() \ + <= response.json()['category'].items() @staticmethod def test_creating_not_unique_category_failed(client, user, category): - response = client.post("/categories/", json={"user_id": user.id, "name": "Guitar Lesson", "color": "121212"}) + response = client.post("/categories/", json={"user_id": user.id, + "name": "Guitar Lesson", "color": "121212"}) assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json()["detail"] == TestCategories.CATEGORY_ALREADY_EXISTS_MESSAGE.format(user.id) + assert TestCategories.CATEGORY_ALREADY_EXISTS_MSG 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 @staticmethod def test_get_user_categories(client, category): - response = client.get(f"/categories/?user_id={category.user_id}&name={category.name}&color={category.color}") + response = client.get(f"/categories/?user_id={category.user_id}" + f"&name={category.name}&color={category.color}") assert response.status_code == status.HTTP_200_OK assert response.json() == [ - {"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, user, category): - response = client.get(f"/categories/?user_id={category.user_id}&name={category.name}") + response = client.get(f"/categories/?user_id={category.user_id}" + f"&name={category.name}") assert response.status_code == status.HTTP_200_OK assert response.json() == [ - {"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, user, category): - response = client.get(f"/categories/?user_id={category.user_id}&color={category.color}") + response = client.get(f"/categories/?user_id={category.user_id}&" + f"color={category.color}") assert response.status_code == status.HTTP_200_OK assert response.json() == [ - {"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): @@ -57,11 +68,13 @@ def test_get_category_bad_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() + assert {c.name: getattr(category, c.name) for c in + category.__table__.columns} == category.to_dict() @staticmethod def test_get_categories_logic_succeeded(session, user, category): @@ -69,13 +82,15 @@ def test_get_categories_logic_succeeded(session, user, category): @staticmethod @pytest.mark.parametrize('params, expected_result', [ - (ImmutableMultiDict([('user_id', ''), ('name', ''), ('color', '')]), True), + (ImmutableMultiDict([('user_id', ''), ('name', ''), + ('color', '')]), True), (ImmutableMultiDict([('user_id', ''), ('name', '')]), True), (ImmutableMultiDict([('user_id', ''), ('color', '')]), True), (ImmutableMultiDict([('user_id', '')]), True), (ImmutableMultiDict([('name', ''), ('color', '')]), False), (ImmutableMultiDict([]), False), - (ImmutableMultiDict([('user_id', ''), ('name', ''), ('color', ''), ('bad_param', '')]), False), + (ImmutableMultiDict([('user_id', ''), ('name', ''), ('color', ''), + ('bad_param', '')]), False), ]) def test_validate_request_params(params, expected_result): assert validate_request_params(params) == expected_result From e0c75f5061fa7f9d607f2a561ed8007195e16c2f Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Fri, 22 Jan 2021 08:10:00 +0200 Subject: [PATCH 054/108] fix: type annotation --- app/internal/agenda_events.py | 8 ++++---- app/internal/event.py | 0 app/internal/events.py | 12 ------------ app/routers/event.py | 14 +++++++++++++- 4 files changed, 17 insertions(+), 17 deletions(-) create mode 100644 app/internal/event.py delete mode 100644 app/internal/events.py diff --git a/app/internal/agenda_events.py b/app/internal/agenda_events.py index 052cf8b0..f3c79d9b 100644 --- a/app/internal/agenda_events.py +++ b/app/internal/agenda_events.py @@ -1,11 +1,11 @@ from datetime import date, timedelta -from typing import List, Optional +from typing import List, Optional, Union, Iterator import arrow from sqlalchemy.orm import Session from app.database.models import Event -from app.internal.events import sort_by_date +from app.routers.event import sort_by_date from app.routers.user import get_all_user_events @@ -14,7 +14,7 @@ def get_events_per_dates( user_id: int, start: Optional[date], end: Optional[date] -) -> List[Event]: +) -> Union[Iterator[Event], list]: """Read from the db. Return a list of all the user events between the relevant dates.""" @@ -60,7 +60,7 @@ def get_time_delta_string(start: date, end: date) -> str: def filter_dates( events: List[Event], start: Optional[date], - end: Optional[date]) -> List[Event]: + end: Optional[date]) -> Iterator[Event]: """filter events by a time frame.""" yield from ( diff --git a/app/internal/event.py b/app/internal/event.py new file mode 100644 index 00000000..e69de29b diff --git a/app/internal/events.py b/app/internal/events.py deleted file mode 100644 index 3f5686d4..00000000 --- a/app/internal/events.py +++ /dev/null @@ -1,12 +0,0 @@ -from operator import attrgetter -from typing import List - -from app.database.models import Event - - -def sort_by_date(events: List[Event]) -> List[Event]: - """Sorts the events by the start of the event.""" - - temp = events.copy() - temp.sort(key=attrgetter('start')) - return temp diff --git a/app/routers/event.py b/app/routers/event.py index 8571d510..0e993c34 100644 --- a/app/routers/event.py +++ b/app/routers/event.py @@ -1,6 +1,10 @@ +from operator import attrgetter +from typing import List + from fastapi import APIRouter, Request -from app.database.models import Event, UserEvent +from app.database.models import Event +from app.database.models import UserEvent from app.dependencies import templates from app.internal.utils import create_model @@ -41,3 +45,11 @@ def create_event(db, title, start, end, owner_id, content=None, location=None): event_id=event.id ) return event + + +def sort_by_date(events: List[Event]) -> List[Event]: + """Sorts the events by the start of the event.""" + + temp = events.copy() + temp.sort(key=attrgetter('start')) + return temp From 32c106bfc14de26b615bd35462b2b9cd8bc50ffa Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Fri, 22 Jan 2021 10:31:53 +0200 Subject: [PATCH 055/108] feat: get weather forecast - add API mocking --- tests/test_weather_forecast.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/tests/test_weather_forecast.py b/tests/test_weather_forecast.py index ba8e3066..eb9c17ad 100644 --- a/tests/test_weather_forecast.py +++ b/tests/test_weather_forecast.py @@ -4,6 +4,11 @@ from app.routers.weather_forecast import get_weather_data +HISTORY_URL = "https://visual-crossing-weather.p.rapidapi.com/history" +FORECAST_URL = "https://visual-crossing-weather.p.rapidapi.com/forecast" +RESPONSE_FROM_MOCK = {"locations": {"Tel Aviv": {"values": [ + {"mint": 6, "maxt": 17.2, "conditions": "Partially cloudy"}]}}} +LOCATION_NOT_FOUND = {"message": "location not found"} DATA_GET_WEATHER = [ pytest.param(2020, "tel aviv", 0, marks=pytest.mark.xfail, id="invalid input type"), @@ -17,19 +22,30 @@ "tel aviv", 0, id="basic historical forecast test - future"), pytest.param(datetime.datetime(day=29, month=2, year=2024), "tel aviv", 0, id="basic historical forecast test"), - pytest.param(datetime.datetime(day=15, month=1, year=2020), "neo", 0, - marks=pytest.mark.xfail, id="location not found test"), ] @pytest.mark.parametrize('requested_date, location, expected', DATA_GET_WEATHER) -def test_get_weather_data(requested_date, location, expected): +def test_get_weather_data(requested_date, location, expected, requests_mock): + requests_mock.get(HISTORY_URL, json=RESPONSE_FROM_MOCK) output = get_weather_data(requested_date, location) assert output['Status'] == expected -def test_get_forecast_weather_data(): +@pytest.mark.xfail +def test_location_not_found(requests_mock): + requested_date = datetime.datetime(day=15, month=1, year=2020) + requests_mock.get(HISTORY_URL, json=LOCATION_NOT_FOUND) + output = get_weather_data(requested_date, "neo") + assert output['Status'] == 0 + + +def test_get_forecast_weather_data(requests_mock): temp_date = datetime.datetime.now() + datetime.timedelta(days=2) + response_from_mock = RESPONSE_FROM_MOCK + response_from_mock["locations"]["Tel Aviv"]["values"][0]["datetimeStr"] =\ + temp_date.isoformat() + requests_mock.get(FORECAST_URL, json=response_from_mock) output = get_weather_data(temp_date, "tel aviv") assert output['Status'] == 0 From 4fdb671c49552635f10322ff600ce2d9efdff4d5 Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Fri, 22 Jan 2021 10:37:42 +0200 Subject: [PATCH 056/108] feat: get weather forecast - add API mocking --- requirements.txt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index 6efb41ed..ff81efc3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,13 +8,10 @@ click==7.1.2 colorama==0.4.4 coverage==5.3.1 fastapi==0.63.0 -<<<<<<< HEAD -frozendict~=1.2 -======= fastapi_mail==0.3.3.1 faker==5.6.2 +frozendict~=1.2 smtpdfix==0.2.6 ->>>>>>> 2055db7a5598794df6ec487fd7e82c0639b56bb9 h11==0.12.0 h2==4.0.0 hpack==4.0.0 From 880725158e9aecf089182b3ea6a808ae734d121c Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Fri, 22 Jan 2021 10:41:40 +0200 Subject: [PATCH 057/108] feat: get weather forecast - add API mocking --- app/config.py.example | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/app/config.py.example b/app/config.py.example index 1cca4b97..b49f8dcc 100644 --- a/app/config.py.example +++ b/app/config.py.example @@ -1,10 +1,7 @@ import os -<<<<<<< HEAD - -======= from fastapi_mail import ConnectionConfig ->>>>>>> 2055db7a5598794df6ec487fd7e82c0639b56bb9 + # flake8: noqa @@ -16,10 +13,9 @@ MEDIA_DIRECTORY = 'media' PICTURE_EXTENSION = '.png' AVATAR_SIZE = (120, 120) -<<<<<<< HEAD # API-KEYS WEATHER_API_KEY = os.getenv('WEATHER_API_KEY') -======= + email_conf = ConnectionConfig( MAIL_USERNAME=os.getenv("MAIL_USERNAME") or "user", MAIL_PASSWORD=os.getenv("MAIL_PASSWORD") or "password", @@ -30,4 +26,3 @@ email_conf = ConnectionConfig( MAIL_SSL=False, USE_CREDENTIALS=True, ) ->>>>>>> 2055db7a5598794df6ec487fd7e82c0639b56bb9 From 046cc8dc7f1ef30c69384e94d30a91526e400fce Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Fri, 22 Jan 2021 10:45:27 +0200 Subject: [PATCH 058/108] feat: get weather forecast - add API mocking --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index ff81efc3..9e75c85b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,6 +35,7 @@ python-dotenv==0.15.0 python-multipart==0.0.5 PyYAML==5.3.1 requests==2.25.1 +requests-mock==1.8.0 six==1.15.0 SQLAlchemy==1.3.22 starlette==0.13.6 From c1b3a123d6e562855b9717cea823cefa77480b47 Mon Sep 17 00:00:00 2001 From: Sagi Zaid Or Date: Fri, 22 Jan 2021 10:52:27 +0200 Subject: [PATCH 059/108] fixed: changed "size" var in html and and more acuurate typing --- app/routers/dayview.py | 42 +++++++++++++++++++++----------------- app/static/dayview.css | 17 +++++++++++++-- app/templates/dayview.html | 10 ++++----- 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/app/routers/dayview.py b/app/routers/dayview.py index a8d9e6db..4ceedc9c 100644 --- a/app/routers/dayview.py +++ b/app/routers/dayview.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta -from typing import Optional +from typing import Union, Tuple from fastapi import APIRouter, Depends, Request from fastapi.templating import Jinja2Templates @@ -25,20 +25,20 @@ class DivAttributes: FIRST_GRID_BAR = 1 LAST_GRID_BAR = 101 - def __init__(self, event: Event, day: Optional[datetime] = False) -> None: + def __init__(self, event: Event, + day: Union[bool, datetime] = False) -> None: self.start_time = event.start self.end_time = event.end self.day = day - self._check_if_multiday_event() + self.start_multiday, self.end_multiday = self._check_multiday_event() self.color = self._check_color(event.color) - self._set_total_time() - self._set_grid_position() + self.total_time = self._set_total_time() + self.grid_position = self._set_grid_position() def _check_color(self, color: str) -> str: if color is None: return 'grey' - else: - return color + return color def _minutes_position(self, minutes: int) -> int: for i in range(self.GRID_BAR_QUARTER, self.FULL_GRID_BAR + 1): @@ -54,7 +54,7 @@ def _get_position(self, time: datetime) -> int: grid_minutes_modifier = 0 return grid_hour_position + grid_minutes_modifier + self.BASE_GRID_BAR - def _set_grid_position(self) -> None: + def _set_grid_position(self) -> str: if self.start_multiday: start = self.FIRST_GRID_BAR else: @@ -63,9 +63,9 @@ def _set_grid_position(self) -> None: end = self.LAST_GRID_BAR else: end = self._get_position(self.end_time) - self.grid_position = f'{start} / {end}' + return f'{start} / {end}' - def _get_time_format(self): + def _get_time_format(self) -> str: format = "%H:%M" multiday_format = "%d/%m %H:%M" for multiday in [self.start_multiday, self.end_multiday]: @@ -80,29 +80,33 @@ def _set_total_time(self) -> None: format_gen = self._get_time_format() start_time_str = self.start_time.strftime(next(format_gen)) end_time_str = self.end_time.strftime(next(format_gen)) - self.total_time = ' '.join([start_time_str, '-', end_time_str]) + return ' '.join([start_time_str, '-', end_time_str]) - def _check_if_multiday_event(self) -> None: - self.start_multiday, self.end_multiday = False, False + def _check_multiday_event(self) -> Tuple[bool]: + start_multiday, end_multiday = False, False if self.day: if self.start_time < self.day: - self.start_multiday = True + start_multiday = True self.day += timedelta(hours=24) if self.day <= self.end_time: - self.end_multiday = True + end_multiday = True + return (start_multiday, end_multiday) @router.get('/day/{date}') async def dayview(request: Request, date: str, db_session=Depends(get_db)): - # temporary fake user until there will be login session - user = db_session.query(User).filter_by(username='test1').first() + #user = db_session.query(User).filter_by(username='test1').first() day = datetime.strptime(date, '%d-%m-%Y') - events = db_session.query(Event).filter( + '''events = db_session.query(Event).filter( Event.owner_id == user.id).filter( or_(Event.start >= day, Event.end < day, - and_(Event.start < day, day < Event.end))) + and_(Event.start < day, day < Event.end)))''' + start = datetime(year=2021, month=2, day=1, hour=13, minute=13) + end = datetime(year=2021, month=2, day=1, hour=14, minute=20) + events = [Event(title='test2', content='test', + start=start, end=end, owner_id=1, color='pink')] events_n_divAttr = [(event, DivAttributes(event, day)) for event in events] return templates.TemplateResponse("dayview.html", { "request": request, diff --git a/app/static/dayview.css b/app/static/dayview.css index 9331ba6f..398f161f 100644 --- a/app/static/dayview.css +++ b/app/static/dayview.css @@ -4,7 +4,7 @@ --theme-yellow:#FFDE4D; --theme-red:#EF5454; --theme-grey:#E7E7E7; - --theme-ligth-grey:#F7F7F7; + --theme-light-grey:#F7F7F7; } html { @@ -17,7 +17,7 @@ html { } -.scedual { +.schedule { display: grid; grid-template-rows: 1; } @@ -56,6 +56,19 @@ html { line-height: 1rem; } +.title_size_small { + font-size: 0.6em; +} + +.title_size_Xsmall { + font-size: 0.4em; +} + +.title_size_tiny { + font-size: 0.1em; + line-height: 4em; +} + .actiongrid { grid-row: 1 / -1; grid-column: 1 / -1; diff --git a/app/templates/dayview.html b/app/templates/dayview.html index 02972234..53d983fb 100644 --- a/app/templates/dayview.html +++ b/app/templates/dayview.html @@ -14,7 +14,7 @@ {{month}} {{day}}
-
+
{% for i in range(24)%}
@@ -27,17 +27,17 @@ {% for event, attr in events %} {% set totaltime = 'visible'%} {% if attr.length < 60 %} - {% set size = '0.6em' %} + {% set size = 'title_size_small' %} {% set totaltime = 'invisible'%} {% if attr.length < 45 %} - {% set size = '0.4em' %} + {% set size = 'title_size_Xsmall' %} {% if attr.length < 30 %} - {% set size = '0.1em; line-height: 4em' %} + {% set size = 'title_size_tiny' %} {% endif %} {% endif %} {% endif %}
-

{{ event.title }}

+

{{ event.title }}

{% if totaltime == 'visible' %}

{{attr.total_time}}

{% endif %} From 8131ab70b089bc52ce73918441fe46f058e0ca06 Mon Sep 17 00:00:00 2001 From: Sagi Zaid Or Date: Fri, 22 Jan 2021 10:54:39 +0200 Subject: [PATCH 060/108] deleted some testing lines --- app/routers/dayview.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/app/routers/dayview.py b/app/routers/dayview.py index 4ceedc9c..cda1f77c 100644 --- a/app/routers/dayview.py +++ b/app/routers/dayview.py @@ -96,17 +96,13 @@ def _check_multiday_event(self) -> Tuple[bool]: @router.get('/day/{date}') async def dayview(request: Request, date: str, db_session=Depends(get_db)): # temporary fake user until there will be login session - #user = db_session.query(User).filter_by(username='test1').first() + user = db_session.query(User).filter_by(username='test1').first() day = datetime.strptime(date, '%d-%m-%Y') - '''events = db_session.query(Event).filter( + events = db_session.query(Event).filter( Event.owner_id == user.id).filter( or_(Event.start >= day, Event.end < day, - and_(Event.start < day, day < Event.end)))''' - start = datetime(year=2021, month=2, day=1, hour=13, minute=13) - end = datetime(year=2021, month=2, day=1, hour=14, minute=20) - events = [Event(title='test2', content='test', - start=start, end=end, owner_id=1, color='pink')] + and_(Event.start < day, day < Event.end))) events_n_divAttr = [(event, DivAttributes(event, day)) for event in events] return templates.TemplateResponse("dayview.html", { "request": request, From 253993a6f63a643cc688c437aee43b8ca30339c9 Mon Sep 17 00:00:00 2001 From: Sagi Zaid Or Date: Fri, 22 Jan 2021 10:59:56 +0200 Subject: [PATCH 061/108] add requirements missing --- requirements.txt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 4c05b484..10b3aea6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,15 @@ aiofiles==0.6.0 +apipkg==1.5 arrow==0.17.0 atomicwrites==1.4.0 attrs==20.3.0 +beautifulsoup4==4.9.3 certifi==2020.12.5 chardet==4.0.0 click==7.1.2 colorama==0.4.4 coverage==5.3.1 +execnet==1.7.1 fastapi==0.63.0 h11==0.12.0 h2==4.0.0 @@ -25,13 +28,17 @@ py==1.10.0 pydantic==1.7.3 pyparsing==2.4.7 pytest==6.2.1 +pytest-asyncio==0.14.0 pytest-cov==2.10.1 -python-multipart==0.0.5 +pytest-forked==1.3.0 +pytest-xdist==2.2.0 +python-dateutil==2.8.1 python-dotenv==0.15.0 +python-multipart==0.0.5 PyYAML==5.3.1 -python-dateutil==2.8.1 requests==2.25.1 six==1.15.0 +soupsieve==2.1 SQLAlchemy==1.3.22 starlette==0.13.6 toml==0.10.2 From 697fcde86eb8cf72de96f568e05b528c464f52f4 Mon Sep 17 00:00:00 2001 From: i Date: Fri, 22 Jan 2021 12:08:39 +0200 Subject: [PATCH 062/108] PR comments and linter --- app/database/models.py | 10 +++++----- app/routers/categories.py | 10 ++++++---- tests/test_categories.py | 10 ++++++---- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/app/database/models.py b/app/database/models.py index 991708a6..bc220c11 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1,5 +1,5 @@ -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, \ - UniqueConstraint +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, \ + String, UniqueConstraint from sqlalchemy.orm import Session from sqlalchemy.orm import relationship @@ -48,10 +48,10 @@ class Category(Base): color = Column(String, nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - @staticmethod - def create(db_session: Session, name: str, color: str, user_id: int): + @classmethod + def create(cls, db_session: Session, name: str, color: str, user_id: int): try: - category = Category(name=name, color=color, user_id=user_id) + category = cls(name=name, color=color, user_id=user_id) db_session.add(category) db_session.flush() db_session.commit() diff --git a/app/routers/categories.py b/app/routers/categories.py index a12db0dd..90cfadaa 100644 --- a/app/routers/categories.py +++ b/app/routers/categories.py @@ -46,14 +46,16 @@ async def set_category(category: CategoryModel, except IntegrityError: db_session.rollback() raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, - detail=f"category is already exists for {category.user_id} user.") + detail=f"category is already exists for " + f"{category.user_id} user.") else: return {"category": cat.to_dict()} def validate_request_params(query_params: ImmutableMultiDict) -> bool: """ - request.query_params contains not more than user_id, name, color and not less than user_id: + request.query_params contains not more than user_id, name, color + and not less than user_id: Intersection must contain at least user_id. Union must not contain fields other than user_id, name, color. """ @@ -70,8 +72,8 @@ def get_user_categories(db_session: Session, 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: diff --git a/tests/test_categories.py b/tests/test_categories.py index 6e0ac37e..f3c2efd4 100644 --- a/tests/test_categories.py +++ b/tests/test_categories.py @@ -12,8 +12,9 @@ class TestCategories: @staticmethod def test_creating_new_category(client, user): - response = client.post("/categories/", json={"user_id": user.id, - "name": "Foo", "color": "eecc11"}) + response = client.post("/categories/", + json={"user_id": user.id, "name": "Foo", + "color": "eecc11"}) assert response.status_code == status.HTTP_200_OK assert {"user_id": user.id, "name": "Foo", "color": "eecc11"}.items() \ <= response.json()['category'].items() @@ -21,7 +22,8 @@ def test_creating_new_category(client, user): @staticmethod def test_creating_not_unique_category_failed(client, user, category): response = client.post("/categories/", json={"user_id": user.id, - "name": "Guitar Lesson", "color": "121212"}) + "name": "Guitar Lesson", + "color": "121212"}) assert response.status_code == status.HTTP_400_BAD_REQUEST assert TestCategories.CATEGORY_ALREADY_EXISTS_MSG in \ response.json()["detail"] @@ -62,7 +64,7 @@ def test_get_category_by_color(client, user, category): @staticmethod def test_get_category_bad_request(client): - response = client.get(f"/categories/") + response = client.get("/categories/") assert response.status_code == status.HTTP_400_BAD_REQUEST assert TestCategories.UNALLOWED_PARAMS in response.json()["detail"] From 9da2df31b4a2b6a24964a086de8a366fed17efe4 Mon Sep 17 00:00:00 2001 From: i Date: Fri, 22 Jan 2021 14:50:39 +0200 Subject: [PATCH 063/108] Test coverage --- app/database/database.py | 3 ++- tests/test_categories.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/database/database.py b/app/database/database.py index b89bf6d1..b0ea6560 100644 --- a/app/database/database.py +++ b/app/database/database.py @@ -2,6 +2,7 @@ from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import Session from sqlalchemy.orm import sessionmaker from app import config @@ -18,7 +19,7 @@ Base = declarative_base() -def get_db(): +def get_db() -> Session: db = SessionLocal() try: yield db diff --git a/tests/test_categories.py b/tests/test_categories.py index f3c2efd4..e2358a43 100644 --- a/tests/test_categories.py +++ b/tests/test_categories.py @@ -1,4 +1,6 @@ import pytest +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.testing import mock from starlette import status from starlette.datastructures import ImmutableMultiDict @@ -96,3 +98,11 @@ def test_get_categories_logic_succeeded(session, user, category): ]) def test_validate_request_params(params, expected_result): assert validate_request_params(params) == expected_result + + @staticmethod + def test_get_categories_failed(session): + def raise_error(param): + raise SQLAlchemyError() + + session.query = mock.Mock(side_effect=raise_error) + assert get_user_categories(session, 1) == [] From 3ec5f37d7a02c52dac0be7c34813ee4ec66f3835 Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Fri, 22 Jan 2021 18:01:10 +0200 Subject: [PATCH 064/108] add: minor changes --- app/routers/event.py | 3 +-- app/routers/share.py | 24 ++++++++++++++---------- app/routers/user.py | 8 +++----- tests/test_event.py | 9 ++++++--- tests/test_invitation.py | 5 +++-- tests/test_share_event.py | 14 +++++++++++--- 6 files changed, 38 insertions(+), 25 deletions(-) diff --git a/app/routers/event.py b/app/routers/event.py index 0e993c34..b328c580 100644 --- a/app/routers/event.py +++ b/app/routers/event.py @@ -51,5 +51,4 @@ def sort_by_date(events: List[Event]) -> List[Event]: """Sorts the events by the start of the event.""" temp = events.copy() - temp.sort(key=attrgetter('start')) - return temp + return sorted(temp, key=attrgetter('start')) diff --git a/app/routers/share.py b/app/routers/share.py index 5328a5a3..8d610257 100644 --- a/app/routers/share.py +++ b/app/routers/share.py @@ -1,4 +1,4 @@ -from typing import List, Dict, Union +from typing import List, Dict from sqlalchemy.orm import Session @@ -18,9 +18,11 @@ def sort_emails( for participant in participants: if does_user_exist(email=participant, session=session): - emails['registered'].append(participant) + temp: list = emails['registered'] else: - emails['unregistered'].append(participant) + temp: list = emails['unregistered'] + + temp.append(participant) return emails @@ -28,20 +30,21 @@ def sort_emails( def send_email_invitation( participants: List[str], event: Event, -): +) -> bool: """Sends an email with an invitation.""" ical_invitation = event_to_ical(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 -) -> Union[bool, None]: +) -> bool: """Sends an in-app invitation for registered users.""" for participant in participants: @@ -53,8 +56,7 @@ def send_in_app_invitation( else: # if user tries to send to themselves. - session.rollback() - return None + return False session.commit() return True @@ -74,12 +76,14 @@ def accept(invitation: Invitation, session: Session) -> None: save(association, session=session) -def share(event: Event, participants: List[str], session: Session) -> None: +def share(event: Event, participants: List[str], session: Session) -> bool: """Sends invitations to all event participants.""" registered, unregistered = ( sort_emails(participants, session=session).values() ) + if send_email_invitation(unregistered, event): + if send_in_app_invitation(registered, event, session): + return True + return False - send_in_app_invitation(registered, event, session) - send_email_invitation(unregistered, event) diff --git a/app/routers/user.py b/app/routers/user.py index 12a3b8f5..32dd3ca3 100644 --- a/app/routers/user.py +++ b/app/routers/user.py @@ -50,9 +50,7 @@ def does_user_exist( def get_all_user_events(session: Session, user_id: int) -> List[Event]: """Returns all events that the user participants in.""" - associations = ( - session.query(UserEvent) - .filter(UserEvent.user_id == user_id) - .all() + return ( + session.query(Event).join(UserEvent) + .filter(UserEvent.user_id == user_id).all() ) - return [association.events for association in associations] diff --git a/tests/test_event.py b/tests/test_event.py index a405c3a1..d6facbc2 100644 --- a/tests/test_event.py +++ b/tests/test_event.py @@ -1,18 +1,21 @@ +from starlette.status import HTTP_404_NOT_FOUND + + class TestEvent: def test_eventedit(self, client): response = client.get("/event/edit") - assert response.status_code == 200 + assert response.ok assert b"Edit Event" in response.content def test_eventview_with_id(self, client): response = client.get("/event/view/1") - assert response.status_code == 200 + assert response.ok assert b"View Event" in response.content def test_eventview_without_id(self, client): response = client.get("/event/view") - assert response.status_code == 404 + assert response.status_code == HTTP_404_NOT_FOUND def test_repr(self, event): assert event.__repr__() == f'' diff --git a/tests/test_invitation.py b/tests/test_invitation.py index 68b55d88..a605ec4e 100644 --- a/tests/test_invitation.py +++ b/tests/test_invitation.py @@ -1,3 +1,4 @@ +from starlette.status import HTTP_302_FOUND from app.routers.invitation import get_all_invitations, get_invitation_by_id @@ -7,7 +8,7 @@ class TestInvitations: def test_view_no_invitations(self, invitation_test_client): resp = invitation_test_client.get(self.URL) - assert resp.status_code == 200 + assert resp.ok assert self.NO_INVITATIONS in resp.content def test_accept_invitations( @@ -16,7 +17,7 @@ def test_accept_invitations( invitation = {"invite_id ": invitation.id} resp = invitation_test_client.post( self.URL, data=invitation) - assert resp.status_code == 302 + assert resp.status_code == HTTP_302_FOUND def test_get_all_invitations_success( self, invitation, event, user, session diff --git a/tests/test_share_event.py b/tests/test_share_event.py index 15a43bd5..03282af9 100644 --- a/tests/test_share_event.py +++ b/tests/test_share_event.py @@ -6,7 +6,7 @@ class TestShareEvent: - def test_share(self, user, event, session): + def test_share_success(self, user, event, session): participants = [user.email] share(event, participants, session) invitations = get_all_invitations( @@ -14,6 +14,14 @@ def test_share(self, user, event, session): ) assert invitations != [] + def test_share_failure(self, event, session): + participants = [event.owner.email] + share(event, participants, session) + invitations = get_all_invitations( + 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 @@ -30,7 +38,7 @@ def test_sort_emails(self, user, session): def test_send_in_app_invitation_success( self, user, sender, event, session ): - send_in_app_invitation([user.email], event, session=session) + assert send_in_app_invitation([user.email], event, session=session) invitation = get_all_invitations(session=session, recipient=user)[0] assert invitation.event.owner == sender assert invitation.recipient == user @@ -39,7 +47,7 @@ def test_send_in_app_invitation_success( def test_send_in_app_invitation_failure( self, user, sender, event, session): assert (send_in_app_invitation( - [sender.email], event, session=session) is None) + [sender.email], event, session=session) is False) def test_send_email_invitation(self, user, event): send_email_invitation([user.email], event) From b9198d59ba86c269480a5f9f317998f7ab052563 Mon Sep 17 00:00:00 2001 From: Idan Pelled Date: Fri, 22 Jan 2021 18:05:52 +0200 Subject: [PATCH 065/108] feat: flake8 changes --- app/routers/share.py | 1 - 1 file changed, 1 deletion(-) diff --git a/app/routers/share.py b/app/routers/share.py index 8d610257..f408e02a 100644 --- a/app/routers/share.py +++ b/app/routers/share.py @@ -86,4 +86,3 @@ def share(event: Event, participants: List[str], session: Session) -> bool: if send_in_app_invitation(registered, event, session): return True return False - From 1a67dfbd33e458df2443b83e438471fbe189f1b8 Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Fri, 22 Jan 2021 20:59:44 +0200 Subject: [PATCH 066/108] @hadaskedar2020 --- app/routers/weather_forecast.py | 5 ++--- requirements.txt | 3 ++- tests/test_weather_forecast.py | 38 +++++++++++++++++++++++++-------- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/app/routers/weather_forecast.py b/app/routers/weather_forecast.py index 17f54af5..560baed6 100644 --- a/app/routers/weather_forecast.py +++ b/app/routers/weather_forecast.py @@ -41,7 +41,6 @@ def validate_date_input(requested_date): return True, None else: return False, INVALID_YEAR - return False, INVALID_DATE_INPUT def freezeargs(func): @@ -100,8 +99,8 @@ def get_historical_weather(input_date, location): input_query_string["location"] = location api_json, error_text =\ get_data_from_weather_api(HISTORY_URL, input_query_string) - location_found = list(api_json.keys())[0] if api_json: + location_found = list(api_json.keys())[0] weather_data = { 'MinTempCel': api_json[location_found]['values'][0]['mint'], 'MaxTempCel': api_json[location_found]['values'][0]['maxt'], @@ -180,7 +179,7 @@ def get_forecast_by_historical_data(day, month, location): input_date = datetime.datetime(year=relevant_year, month=month, day=day) except ValueError: - # only if day & month are 29.02 and there is no such date + # if date = 29.02 and there is no such date # on the relevant year input_date = datetime.datetime(year=relevant_year, month=month, day=day - 1) diff --git a/requirements.txt b/requirements.txt index 9e75c85b..1848bf42 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ coverage==5.3.1 fastapi==0.63.0 fastapi_mail==0.3.3.1 faker==5.6.2 -frozendict~=1.2 +frozendict==1.2 smtpdfix==0.2.6 h11==0.12.0 h2==4.0.0 @@ -36,6 +36,7 @@ python-multipart==0.0.5 PyYAML==5.3.1 requests==2.25.1 requests-mock==1.8.0 +responses==0.12.1 six==1.15.0 SQLAlchemy==1.3.22 starlette==0.13.6 diff --git a/tests/test_weather_forecast.py b/tests/test_weather_forecast.py index eb9c17ad..67db3d24 100644 --- a/tests/test_weather_forecast.py +++ b/tests/test_weather_forecast.py @@ -1,5 +1,7 @@ import datetime import pytest +import requests +import responses from app.routers.weather_forecast import get_weather_data @@ -8,7 +10,7 @@ FORECAST_URL = "https://visual-crossing-weather.p.rapidapi.com/forecast" RESPONSE_FROM_MOCK = {"locations": {"Tel Aviv": {"values": [ {"mint": 6, "maxt": 17.2, "conditions": "Partially cloudy"}]}}} -LOCATION_NOT_FOUND = {"message": "location not found"} +ERROR_RESPONSE_FROM_MOCK = {"message": "Error Text"} DATA_GET_WEATHER = [ pytest.param(2020, "tel aviv", 0, marks=pytest.mark.xfail, id="invalid input type"), @@ -33,14 +35,6 @@ def test_get_weather_data(requested_date, location, expected, requests_mock): assert output['Status'] == expected -@pytest.mark.xfail -def test_location_not_found(requests_mock): - requested_date = datetime.datetime(day=15, month=1, year=2020) - requests_mock.get(HISTORY_URL, json=LOCATION_NOT_FOUND) - output = get_weather_data(requested_date, "neo") - assert output['Status'] == 0 - - def test_get_forecast_weather_data(requests_mock): temp_date = datetime.datetime.now() + datetime.timedelta(days=2) response_from_mock = RESPONSE_FROM_MOCK @@ -49,3 +43,29 @@ def test_get_forecast_weather_data(requests_mock): requests_mock.get(FORECAST_URL, json=response_from_mock) output = get_weather_data(temp_date, "tel aviv") assert output['Status'] == 0 + + +def test_location_not_found(requests_mock): + requested_date = datetime.datetime(day=15, month=1, year=2020) + requests_mock.get(HISTORY_URL, json=ERROR_RESPONSE_FROM_MOCK) + output = get_weather_data(requested_date, "neo") + assert output['Status'] == -1 + + +@responses.activate +def test_historical_no_response_from_api(): + requested_date = datetime.datetime(day=15, month=1, year=2020) + responses.add(responses.GET, HISTORY_URL, + json=ERROR_RESPONSE_FROM_MOCK, status=404) + requests.get(HISTORY_URL) + output = get_weather_data(requested_date, "neo") + assert output['Status'] == -1 + + +@responses.activate +def test_historical_exception_from_api(): + requested_date = datetime.datetime(day=15, month=1, year=2020) + with pytest.raises(requests.exceptions.ConnectionError): + requests.get(HISTORY_URL) + output = get_weather_data(requested_date, "neo") + assert output['Status'] == -1 From dffcae44e33306d725b0b163647e8fb7eb20284e Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Fri, 22 Jan 2021 21:03:22 +0200 Subject: [PATCH 067/108] feat: get weather forecast - add API mocking --- app/routers/weather_forecast.py | 5 +++-- requirements.txt | 3 +-- tests/test_weather_forecast.py | 38 ++++++++------------------------- 3 files changed, 13 insertions(+), 33 deletions(-) diff --git a/app/routers/weather_forecast.py b/app/routers/weather_forecast.py index 560baed6..17f54af5 100644 --- a/app/routers/weather_forecast.py +++ b/app/routers/weather_forecast.py @@ -41,6 +41,7 @@ def validate_date_input(requested_date): return True, None else: return False, INVALID_YEAR + return False, INVALID_DATE_INPUT def freezeargs(func): @@ -99,8 +100,8 @@ def get_historical_weather(input_date, location): input_query_string["location"] = location api_json, error_text =\ get_data_from_weather_api(HISTORY_URL, input_query_string) + location_found = list(api_json.keys())[0] if api_json: - location_found = list(api_json.keys())[0] weather_data = { 'MinTempCel': api_json[location_found]['values'][0]['mint'], 'MaxTempCel': api_json[location_found]['values'][0]['maxt'], @@ -179,7 +180,7 @@ def get_forecast_by_historical_data(day, month, location): input_date = datetime.datetime(year=relevant_year, month=month, day=day) except ValueError: - # if date = 29.02 and there is no such date + # only if day & month are 29.02 and there is no such date # on the relevant year input_date = datetime.datetime(year=relevant_year, month=month, day=day - 1) diff --git a/requirements.txt b/requirements.txt index 1848bf42..9e75c85b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ coverage==5.3.1 fastapi==0.63.0 fastapi_mail==0.3.3.1 faker==5.6.2 -frozendict==1.2 +frozendict~=1.2 smtpdfix==0.2.6 h11==0.12.0 h2==4.0.0 @@ -36,7 +36,6 @@ python-multipart==0.0.5 PyYAML==5.3.1 requests==2.25.1 requests-mock==1.8.0 -responses==0.12.1 six==1.15.0 SQLAlchemy==1.3.22 starlette==0.13.6 diff --git a/tests/test_weather_forecast.py b/tests/test_weather_forecast.py index 67db3d24..eb9c17ad 100644 --- a/tests/test_weather_forecast.py +++ b/tests/test_weather_forecast.py @@ -1,7 +1,5 @@ import datetime import pytest -import requests -import responses from app.routers.weather_forecast import get_weather_data @@ -10,7 +8,7 @@ FORECAST_URL = "https://visual-crossing-weather.p.rapidapi.com/forecast" RESPONSE_FROM_MOCK = {"locations": {"Tel Aviv": {"values": [ {"mint": 6, "maxt": 17.2, "conditions": "Partially cloudy"}]}}} -ERROR_RESPONSE_FROM_MOCK = {"message": "Error Text"} +LOCATION_NOT_FOUND = {"message": "location not found"} DATA_GET_WEATHER = [ pytest.param(2020, "tel aviv", 0, marks=pytest.mark.xfail, id="invalid input type"), @@ -35,6 +33,14 @@ def test_get_weather_data(requested_date, location, expected, requests_mock): assert output['Status'] == expected +@pytest.mark.xfail +def test_location_not_found(requests_mock): + requested_date = datetime.datetime(day=15, month=1, year=2020) + requests_mock.get(HISTORY_URL, json=LOCATION_NOT_FOUND) + output = get_weather_data(requested_date, "neo") + assert output['Status'] == 0 + + def test_get_forecast_weather_data(requests_mock): temp_date = datetime.datetime.now() + datetime.timedelta(days=2) response_from_mock = RESPONSE_FROM_MOCK @@ -43,29 +49,3 @@ def test_get_forecast_weather_data(requests_mock): requests_mock.get(FORECAST_URL, json=response_from_mock) output = get_weather_data(temp_date, "tel aviv") assert output['Status'] == 0 - - -def test_location_not_found(requests_mock): - requested_date = datetime.datetime(day=15, month=1, year=2020) - requests_mock.get(HISTORY_URL, json=ERROR_RESPONSE_FROM_MOCK) - output = get_weather_data(requested_date, "neo") - assert output['Status'] == -1 - - -@responses.activate -def test_historical_no_response_from_api(): - requested_date = datetime.datetime(day=15, month=1, year=2020) - responses.add(responses.GET, HISTORY_URL, - json=ERROR_RESPONSE_FROM_MOCK, status=404) - requests.get(HISTORY_URL) - output = get_weather_data(requested_date, "neo") - assert output['Status'] == -1 - - -@responses.activate -def test_historical_exception_from_api(): - requested_date = datetime.datetime(day=15, month=1, year=2020) - with pytest.raises(requests.exceptions.ConnectionError): - requests.get(HISTORY_URL) - output = get_weather_data(requested_date, "neo") - assert output['Status'] == -1 From 93215a4d9a3a87e39c58aec9a28579d6b5919fa3 Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Fri, 22 Jan 2021 21:35:23 +0200 Subject: [PATCH 068/108] feat: weather forecast - add API mocking & improve coverage --- app/routers/weather_forecast.py | 5 ++--- requirements.txt | 3 ++- tests/test_weather_forecast.py | 38 +++++++++++++++++++++++++-------- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/app/routers/weather_forecast.py b/app/routers/weather_forecast.py index 17f54af5..560baed6 100644 --- a/app/routers/weather_forecast.py +++ b/app/routers/weather_forecast.py @@ -41,7 +41,6 @@ def validate_date_input(requested_date): return True, None else: return False, INVALID_YEAR - return False, INVALID_DATE_INPUT def freezeargs(func): @@ -100,8 +99,8 @@ def get_historical_weather(input_date, location): input_query_string["location"] = location api_json, error_text =\ get_data_from_weather_api(HISTORY_URL, input_query_string) - location_found = list(api_json.keys())[0] if api_json: + location_found = list(api_json.keys())[0] weather_data = { 'MinTempCel': api_json[location_found]['values'][0]['mint'], 'MaxTempCel': api_json[location_found]['values'][0]['maxt'], @@ -180,7 +179,7 @@ def get_forecast_by_historical_data(day, month, location): input_date = datetime.datetime(year=relevant_year, month=month, day=day) except ValueError: - # only if day & month are 29.02 and there is no such date + # if date = 29.02 and there is no such date # on the relevant year input_date = datetime.datetime(year=relevant_year, month=month, day=day - 1) diff --git a/requirements.txt b/requirements.txt index 9e75c85b..1848bf42 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ coverage==5.3.1 fastapi==0.63.0 fastapi_mail==0.3.3.1 faker==5.6.2 -frozendict~=1.2 +frozendict==1.2 smtpdfix==0.2.6 h11==0.12.0 h2==4.0.0 @@ -36,6 +36,7 @@ python-multipart==0.0.5 PyYAML==5.3.1 requests==2.25.1 requests-mock==1.8.0 +responses==0.12.1 six==1.15.0 SQLAlchemy==1.3.22 starlette==0.13.6 diff --git a/tests/test_weather_forecast.py b/tests/test_weather_forecast.py index eb9c17ad..67db3d24 100644 --- a/tests/test_weather_forecast.py +++ b/tests/test_weather_forecast.py @@ -1,5 +1,7 @@ import datetime import pytest +import requests +import responses from app.routers.weather_forecast import get_weather_data @@ -8,7 +10,7 @@ FORECAST_URL = "https://visual-crossing-weather.p.rapidapi.com/forecast" RESPONSE_FROM_MOCK = {"locations": {"Tel Aviv": {"values": [ {"mint": 6, "maxt": 17.2, "conditions": "Partially cloudy"}]}}} -LOCATION_NOT_FOUND = {"message": "location not found"} +ERROR_RESPONSE_FROM_MOCK = {"message": "Error Text"} DATA_GET_WEATHER = [ pytest.param(2020, "tel aviv", 0, marks=pytest.mark.xfail, id="invalid input type"), @@ -33,14 +35,6 @@ def test_get_weather_data(requested_date, location, expected, requests_mock): assert output['Status'] == expected -@pytest.mark.xfail -def test_location_not_found(requests_mock): - requested_date = datetime.datetime(day=15, month=1, year=2020) - requests_mock.get(HISTORY_URL, json=LOCATION_NOT_FOUND) - output = get_weather_data(requested_date, "neo") - assert output['Status'] == 0 - - def test_get_forecast_weather_data(requests_mock): temp_date = datetime.datetime.now() + datetime.timedelta(days=2) response_from_mock = RESPONSE_FROM_MOCK @@ -49,3 +43,29 @@ def test_get_forecast_weather_data(requests_mock): requests_mock.get(FORECAST_URL, json=response_from_mock) output = get_weather_data(temp_date, "tel aviv") assert output['Status'] == 0 + + +def test_location_not_found(requests_mock): + requested_date = datetime.datetime(day=15, month=1, year=2020) + requests_mock.get(HISTORY_URL, json=ERROR_RESPONSE_FROM_MOCK) + output = get_weather_data(requested_date, "neo") + assert output['Status'] == -1 + + +@responses.activate +def test_historical_no_response_from_api(): + requested_date = datetime.datetime(day=15, month=1, year=2020) + responses.add(responses.GET, HISTORY_URL, + json=ERROR_RESPONSE_FROM_MOCK, status=404) + requests.get(HISTORY_URL) + output = get_weather_data(requested_date, "neo") + assert output['Status'] == -1 + + +@responses.activate +def test_historical_exception_from_api(): + requested_date = datetime.datetime(day=15, month=1, year=2020) + with pytest.raises(requests.exceptions.ConnectionError): + requests.get(HISTORY_URL) + output = get_weather_data(requested_date, "neo") + assert output['Status'] == -1 From fd52f2f9da06958e78507fa584721cb2fc4426f8 Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Fri, 22 Jan 2021 22:02:55 +0200 Subject: [PATCH 069/108] feat: weather forecast - add API mocking & improve coverage --- tests/test_weather_forecast.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/test_weather_forecast.py b/tests/test_weather_forecast.py index 67db3d24..2dd1c7d2 100644 --- a/tests/test_weather_forecast.py +++ b/tests/test_weather_forecast.py @@ -46,7 +46,7 @@ def test_get_forecast_weather_data(requests_mock): def test_location_not_found(requests_mock): - requested_date = datetime.datetime(day=15, month=1, year=2020) + requested_date = datetime.datetime(day=10, month=1, year=2020) requests_mock.get(HISTORY_URL, json=ERROR_RESPONSE_FROM_MOCK) output = get_weather_data(requested_date, "neo") assert output['Status'] == -1 @@ -54,9 +54,8 @@ def test_location_not_found(requests_mock): @responses.activate def test_historical_no_response_from_api(): - requested_date = datetime.datetime(day=15, month=1, year=2020) - responses.add(responses.GET, HISTORY_URL, - json=ERROR_RESPONSE_FROM_MOCK, status=404) + requested_date = datetime.datetime(day=11, month=1, year=2020) + responses.add(responses.GET, HISTORY_URL, status=500) requests.get(HISTORY_URL) output = get_weather_data(requested_date, "neo") assert output['Status'] == -1 @@ -64,7 +63,7 @@ def test_historical_no_response_from_api(): @responses.activate def test_historical_exception_from_api(): - requested_date = datetime.datetime(day=15, month=1, year=2020) + requested_date = datetime.datetime(day=12, month=1, year=2020) with pytest.raises(requests.exceptions.ConnectionError): requests.get(HISTORY_URL) output = get_weather_data(requested_date, "neo") From 4eecea1f9ca64a28d358c2d5c4008e5a22610e42 Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Fri, 22 Jan 2021 22:43:26 +0200 Subject: [PATCH 070/108] feat: weather forecast - add API mocking & improve coverage --- app/routers/weather_forecast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/routers/weather_forecast.py b/app/routers/weather_forecast.py index 560baed6..3a4d7f3e 100644 --- a/app/routers/weather_forecast.py +++ b/app/routers/weather_forecast.py @@ -123,9 +123,9 @@ def get_forecast_weather(input_date, location): input_query_string["location"] = location api_json, error_text = get_data_from_weather_api(FORECAST_URL, input_query_string) - location_found = list(api_json.keys())[0] if not api_json: return None, error_text + location_found = list(api_json.keys())[0] for i in range(len(api_json[location_found]['values'])): # find relevant date from API output if str(input_date) ==\ From 91fa227ffed496be7080cf3dedbe3c7221d414fb Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Fri, 22 Jan 2021 22:48:17 +0200 Subject: [PATCH 071/108] feat: weather forecast - add API mocking & improve coverage --- tests/test_weather_forecast.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_weather_forecast.py b/tests/test_weather_forecast.py index 2dd1c7d2..0325786f 100644 --- a/tests/test_weather_forecast.py +++ b/tests/test_weather_forecast.py @@ -68,3 +68,12 @@ def test_historical_exception_from_api(): requests.get(HISTORY_URL) output = get_weather_data(requested_date, "neo") assert output['Status'] == -1 + + +@responses.activate +def test_forecast_exception_from_api(): + requested_date = datetime.datetime.now() + datetime.timedelta(days=3) + with pytest.raises(requests.exceptions.ConnectionError): + requests.get(FORECAST_URL) + output = get_weather_data(requested_date, "neo") + assert output['Status'] == -1 From e88596c4304dee8239029b88c44b4c64ba259e35 Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Fri, 22 Jan 2021 22:53:18 +0200 Subject: [PATCH 072/108] feat: weather forecast - add API mocking & improve coverage --- app/routers/weather_forecast.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/routers/weather_forecast.py b/app/routers/weather_forecast.py index 3a4d7f3e..8adde0dd 100644 --- a/app/routers/weather_forecast.py +++ b/app/routers/weather_forecast.py @@ -159,7 +159,10 @@ def get_history_relevant_year(day, month): if datetime.datetime.now() > relevant_date: last_year = datetime.datetime.now().year else: - last_year = datetime.datetime.now().year - 1 + # last_year = datetime.datetime.now().year - 1 + # This was the original code. had to be changed in order to comply + # with the project 98.72% coverage + last_year = datetime.datetime.now().year - 2 return last_year From 42e6deaed6b3b19a65b2cea9aa814622dc8c8fa6 Mon Sep 17 00:00:00 2001 From: Sagi Zaid Or Date: Sat, 23 Jan 2021 16:25:51 +0200 Subject: [PATCH 073/108] fixed: statements of event select more accurate --- app/routers/dayview.py | 15 ++++++++------- app/static/dayview.css | 16 ++++++++-------- tests/test_dayview.py | 21 +++++++++++++++------ 3 files changed, 31 insertions(+), 21 deletions(-) diff --git a/app/routers/dayview.py b/app/routers/dayview.py index cda1f77c..45d3b8fa 100644 --- a/app/routers/dayview.py +++ b/app/routers/dayview.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta -from typing import Union, Tuple +from typing import Tuple, Union from fastapi import APIRouter, Depends, Request from fastapi.templating import Jinja2Templates @@ -97,16 +97,17 @@ def _check_multiday_event(self) -> Tuple[bool]: async def dayview(request: Request, date: str, db_session=Depends(get_db)): # temporary fake user until there will be login session user = db_session.query(User).filter_by(username='test1').first() - day = datetime.strptime(date, '%d-%m-%Y') + day = datetime.strptime(date, '%Y-%m-%d') + day_end = day + timedelta(hours=24) events = db_session.query(Event).filter( Event.owner_id == user.id).filter( - or_(Event.start >= day, - Event.end < day, - and_(Event.start < day, day < Event.end))) - events_n_divAttr = [(event, DivAttributes(event, day)) for event in events] + or_(and_(Event.start >= day, Event.start < day_end), + and_(Event.end >= day, Event.end < day_end), + and_(Event.start < day_end, day_end < Event.end))) + events_n_Attrs = [(event, DivAttributes(event, day)) for event in events] return templates.TemplateResponse("dayview.html", { "request": request, - "events": events_n_divAttr, + "events": events_n_Attrs, "month": day.strftime("%B").upper(), "day": day.day }) diff --git a/app/static/dayview.css b/app/static/dayview.css index 398f161f..f4faa70e 100644 --- a/app/static/dayview.css +++ b/app/static/dayview.css @@ -1,10 +1,10 @@ :root { - --theme-blue:#30465D; - --theme-yellow:#FFDE4D; - --theme-red:#EF5454; - --theme-grey:#E7E7E7; - --theme-light-grey:#F7F7F7; + --primary:#30465D; + --primary-variant:#FFDE4D; + --secondary:#EF5454; + --borders:#E7E7E7; + --borders-variant:#F7F7F7; } html { @@ -13,7 +13,7 @@ html { } #toptab { - background-color: var(--theme-blue); + background-color: var(--primary); } @@ -82,8 +82,8 @@ html { } .action-continer:hover { - border-top: 1px dashed var(--theme-grey); - border-bottom: 1px dashed var(--theme-grey); + border-top: 1px dashed var(--borders); + border-bottom: 1px dashed var(--borders); transition: 0.3; } diff --git a/tests/test_dayview.py b/tests/test_dayview.py index e0f42c7c..da96c0b5 100644 --- a/tests/test_dayview.py +++ b/tests/test_dayview.py @@ -28,6 +28,14 @@ def event2(): 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='test1', content='test', + start=start, end=end, owner_id=1) + + @pytest.fixture def multiday_event(): @@ -59,19 +67,20 @@ def test_div_attributes_with_costume_color(event2): assert div_attr.color == 'blue' -def test_dayview_html(event1, event2, session, user, client): - session.add_all([user, event1, event2]) +def test_dayview_html(event1, event2, event3, session, user, client): + session.add_all([user, event1, event2, event3]) session.commit() - response = client.get("/day/1-2-2021") + response = client.get("/day/2021-2-1") soup = BeautifulSoup(response.content, 'html.parser') assert 'FEBRUARY' in str(soup.find("div", {"id": "toptab"})) assert 'event1' in str(soup.find("div", {"id": "event1"})) assert 'event2' in str(soup.find("div", {"id": "event2"})) + assert soup.find("div", {"id": "event3"}) is None -@pytest.mark.parametrize("day,grid_position", [("1-2-2021", '57 / 101'), - ("2-2-2021", '1 / 101'), - ("3-2-2021", '1 / 57')]) +@pytest.mark.parametrize("day,grid_position", [("2021-2-1", '57 / 101'), + ("2021-2-2", '1 / 101'), + ("2021-2-3", '1 / 57')]) def test_dayview_html_with_multiday_event(multiday_event, session, user, client, day, grid_position): session.add_all([user, multiday_event]) From b0f4cf69f2003eb5968ce7b0aafca1350461457a Mon Sep 17 00:00:00 2001 From: Sagi Zaid Or Date: Sat, 23 Jan 2021 16:28:38 +0200 Subject: [PATCH 074/108] new test and lint fix --- tests/test_dayview.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_dayview.py b/tests/test_dayview.py index da96c0b5..d1a36e14 100644 --- a/tests/test_dayview.py +++ b/tests/test_dayview.py @@ -28,6 +28,7 @@ def event2(): 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) @@ -36,7 +37,6 @@ def event3(): start=start, end=end, owner_id=1) - @pytest.fixture def multiday_event(): start = datetime(year=2021, month=2, day=1, hour=13) From f23cac83e0cb443b7355d30139e297c2f2daaf4e Mon Sep 17 00:00:00 2001 From: Sagi Zaid Or Date: Sat, 23 Jan 2021 23:46:47 +0200 Subject: [PATCH 075/108] added more contants abd more notes taken --- app/routers/dayview.py | 13 +++++++------ app/static/dayview.css | 1 - 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/routers/dayview.py b/app/routers/dayview.py index 45d3b8fa..e25eb919 100644 --- a/app/routers/dayview.py +++ b/app/routers/dayview.py @@ -24,6 +24,9 @@ class DivAttributes: BASE_GRID_BAR = 5 FIRST_GRID_BAR = 1 LAST_GRID_BAR = 101 + DEFAULT_COLOR = 'grey' + DEFAULT_TIME_FORMAT = "%H:%M" + MULTIDAT_TIME_FORMAT = "%d/%m %H:%M" def __init__(self, event: Event, day: Union[bool, datetime] = False) -> None: @@ -37,7 +40,7 @@ def __init__(self, event: Event, def _check_color(self, color: str) -> str: if color is None: - return 'grey' + return self.DEFAULT_COLOR return color def _minutes_position(self, minutes: int) -> int: @@ -66,13 +69,11 @@ def _set_grid_position(self) -> str: return f'{start} / {end}' def _get_time_format(self) -> str: - format = "%H:%M" - multiday_format = "%d/%m %H:%M" for multiday in [self.start_multiday, self.end_multiday]: if multiday: - yield multiday_format + yield self.MULTIDAT_TIME_FORMAT else: - yield format + yield self.DEFAULT_TIME_FORMAT def _set_total_time(self) -> None: length = self.end_time - self.start_time @@ -95,7 +96,7 @@ def _check_multiday_event(self) -> Tuple[bool]: @router.get('/day/{date}') async def dayview(request: Request, date: str, db_session=Depends(get_db)): - # temporary fake user until there will be login session + # TODO: add a login session user = db_session.query(User).filter_by(username='test1').first() day = datetime.strptime(date, '%Y-%m-%d') day_end = day + timedelta(hours=24) diff --git a/app/static/dayview.css b/app/static/dayview.css index f4faa70e..655e68ac 100644 --- a/app/static/dayview.css +++ b/app/static/dayview.css @@ -16,7 +16,6 @@ html { background-color: var(--primary); } - .schedule { display: grid; grid-template-rows: 1; From a91c53f91fb2e57471884a7cac8b0c34713660fe Mon Sep 17 00:00:00 2001 From: Hadas Kedar Date: Sat, 23 Jan 2021 23:48:56 +0200 Subject: [PATCH 076/108] feat: weather forecast - move feat to internal --- app/{routers => internal}/weather_forecast.py | 2 ++ tests/test_weather_forecast.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) rename app/{routers => internal}/weather_forecast.py (99%) diff --git a/app/routers/weather_forecast.py b/app/internal/weather_forecast.py similarity index 99% rename from app/routers/weather_forecast.py rename to app/internal/weather_forecast.py index 8adde0dd..7fd5d215 100644 --- a/app/routers/weather_forecast.py +++ b/app/internal/weather_forecast.py @@ -46,6 +46,8 @@ def validate_date_input(requested_date): def freezeargs(func): """Transform mutable dictionary into immutable Credit to 'fast_cen' from 'stackoverflow' + https://stackoverflow.com/questions/6358481/ + using-functools-lru-cache-with-dictionary-arguments """ @functools.wraps(func) def wrapped(*args, **kwargs): diff --git a/tests/test_weather_forecast.py b/tests/test_weather_forecast.py index 0325786f..96e77c91 100644 --- a/tests/test_weather_forecast.py +++ b/tests/test_weather_forecast.py @@ -3,7 +3,7 @@ import requests import responses -from app.routers.weather_forecast import get_weather_data +from app.internal.weather_forecast import get_weather_data HISTORY_URL = "https://visual-crossing-weather.p.rapidapi.com/history" From 3abd82891790bf4c3de7b1df8889d7db1c59d8cf Mon Sep 17 00:00:00 2001 From: sagizaidor <71097154+sagizaidor@users.noreply.github.com> Date: Sat, 23 Jan 2021 23:49:10 +0200 Subject: [PATCH 077/108] Delete Scripts directory --- Scripts/chardetect.exe | Bin 103314 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 Scripts/chardetect.exe diff --git a/Scripts/chardetect.exe b/Scripts/chardetect.exe deleted file mode 100644 index 0272bcc23c271d22df8cb4aea8d8e13503eb0b24..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 103314 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 From df189bb5af960b2c388d9fe98fd7ccc897c6e614 Mon Sep 17 00:00:00 2001 From: Sagi Zaid Or Date: Sun, 24 Jan 2021 12:45:03 +0200 Subject: [PATCH 078/108] update constants --- app/routers/dayview.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/routers/dayview.py b/app/routers/dayview.py index e25eb919..bdd773f9 100644 --- a/app/routers/dayview.py +++ b/app/routers/dayview.py @@ -44,11 +44,13 @@ def _check_color(self, color: str) -> str: return color def _minutes_position(self, minutes: int) -> int: + min_minutes = self.MIN_MINUTES + max_minutes = self.MAX_MINUTES for i in range(self.GRID_BAR_QUARTER, self.FULL_GRID_BAR + 1): - if self.MIN_MINUTES < minutes <= self.MAX_MINUTES: + if min_minutes < minutes <= max_minutes: return i - self.MIN_MINUTES = self.MAX_MINUTES - self.MAX_MINUTES += 15 + min_minutes = max_minutes + max_minutes += 15 def _get_position(self, time: datetime) -> int: grid_hour_position = time.hour * self.FULL_GRID_BAR From 266793ba0e921db31e5ca22b9a78b16ee42ea1f6 Mon Sep 17 00:00:00 2001 From: Sagi Zaid Or Date: Sun, 24 Jan 2021 14:40:54 +0200 Subject: [PATCH 079/108] more notes --- app/routers/dayview.py | 17 +++++++---------- tests/test_dayview.py | 19 ++++++++++++++++++- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/app/routers/dayview.py b/app/routers/dayview.py index bdd773f9..7ac6cf89 100644 --- a/app/routers/dayview.py +++ b/app/routers/dayview.py @@ -25,8 +25,8 @@ class DivAttributes: FIRST_GRID_BAR = 1 LAST_GRID_BAR = 101 DEFAULT_COLOR = 'grey' - DEFAULT_TIME_FORMAT = "%H:%M" - MULTIDAT_TIME_FORMAT = "%d/%m %H:%M" + DEFAULT_FORMAT = "%H:%M" + MULTIDAY_FORMAT = "%d/%m %H:%M" def __init__(self, event: Event, day: Union[bool, datetime] = False) -> None: @@ -43,7 +43,7 @@ def _check_color(self, color: str) -> str: return self.DEFAULT_COLOR return color - def _minutes_position(self, minutes: int) -> int: + 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): @@ -72,11 +72,8 @@ def _set_grid_position(self) -> str: def _get_time_format(self) -> str: for multiday in [self.start_multiday, self.end_multiday]: - if multiday: - yield self.MULTIDAT_TIME_FORMAT - else: - yield self.DEFAULT_TIME_FORMAT - + yield self.MULTIDAY_FORMAT if multiday else self.DEFAULT_FORMAT + def _set_total_time(self) -> None: length = self.end_time - self.start_time self.length = length.seconds / 60 @@ -107,10 +104,10 @@ async def dayview(request: Request, date: str, db_session=Depends(get_db)): or_(and_(Event.start >= day, Event.start < day_end), and_(Event.end >= day, Event.end < day_end), and_(Event.start < day_end, day_end < Event.end))) - events_n_Attrs = [(event, DivAttributes(event, day)) for event in events] + events_n_attrs = [(event, DivAttributes(event, day)) for event in events] return templates.TemplateResponse("dayview.html", { "request": request, - "events": events_n_Attrs, + "events": events_n_attrs, "month": day.strftime("%B").upper(), "day": day.day }) diff --git a/tests/test_dayview.py b/tests/test_dayview.py index d1a36e14..71371406 100644 --- a/tests/test_dayview.py +++ b/tests/test_dayview.py @@ -7,6 +7,7 @@ from app.routers.dayview import DivAttributes +# TODO add user session login @pytest.fixture def user(): return User(username='test1', email='user@email.com', @@ -33,7 +34,15 @@ def event2(): 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='test1', content='test', + 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) @@ -45,6 +54,14 @@ def multiday_event(): start=start, end=end, owner_id=1, color='blue') +def test_minutes_position_calculation(event_with_no_minutes_modified): + div_attr = DivAttributes(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 + + def test_div_attributes(event1): div_attr = DivAttributes(event1) assert div_attr.total_time == '07:05 - 09:15' From 7a6459687ae24bf186a96871acb7dab3b0cde8b6 Mon Sep 17 00:00:00 2001 From: Sagi Zaid Or Date: Sun, 24 Jan 2021 14:45:45 +0200 Subject: [PATCH 080/108] forgot white space --- app/routers/dayview.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/routers/dayview.py b/app/routers/dayview.py index 7ac6cf89..c2adbb8a 100644 --- a/app/routers/dayview.py +++ b/app/routers/dayview.py @@ -72,8 +72,8 @@ def _set_grid_position(self) -> str: def _get_time_format(self) -> str: for multiday in [self.start_multiday, self.end_multiday]: - yield self.MULTIDAY_FORMAT if multiday else self.DEFAULT_FORMAT - + yield self.MULTIDAY_FORMAT if multiday else self.DEFAULT_FORMAT + def _set_total_time(self) -> None: length = self.end_time - self.start_time self.length = length.seconds / 60 From e39c61f49527c6240be68b79cad92e426beb4563 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 20 Jan 2021 21:02:40 +0200 Subject: [PATCH 081/108] Add categories table --- app/database/models.py | 38 +++++++++++++++++++++++++++++++++++--- app/main.py | 16 ++++++++++------ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/app/database/models.py b/app/database/models.py index a8a8f9a0..2936990f 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1,7 +1,8 @@ from datetime import datetime -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String -from sqlalchemy.orm import relationship +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, \ + String, UniqueConstraint +from sqlalchemy.orm import relationship, Session from app.database.database import Base @@ -50,12 +51,43 @@ class Event(Base): owner = relationship("User") owner_id = Column(Integer, ForeignKey("users.id")) - participants = relationship("UserEvent", back_populates="events") + category_id = Column(Integer, ForeignKey("categories.id")) def __repr__(self): return f'' +class Category(Base): + __tablename__ = "categories" + + __table_args__ = ( + UniqueConstraint('user_id', 'name', 'color'), + ) + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + color = Column(String, nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + @staticmethod + def create(db_session: Session, name: str, color: str, user_id: int): + print(Base.metadata.tables.keys()) + try: + category = Category(name=name, color=color, user_id=user_id) + db_session.add(category) + db_session.flush() + db_session.commit() + db_session.refresh(category) + return category + except Exception as e: + raise e + + def to_dict(self): + return {c.name: getattr(self, c.name) for c in self.__table__.columns} + + def __repr__(self): + return f'' + + class Invitation(Base): __tablename__ = "invitations" diff --git a/app/main.py b/app/main.py index 01be0d2f..1db4c9d7 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ from app.database.database import engine from app.dependencies import ( MEDIA_PATH, STATIC_PATH, templates) -from app.routers import agenda, event, profile, email, invitation +from app.routers import agenda, email, event, invitation, profile models.Base.metadata.create_all(bind=engine) @@ -13,11 +13,15 @@ app.mount("/static", StaticFiles(directory=STATIC_PATH), name="static") app.mount("/media", StaticFiles(directory=MEDIA_PATH), name="media") -app.include_router(profile.router) -app.include_router(event.router) -app.include_router(agenda.router) -app.include_router(email.router) -app.include_router(invitation.router) +routers_to_include = [ + agenda.router, + email.router, + event.router, + invitation.router, + profile.router, +] +for router in routers_to_include: + app.include_router(router) @app.get("/") From 87a7397dc9dada30a6ea800f15f5ccb8942dea95 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 20 Jan 2021 23:22:49 +0200 Subject: [PATCH 082/108] Add router for categories --- app/database/models.py | 1 - app/main.py | 3 +- app/routers/categories.py | 69 +++++++++++++++++++++++++++++++++++++++ schema.md | 2 ++ 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 app/routers/categories.py diff --git a/app/database/models.py b/app/database/models.py index 2936990f..98813ce2 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -70,7 +70,6 @@ class Category(Base): @staticmethod def create(db_session: Session, name: str, color: str, user_id: int): - print(Base.metadata.tables.keys()) try: category = Category(name=name, color=color, user_id=user_id) db_session.add(category) diff --git a/app/main.py b/app/main.py index 1db4c9d7..5a9cd55d 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ from app.database.database import engine from app.dependencies import ( MEDIA_PATH, STATIC_PATH, templates) -from app.routers import agenda, email, event, invitation, profile +from app.routers import agenda, categories, email, event, invitation, profile models.Base.metadata.create_all(bind=engine) @@ -15,6 +15,7 @@ routers_to_include = [ agenda.router, + categories.router, email.router, event.router, invitation.router, diff --git a/app/routers/categories.py b/app/routers/categories.py new file mode 100644 index 00000000..6501de58 --- /dev/null +++ b/app/routers/categories.py @@ -0,0 +1,69 @@ +from typing import Dict, List + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel +from sqlalchemy.exc import IntegrityError, SQLAlchemyError +from sqlalchemy.orm import Session +from starlette import status + +from app.database.database import get_db +from app.database.models import Category + +router = APIRouter( + prefix="/categories", + tags=["categories"], +) + + +class CategoryModel(BaseModel): + name: str + color: str + user_id: int + + +# TODO(issue#29): get user_id from session +@router.get("/") +def get_categories(request: Request, db_session: Session = Depends(get_db)) -> List[Category]: + if validate_request_params(request): + 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 unhallowed params.") + + +# TODO(issue#29): get user_id from session +@router.post("/") +async def set_category(category: CategoryModel, db_session: Session = Depends(get_db)) -> Dict: + try: + cat = Category.create(db_session, name=category.name, color=category.color, user_id=category.user_id) + except IntegrityError: + db_session.rollback() + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail=f"category is already exists for {category.user_id} user.") + else: + return {"category": cat.to_dict()} + + +def validate_request_params(request: Request) -> bool: + """ + request contains not more than user_id, name, color and not less than user_id: + Intersection must contain at least user_id. + Union must not contain fields other than user_id, name, color. + + """ + all_fields = set(CategoryModel.schema()["required"]) + union_set = set(request.keys()).union(all_fields) + intersection_set = set(request.keys()).intersection(all_fields) + return union_set == all_fields and {"user_id"} in intersection_set + + +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() + except SQLAlchemyError: + return [] + else: + return categories diff --git a/schema.md b/schema.md index 58140f95..443cd9a8 100644 --- a/schema.md +++ b/schema.md @@ -13,6 +13,8 @@ │ ├── admin.py │ ├── routers │ ├── __init__.py +│ ├── agenda.py +│ ├── categories.py │ ├── profile.py │ ├── media │ ├── example.png From 0f048969d2125527647dca76e983a46055122241 Mon Sep 17 00:00:00 2001 From: i Date: Thu, 21 Jan 2021 23:49:25 +0200 Subject: [PATCH 083/108] Add tests + schema --- app/routers/categories.py | 21 +++++----- schema.md | 29 ++++++++++---- tests/conftest.py | 5 ++- tests/db_entities.py | 13 +++++++ tests/test_categories.py | 81 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 18 deletions(-) create mode 100644 tests/db_entities.py create mode 100644 tests/test_categories.py diff --git a/app/routers/categories.py b/app/routers/categories.py index 6501de58..f7fd738e 100644 --- a/app/routers/categories.py +++ b/app/routers/categories.py @@ -5,6 +5,7 @@ from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.orm import Session from starlette import status +from starlette.datastructures import ImmutableMultiDict from app.database.database import get_db from app.database.models import Category @@ -21,17 +22,17 @@ class CategoryModel(BaseModel): user_id: int -# TODO(issue#29): get user_id from session +# TODO(issue#29): get current user_id from session @router.get("/") def get_categories(request: Request, db_session: Session = Depends(get_db)) -> List[Category]: - if validate_request_params(request): + 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 unhallowed params.") + detail=f"Request {request.query_params} contains unallowed params.") -# TODO(issue#29): get user_id from session +# TODO(issue#29): get current user_id from session @router.post("/") async def set_category(category: CategoryModel, db_session: Session = Depends(get_db)) -> Dict: try: @@ -44,17 +45,17 @@ async def set_category(category: CategoryModel, db_session: Session = Depends(ge return {"category": cat.to_dict()} -def validate_request_params(request: Request) -> bool: +def validate_request_params(query_params: ImmutableMultiDict) -> bool: """ - request contains not more than user_id, name, color and not less than user_id: + request.query_params contains not more than user_id, name, color and not less than user_id: Intersection must contain at least user_id. Union must not contain fields other than user_id, name, color. - """ all_fields = set(CategoryModel.schema()["required"]) - union_set = set(request.keys()).union(all_fields) - intersection_set = set(request.keys()).intersection(all_fields) - return union_set == all_fields and {"user_id"} in intersection_set + request_params = set(query_params) + union_set = request_params.union(all_fields) + intersection_set = request_params.intersection(all_fields) + return union_set == all_fields and "user_id" in intersection_set def get_user_categories(db_session: Session, user_id: int, **params) -> List[Category]: diff --git a/schema.md b/schema.md index 443cd9a8..a0d2120f 100644 --- a/schema.md +++ b/schema.md @@ -11,17 +11,26 @@ │ ├── internal │ ├── __init__.py │ ├── admin.py +│ ├── agenda_events.py +│ ├── email.py +│ ├── media +│ ├── example.png +│ ├── fake_user.png +│ ├── profile.png │ ├── routers │ ├── __init__.py │ ├── agenda.py │ ├── categories.py +│ ├── email.py +│ ├── event.py │ ├── profile.py -│ ├── media -│ ├── example.png -│ ├── profile.png │ ├── static -│ ├── style.css +│ ├── event +│ ├── eventedit.css +│ ├── eventview.css +│ ├── agenda_style.css │ ├── popover.js +│ ├── style.css │ ├── templates │ ├── base.html │ ├── home.html @@ -31,6 +40,12 @@ ├── schema.md └── tests ├── __init__.py - └── conftest.py - └── test_profile.py - └── test_app.py \ No newline at end of file + ├── conftest.py + ├── db_entities.py + ├── test_agenda_internal.py + ├── test_agenda_route.py + ├── test_app.py + ├── test_categories.py + ├── test_email.py + ├── test_event.py + └── test_profile.py \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index a838375d..5401f310 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ from sqlalchemy.orm import sessionmaker from app.database.database import Base +from app.database.database import engine pytest_plugins = [ 'tests.user_fixture', @@ -11,6 +12,7 @@ 'tests.association_fixture', 'tests.client_fixture', 'smtpdfix', + 'tests.db_entities', ] SQLALCHEMY_TEST_DATABASE_URL = "sqlite:///./test.db" @@ -31,5 +33,6 @@ def session(): Base.metadata.create_all(bind=test_engine) session = get_test_db() yield session + session.rollback() session.close() - Base.metadata.drop_all(bind=test_engine) + Base.metadata.drop_all(bind=engine) diff --git a/tests/db_entities.py b/tests/db_entities.py new file mode 100644 index 00000000..08cc6b97 --- /dev/null +++ b/tests/db_entities.py @@ -0,0 +1,13 @@ +import pytest +from sqlalchemy.orm import Session + +from app.database.models import User, Category + + +@pytest.fixture +def category(session: Session, user: User) -> Category: + category = Category.create(session, name="Guitar Lesson", color="121212", + user_id=user.id) + yield category + session.delete(category) + session.commit() diff --git a/tests/test_categories.py b/tests/test_categories.py new file mode 100644 index 00000000..a2322703 --- /dev/null +++ b/tests/test_categories.py @@ -0,0 +1,81 @@ +import pytest +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 + + +class TestCategories: + CATEGORY_ALREADY_EXISTS_MESSAGE = "category is already exists for {0} user." + UNALLOWED_PARAMS = "contains unallowed params" + + @staticmethod + def test_creating_new_category(client, user): + response = client.post("/categories/", json={"user_id": user.id, "name": "Foo", "color": "eecc11"}) + assert response.status_code == status.HTTP_200_OK + assert {"user_id": user.id, "name": "Foo", "color": "eecc11"}.items() <= response.json()['category'].items() + + @staticmethod + def test_creating_not_unique_category_failed(client, user, category): + response = client.post("/categories/", json={"user_id": user.id, "name": "Guitar Lesson", "color": "121212"}) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["detail"] == TestCategories.CATEGORY_ALREADY_EXISTS_MESSAGE.format(user.id) + + @staticmethod + def test_create_event_with_category(category): + 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 + + @staticmethod + def test_get_user_categories(client, category): + response = client.get(f"/categories/?user_id={category.user_id}&name={category.name}&color={category.color}") + assert response.status_code == status.HTTP_200_OK + assert response.json() == [ + {"user_id": category.user_id, "color": "121212", "name": "Guitar Lesson", "id": category.id}] + + @staticmethod + def test_get_category_by_name(client, user, category): + response = client.get(f"/categories/?user_id={category.user_id}&name={category.name}") + assert response.status_code == status.HTTP_200_OK + assert response.json() == [ + {"user_id": category.user_id, "color": "121212", "name": "Guitar Lesson", "id": category.id}] + + @staticmethod + def test_get_category_by_color(client, user, category): + response = client.get(f"/categories/?user_id={category.user_id}&color={category.color}") + assert response.status_code == status.HTTP_200_OK + assert response.json() == [ + {"user_id": category.user_id, "color": "121212", "name": "Guitar Lesson", "id": category.id}] + + @staticmethod + def test_get_category_bad_request(client): + response = client.get(f"/categories/") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert TestCategories.UNALLOWED_PARAMS in response.json()["detail"] + + @staticmethod + def test_repr(category): + 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 + def test_get_categories_logic_succeeded(session, user, category): + assert get_user_categories(session, category.user_id) == [category] + + @staticmethod + @pytest.mark.parametrize('params, expected_result', [ + (ImmutableMultiDict([('user_id', ''), ('name', ''), ('color', '')]), True), + (ImmutableMultiDict([('user_id', ''), ('name', '')]), True), + (ImmutableMultiDict([('user_id', ''), ('color', '')]), True), + (ImmutableMultiDict([('user_id', '')]), True), + (ImmutableMultiDict([('name', ''), ('color', '')]), False), + (ImmutableMultiDict([]), False), + (ImmutableMultiDict([('user_id', ''), ('name', ''), ('color', ''), ('bad_param', '')]), False), + ]) + def test_validate_request_params(params, expected_result): + assert validate_request_params(params) == expected_result From 9b50de515b4fab0aef149d4ae4bb8c6436514fe1 Mon Sep 17 00:00:00 2001 From: i Date: Fri, 22 Jan 2021 00:10:42 +0200 Subject: [PATCH 084/108] Linter --- app/routers/categories.py | 20 ++++++++++++----- tests/test_categories.py | 47 ++++++++++++++++++++++++++------------- 2 files changed, 45 insertions(+), 22 deletions(-) diff --git a/app/routers/categories.py b/app/routers/categories.py index f7fd738e..a12db0dd 100644 --- a/app/routers/categories.py +++ b/app/routers/categories.py @@ -24,19 +24,25 @@ class CategoryModel(BaseModel): # TODO(issue#29): get current user_id from session @router.get("/") -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 unallowed params.") + detail=f"Request {request.query_params} contains " + f"unallowed params.") # TODO(issue#29): get current user_id from session @router.post("/") -async def set_category(category: CategoryModel, db_session: Session = Depends(get_db)) -> Dict: +async def set_category(category: CategoryModel, + db_session: Session = Depends(get_db)) -> Dict: try: - cat = Category.create(db_session, name=category.name, color=category.color, user_id=category.user_id) + cat = Category.create(db_session, + name=category.name, + color=category.color, + user_id=category.user_id) except IntegrityError: db_session.rollback() raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, @@ -58,12 +64,14 @@ def validate_request_params(query_params: ImmutableMultiDict) -> bool: return union_set == all_fields and "user_id" in intersection_set -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: diff --git a/tests/test_categories.py b/tests/test_categories.py index a2322703..6e0ac37e 100644 --- a/tests/test_categories.py +++ b/tests/test_categories.py @@ -7,47 +7,58 @@ class TestCategories: - CATEGORY_ALREADY_EXISTS_MESSAGE = "category is already exists for {0} user." + CATEGORY_ALREADY_EXISTS_MSG = "category is already exists for" UNALLOWED_PARAMS = "contains unallowed params" @staticmethod def test_creating_new_category(client, user): - response = client.post("/categories/", json={"user_id": user.id, "name": "Foo", "color": "eecc11"}) + response = client.post("/categories/", json={"user_id": user.id, + "name": "Foo", "color": "eecc11"}) assert response.status_code == status.HTTP_200_OK - assert {"user_id": user.id, "name": "Foo", "color": "eecc11"}.items() <= response.json()['category'].items() + assert {"user_id": user.id, "name": "Foo", "color": "eecc11"}.items() \ + <= response.json()['category'].items() @staticmethod def test_creating_not_unique_category_failed(client, user, category): - response = client.post("/categories/", json={"user_id": user.id, "name": "Guitar Lesson", "color": "121212"}) + response = client.post("/categories/", json={"user_id": user.id, + "name": "Guitar Lesson", "color": "121212"}) assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json()["detail"] == TestCategories.CATEGORY_ALREADY_EXISTS_MESSAGE.format(user.id) + assert TestCategories.CATEGORY_ALREADY_EXISTS_MSG 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 @staticmethod def test_get_user_categories(client, category): - response = client.get(f"/categories/?user_id={category.user_id}&name={category.name}&color={category.color}") + response = client.get(f"/categories/?user_id={category.user_id}" + f"&name={category.name}&color={category.color}") assert response.status_code == status.HTTP_200_OK assert response.json() == [ - {"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, user, category): - response = client.get(f"/categories/?user_id={category.user_id}&name={category.name}") + response = client.get(f"/categories/?user_id={category.user_id}" + f"&name={category.name}") assert response.status_code == status.HTTP_200_OK assert response.json() == [ - {"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, user, category): - response = client.get(f"/categories/?user_id={category.user_id}&color={category.color}") + response = client.get(f"/categories/?user_id={category.user_id}&" + f"color={category.color}") assert response.status_code == status.HTTP_200_OK assert response.json() == [ - {"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): @@ -57,11 +68,13 @@ def test_get_category_bad_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() + assert {c.name: getattr(category, c.name) for c in + category.__table__.columns} == category.to_dict() @staticmethod def test_get_categories_logic_succeeded(session, user, category): @@ -69,13 +82,15 @@ def test_get_categories_logic_succeeded(session, user, category): @staticmethod @pytest.mark.parametrize('params, expected_result', [ - (ImmutableMultiDict([('user_id', ''), ('name', ''), ('color', '')]), True), + (ImmutableMultiDict([('user_id', ''), ('name', ''), + ('color', '')]), True), (ImmutableMultiDict([('user_id', ''), ('name', '')]), True), (ImmutableMultiDict([('user_id', ''), ('color', '')]), True), (ImmutableMultiDict([('user_id', '')]), True), (ImmutableMultiDict([('name', ''), ('color', '')]), False), (ImmutableMultiDict([]), False), - (ImmutableMultiDict([('user_id', ''), ('name', ''), ('color', ''), ('bad_param', '')]), False), + (ImmutableMultiDict([('user_id', ''), ('name', ''), ('color', ''), + ('bad_param', '')]), False), ]) def test_validate_request_params(params, expected_result): assert validate_request_params(params) == expected_result From e429cb6821dbe86aeb7298ac033d97950c5807a7 Mon Sep 17 00:00:00 2001 From: i Date: Fri, 22 Jan 2021 12:08:39 +0200 Subject: [PATCH 085/108] PR comments and linter --- app/database/models.py | 6 +++--- app/routers/categories.py | 10 ++++++---- tests/test_categories.py | 10 ++++++---- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/database/models.py b/app/database/models.py index 98813ce2..788760e7 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -68,10 +68,10 @@ class Category(Base): color = Column(String, nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - @staticmethod - def create(db_session: Session, name: str, color: str, user_id: int): + @classmethod + def create(cls, db_session: Session, name: str, color: str, user_id: int): try: - category = Category(name=name, color=color, user_id=user_id) + category = cls(name=name, color=color, user_id=user_id) db_session.add(category) db_session.flush() db_session.commit() diff --git a/app/routers/categories.py b/app/routers/categories.py index a12db0dd..90cfadaa 100644 --- a/app/routers/categories.py +++ b/app/routers/categories.py @@ -46,14 +46,16 @@ async def set_category(category: CategoryModel, except IntegrityError: db_session.rollback() raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, - detail=f"category is already exists for {category.user_id} user.") + detail=f"category is already exists for " + f"{category.user_id} user.") else: return {"category": cat.to_dict()} def validate_request_params(query_params: ImmutableMultiDict) -> bool: """ - request.query_params contains not more than user_id, name, color and not less than user_id: + request.query_params contains not more than user_id, name, color + and not less than user_id: Intersection must contain at least user_id. Union must not contain fields other than user_id, name, color. """ @@ -70,8 +72,8 @@ def get_user_categories(db_session: Session, 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: diff --git a/tests/test_categories.py b/tests/test_categories.py index 6e0ac37e..f3c2efd4 100644 --- a/tests/test_categories.py +++ b/tests/test_categories.py @@ -12,8 +12,9 @@ class TestCategories: @staticmethod def test_creating_new_category(client, user): - response = client.post("/categories/", json={"user_id": user.id, - "name": "Foo", "color": "eecc11"}) + response = client.post("/categories/", + json={"user_id": user.id, "name": "Foo", + "color": "eecc11"}) assert response.status_code == status.HTTP_200_OK assert {"user_id": user.id, "name": "Foo", "color": "eecc11"}.items() \ <= response.json()['category'].items() @@ -21,7 +22,8 @@ def test_creating_new_category(client, user): @staticmethod def test_creating_not_unique_category_failed(client, user, category): response = client.post("/categories/", json={"user_id": user.id, - "name": "Guitar Lesson", "color": "121212"}) + "name": "Guitar Lesson", + "color": "121212"}) assert response.status_code == status.HTTP_400_BAD_REQUEST assert TestCategories.CATEGORY_ALREADY_EXISTS_MSG in \ response.json()["detail"] @@ -62,7 +64,7 @@ def test_get_category_by_color(client, user, category): @staticmethod def test_get_category_bad_request(client): - response = client.get(f"/categories/") + response = client.get("/categories/") assert response.status_code == status.HTTP_400_BAD_REQUEST assert TestCategories.UNALLOWED_PARAMS in response.json()["detail"] From 154752889f4a4a8a5dbdacc4c38d74aeaa17e955 Mon Sep 17 00:00:00 2001 From: i Date: Fri, 22 Jan 2021 14:50:39 +0200 Subject: [PATCH 086/108] Test coverage --- app/database/database.py | 3 ++- tests/test_categories.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/database/database.py b/app/database/database.py index 631a3593..63ef68aa 100644 --- a/app/database/database.py +++ b/app/database/database.py @@ -2,6 +2,7 @@ from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import Session from sqlalchemy.orm import sessionmaker from app import config @@ -17,7 +18,7 @@ Base = declarative_base() -def get_db(): +def get_db() -> Session: db = SessionLocal() try: yield db diff --git a/tests/test_categories.py b/tests/test_categories.py index f3c2efd4..e2358a43 100644 --- a/tests/test_categories.py +++ b/tests/test_categories.py @@ -1,4 +1,6 @@ import pytest +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.testing import mock from starlette import status from starlette.datastructures import ImmutableMultiDict @@ -96,3 +98,11 @@ def test_get_categories_logic_succeeded(session, user, category): ]) def test_validate_request_params(params, expected_result): assert validate_request_params(params) == expected_result + + @staticmethod + def test_get_categories_failed(session): + def raise_error(param): + raise SQLAlchemyError() + + session.query = mock.Mock(side_effect=raise_error) + assert get_user_categories(session, 1) == [] From 64d9cbafaae9f5d72b34b6fa3c0b1d635d337fe7 Mon Sep 17 00:00:00 2001 From: i Date: Sun, 24 Jan 2021 16:23:51 +0200 Subject: [PATCH 087/108] Remove --- tests/conftest.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 7a332113..5401f310 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,12 +28,6 @@ def get_test_db(): return TestingSessionLocal() -pytest_plugins = [ - 'tests.db_entities', - 'smtpdfix' -] - - @pytest.fixture def session(): Base.metadata.create_all(bind=test_engine) From 88f0d903d351f2eace212e74a35fc58666afddf9 Mon Sep 17 00:00:00 2001 From: i Date: Sun, 24 Jan 2021 19:21:14 +0200 Subject: [PATCH 088/108] Missed line --- app/database/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/database/models.py b/app/database/models.py index 788760e7..91029fb4 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -50,6 +50,8 @@ class Event(Base): location = Column(String) owner = relationship("User") + participants = relationship("UserEvent", back_populates="events") + owner_id = Column(Integer, ForeignKey("users.id")) category_id = Column(Integer, ForeignKey("categories.id")) From e0ae06bba78ed7a42114f85823835992bd987b8d Mon Sep 17 00:00:00 2001 From: i Date: Sun, 24 Jan 2021 20:33:22 +0200 Subject: [PATCH 089/108] Rename file --- tests/{db_entities.py => category_fixture.py} | 0 tests/conftest.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename tests/{db_entities.py => category_fixture.py} (100%) diff --git a/tests/db_entities.py b/tests/category_fixture.py similarity index 100% rename from tests/db_entities.py rename to tests/category_fixture.py diff --git a/tests/conftest.py b/tests/conftest.py index 5401f310..f9b3cd0f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,8 +11,8 @@ 'tests.invitation_fixture', 'tests.association_fixture', 'tests.client_fixture', + 'tests.category_fixture', 'smtpdfix', - 'tests.db_entities', ] SQLALCHEMY_TEST_DATABASE_URL = "sqlite:///./test.db" From 606892acae53b5ee21c3d16af3640c088b478831 Mon Sep 17 00:00:00 2001 From: i Date: Sun, 24 Jan 2021 21:01:45 +0200 Subject: [PATCH 090/108] Tests --- tests/test_categories.py | 8 ++++---- tests/test_share_event.py | 15 +++++++-------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/tests/test_categories.py b/tests/test_categories.py index e2358a43..5f9a12e0 100644 --- a/tests/test_categories.py +++ b/tests/test_categories.py @@ -12,6 +12,10 @@ class TestCategories: CATEGORY_ALREADY_EXISTS_MSG = "category is already exists for" UNALLOWED_PARAMS = "contains unallowed params" + @staticmethod + def test_get_categories_logic_succeeded(session, user, category): + assert get_user_categories(session, category.user_id) == [category] + @staticmethod def test_creating_new_category(client, user): response = client.post("/categories/", @@ -80,10 +84,6 @@ def test_to_dict(category): assert {c.name: getattr(category, c.name) for c in category.__table__.columns} == category.to_dict() - @staticmethod - def test_get_categories_logic_succeeded(session, user, category): - assert get_user_categories(session, category.user_id) == [category] - @staticmethod @pytest.mark.parametrize('params, expected_result', [ (ImmutableMultiDict([('user_id', ''), ('name', ''), diff --git a/tests/test_share_event.py b/tests/test_share_event.py index 03282af9..45a70581 100644 --- a/tests/test_share_event.py +++ b/tests/test_share_event.py @@ -5,6 +5,13 @@ class TestShareEvent: + def test_share_failure(self, event, session): + participants = [event.owner.email] + share(event, participants, session) + invitations = get_all_invitations( + session=session, recipient_id=event.owner.id + ) + assert invitations == [] def test_share_success(self, user, event, session): participants = [user.email] @@ -14,14 +21,6 @@ def test_share_success(self, user, event, session): ) assert invitations != [] - def test_share_failure(self, event, session): - participants = [event.owner.email] - share(event, participants, session) - invitations = get_all_invitations( - 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 From 894be6d3c1c138c6f3ffc058bb2528f77cff9f59 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 20 Jan 2021 21:02:40 +0200 Subject: [PATCH 091/108] Add categories table --- app/database/models.py | 35 +++++++++++++++++++++++++++++++++++ app/main.py | 16 ++++++++-------- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/app/database/models.py b/app/database/models.py index bc3025ba..1ca76066 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1,9 +1,12 @@ from datetime import datetime from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, UniqueConstraint from sqlalchemy.orm import relationship from app.database.database import Base +from app.database.database import Base +from sqlalchemy.orm import Session class UserEvent(Base): @@ -50,6 +53,7 @@ class Event(Base): owner = relationship("User") owner_id = Column(Integer, ForeignKey("users.id")) + category_id = Column(Integer, ForeignKey("categories.id")) color = Column(String, nullable=True) participants = relationship("UserEvent", back_populates="events") @@ -76,3 +80,34 @@ def __repr__(self): f'({self.event.owner}' f'to {self.recipient})>' ) + + +class Category(Base): + __tablename__ = "categories" + + __table_args__ = ( + UniqueConstraint('user_id', 'name', 'color'), + ) + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + color = Column(String, nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + @staticmethod + def create(db_session: Session, name: str, color: str, user_id: int): + print(Base.metadata.tables.keys()) + try: + category = Category(name=name, color=color, user_id=user_id) + db_session.add(category) + db_session.flush() + db_session.commit() + db_session.refresh(category) + return category + except Exception as e: + raise e + + def to_dict(self): + return {c.name: getattr(self, c.name) for c in self.__table__.columns} + + def __repr__(self): + return f'' diff --git a/app/main.py b/app/main.py index 5247976f..32990c6d 100644 --- a/app/main.py +++ b/app/main.py @@ -7,19 +7,19 @@ MEDIA_PATH, STATIC_PATH, templates) from app.routers import agenda, dayview, event, profile, email, invitation - models.Base.metadata.create_all(bind=engine) -app = FastAPI() +app = FastAPI(debug=True) app.mount("/static", StaticFiles(directory=STATIC_PATH), name="static") app.mount("/media", StaticFiles(directory=MEDIA_PATH), name="media") -app.include_router(profile.router) -app.include_router(event.router) -app.include_router(agenda.router) -app.include_router(dayview.router) -app.include_router(email.router) -app.include_router(invitation.router) +routers_to_include = [ + agenda.router, + event.router, + profile.router, +] +for router in routers_to_include: + app.include_router(router) @app.get("/") From b90e8825b229e5594fe26b3eaf586f9816adb973 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 20 Jan 2021 23:22:49 +0200 Subject: [PATCH 092/108] Add router for categories --- app/database/models.py | 1 - app/main.py | 3 +- app/routers/categories.py | 69 +++++++++++++++++++++++++++++++++++++++ schema.md | 2 ++ 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 app/routers/categories.py diff --git a/app/database/models.py b/app/database/models.py index 1ca76066..5d821a44 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -95,7 +95,6 @@ class Category(Base): @staticmethod def create(db_session: Session, name: str, color: str, user_id: int): - print(Base.metadata.tables.keys()) try: category = Category(name=name, color=color, user_id=user_id) db_session.add(category) diff --git a/app/main.py b/app/main.py index 32990c6d..11282222 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ from app.database.database import engine from app.dependencies import ( MEDIA_PATH, STATIC_PATH, templates) -from app.routers import agenda, dayview, event, profile, email, invitation +from app.routers import agenda, event, profile, categories models.Base.metadata.create_all(bind=engine) @@ -15,6 +15,7 @@ routers_to_include = [ agenda.router, + categories.router, event.router, profile.router, ] diff --git a/app/routers/categories.py b/app/routers/categories.py new file mode 100644 index 00000000..6501de58 --- /dev/null +++ b/app/routers/categories.py @@ -0,0 +1,69 @@ +from typing import Dict, List + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel +from sqlalchemy.exc import IntegrityError, SQLAlchemyError +from sqlalchemy.orm import Session +from starlette import status + +from app.database.database import get_db +from app.database.models import Category + +router = APIRouter( + prefix="/categories", + tags=["categories"], +) + + +class CategoryModel(BaseModel): + name: str + color: str + user_id: int + + +# TODO(issue#29): get user_id from session +@router.get("/") +def get_categories(request: Request, db_session: Session = Depends(get_db)) -> List[Category]: + if validate_request_params(request): + 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 unhallowed params.") + + +# TODO(issue#29): get user_id from session +@router.post("/") +async def set_category(category: CategoryModel, db_session: Session = Depends(get_db)) -> Dict: + try: + cat = Category.create(db_session, name=category.name, color=category.color, user_id=category.user_id) + except IntegrityError: + db_session.rollback() + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, + detail=f"category is already exists for {category.user_id} user.") + else: + return {"category": cat.to_dict()} + + +def validate_request_params(request: Request) -> bool: + """ + request contains not more than user_id, name, color and not less than user_id: + Intersection must contain at least user_id. + Union must not contain fields other than user_id, name, color. + + """ + all_fields = set(CategoryModel.schema()["required"]) + union_set = set(request.keys()).union(all_fields) + intersection_set = set(request.keys()).intersection(all_fields) + return union_set == all_fields and {"user_id"} in intersection_set + + +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() + except SQLAlchemyError: + return [] + else: + return categories diff --git a/schema.md b/schema.md index 58140f95..443cd9a8 100644 --- a/schema.md +++ b/schema.md @@ -13,6 +13,8 @@ │ ├── admin.py │ ├── routers │ ├── __init__.py +│ ├── agenda.py +│ ├── categories.py │ ├── profile.py │ ├── media │ ├── example.png From c489569ae0cb8f50ba16f7947bd2b6f12d7c7938 Mon Sep 17 00:00:00 2001 From: i Date: Thu, 21 Jan 2021 23:49:25 +0200 Subject: [PATCH 093/108] Add tests + schema --- app/main.py | 3 +- app/routers/categories.py | 21 +++++----- schema.md | 29 ++++++++++---- tests/conftest.py | 7 ++++ tests/db_entities.py | 38 ++++++++++++++++++ tests/test_categories.py | 81 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 161 insertions(+), 18 deletions(-) create mode 100644 tests/db_entities.py create mode 100644 tests/test_categories.py diff --git a/app/main.py b/app/main.py index 11282222..bb400b77 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ from app.database.database import engine from app.dependencies import ( MEDIA_PATH, STATIC_PATH, templates) -from app.routers import agenda, event, profile, categories +from app.routers import agenda, categories, email, event, profile models.Base.metadata.create_all(bind=engine) @@ -16,6 +16,7 @@ routers_to_include = [ agenda.router, categories.router, + email.router, event.router, profile.router, ] diff --git a/app/routers/categories.py b/app/routers/categories.py index 6501de58..f7fd738e 100644 --- a/app/routers/categories.py +++ b/app/routers/categories.py @@ -5,6 +5,7 @@ from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.orm import Session from starlette import status +from starlette.datastructures import ImmutableMultiDict from app.database.database import get_db from app.database.models import Category @@ -21,17 +22,17 @@ class CategoryModel(BaseModel): user_id: int -# TODO(issue#29): get user_id from session +# TODO(issue#29): get current user_id from session @router.get("/") def get_categories(request: Request, db_session: Session = Depends(get_db)) -> List[Category]: - if validate_request_params(request): + 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 unhallowed params.") + detail=f"Request {request.query_params} contains unallowed params.") -# TODO(issue#29): get user_id from session +# TODO(issue#29): get current user_id from session @router.post("/") async def set_category(category: CategoryModel, db_session: Session = Depends(get_db)) -> Dict: try: @@ -44,17 +45,17 @@ async def set_category(category: CategoryModel, db_session: Session = Depends(ge return {"category": cat.to_dict()} -def validate_request_params(request: Request) -> bool: +def validate_request_params(query_params: ImmutableMultiDict) -> bool: """ - request contains not more than user_id, name, color and not less than user_id: + request.query_params contains not more than user_id, name, color and not less than user_id: Intersection must contain at least user_id. Union must not contain fields other than user_id, name, color. - """ all_fields = set(CategoryModel.schema()["required"]) - union_set = set(request.keys()).union(all_fields) - intersection_set = set(request.keys()).intersection(all_fields) - return union_set == all_fields and {"user_id"} in intersection_set + request_params = set(query_params) + union_set = request_params.union(all_fields) + intersection_set = request_params.intersection(all_fields) + return union_set == all_fields and "user_id" in intersection_set def get_user_categories(db_session: Session, user_id: int, **params) -> List[Category]: diff --git a/schema.md b/schema.md index 443cd9a8..a0d2120f 100644 --- a/schema.md +++ b/schema.md @@ -11,17 +11,26 @@ │ ├── internal │ ├── __init__.py │ ├── admin.py +│ ├── agenda_events.py +│ ├── email.py +│ ├── media +│ ├── example.png +│ ├── fake_user.png +│ ├── profile.png │ ├── routers │ ├── __init__.py │ ├── agenda.py │ ├── categories.py +│ ├── email.py +│ ├── event.py │ ├── profile.py -│ ├── media -│ ├── example.png -│ ├── profile.png │ ├── static -│ ├── style.css +│ ├── event +│ ├── eventedit.css +│ ├── eventview.css +│ ├── agenda_style.css │ ├── popover.js +│ ├── style.css │ ├── templates │ ├── base.html │ ├── home.html @@ -31,6 +40,12 @@ ├── schema.md └── tests ├── __init__.py - └── conftest.py - └── test_profile.py - └── test_app.py \ No newline at end of file + ├── conftest.py + ├── db_entities.py + ├── test_agenda_internal.py + ├── test_agenda_route.py + ├── test_app.py + ├── test_categories.py + ├── test_email.py + ├── test_event.py + └── test_profile.py \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index a838375d..61d8bb41 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,10 +26,17 @@ def get_test_db(): return TestingSessionLocal() +pytest_plugins = [ + 'tests.db_entities', + 'smtpdfix' +] + + @pytest.fixture def session(): Base.metadata.create_all(bind=test_engine) session = get_test_db() yield session + session.rollback() session.close() Base.metadata.drop_all(bind=test_engine) diff --git a/tests/db_entities.py b/tests/db_entities.py new file mode 100644 index 00000000..8c4afbe6 --- /dev/null +++ b/tests/db_entities.py @@ -0,0 +1,38 @@ +import datetime + +import pytest +from faker import Faker + +from app.database.models import User, Event, Category + + +@pytest.fixture +def user(session): + faker = Faker() + user1 = User(username=faker.first_name(), email=faker.email()) + session.add(user1) + session.commit() + yield user1 + session.delete(user1) + session.commit() + + +@pytest.fixture +def event(session, user): + event1 = Event( + title="Test Email", content="Test TEXT", + start=datetime.datetime.now(), + end=datetime.datetime.now(), owner_id=user.id) + session.add(event1) + session.commit() + yield event1 + session.delete(event1) + session.commit() + + +@pytest.fixture +def category(session, user): + category = Category.create(session, name="Guitar Lesson", color="121212", user_id=user.id) + yield category + session.delete(category) + session.commit() diff --git a/tests/test_categories.py b/tests/test_categories.py new file mode 100644 index 00000000..a2322703 --- /dev/null +++ b/tests/test_categories.py @@ -0,0 +1,81 @@ +import pytest +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 + + +class TestCategories: + CATEGORY_ALREADY_EXISTS_MESSAGE = "category is already exists for {0} user." + UNALLOWED_PARAMS = "contains unallowed params" + + @staticmethod + def test_creating_new_category(client, user): + response = client.post("/categories/", json={"user_id": user.id, "name": "Foo", "color": "eecc11"}) + assert response.status_code == status.HTTP_200_OK + assert {"user_id": user.id, "name": "Foo", "color": "eecc11"}.items() <= response.json()['category'].items() + + @staticmethod + def test_creating_not_unique_category_failed(client, user, category): + response = client.post("/categories/", json={"user_id": user.id, "name": "Guitar Lesson", "color": "121212"}) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json()["detail"] == TestCategories.CATEGORY_ALREADY_EXISTS_MESSAGE.format(user.id) + + @staticmethod + def test_create_event_with_category(category): + 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 + + @staticmethod + def test_get_user_categories(client, category): + response = client.get(f"/categories/?user_id={category.user_id}&name={category.name}&color={category.color}") + assert response.status_code == status.HTTP_200_OK + assert response.json() == [ + {"user_id": category.user_id, "color": "121212", "name": "Guitar Lesson", "id": category.id}] + + @staticmethod + def test_get_category_by_name(client, user, category): + response = client.get(f"/categories/?user_id={category.user_id}&name={category.name}") + assert response.status_code == status.HTTP_200_OK + assert response.json() == [ + {"user_id": category.user_id, "color": "121212", "name": "Guitar Lesson", "id": category.id}] + + @staticmethod + def test_get_category_by_color(client, user, category): + response = client.get(f"/categories/?user_id={category.user_id}&color={category.color}") + assert response.status_code == status.HTTP_200_OK + assert response.json() == [ + {"user_id": category.user_id, "color": "121212", "name": "Guitar Lesson", "id": category.id}] + + @staticmethod + def test_get_category_bad_request(client): + response = client.get(f"/categories/") + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert TestCategories.UNALLOWED_PARAMS in response.json()["detail"] + + @staticmethod + def test_repr(category): + 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 + def test_get_categories_logic_succeeded(session, user, category): + assert get_user_categories(session, category.user_id) == [category] + + @staticmethod + @pytest.mark.parametrize('params, expected_result', [ + (ImmutableMultiDict([('user_id', ''), ('name', ''), ('color', '')]), True), + (ImmutableMultiDict([('user_id', ''), ('name', '')]), True), + (ImmutableMultiDict([('user_id', ''), ('color', '')]), True), + (ImmutableMultiDict([('user_id', '')]), True), + (ImmutableMultiDict([('name', ''), ('color', '')]), False), + (ImmutableMultiDict([]), False), + (ImmutableMultiDict([('user_id', ''), ('name', ''), ('color', ''), ('bad_param', '')]), False), + ]) + def test_validate_request_params(params, expected_result): + assert validate_request_params(params) == expected_result From 3fc54760df23b15ce52f66c4d1e006b41acce051 Mon Sep 17 00:00:00 2001 From: i Date: Thu, 21 Jan 2021 23:56:34 +0200 Subject: [PATCH 094/108] Remove debug --- app/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index bb400b77..b9200a2a 100644 --- a/app/main.py +++ b/app/main.py @@ -9,7 +9,7 @@ models.Base.metadata.create_all(bind=engine) -app = FastAPI(debug=True) +app = FastAPI() app.mount("/static", StaticFiles(directory=STATIC_PATH), name="static") app.mount("/media", StaticFiles(directory=MEDIA_PATH), name="media") From 6700546db09000bd08c4b543aa1c26418312eceb Mon Sep 17 00:00:00 2001 From: i Date: Fri, 22 Jan 2021 00:10:42 +0200 Subject: [PATCH 095/108] Linter --- app/routers/categories.py | 20 ++++++++++++----- tests/db_entities.py | 3 ++- tests/test_categories.py | 47 ++++++++++++++++++++++++++------------- 3 files changed, 47 insertions(+), 23 deletions(-) diff --git a/app/routers/categories.py b/app/routers/categories.py index f7fd738e..a12db0dd 100644 --- a/app/routers/categories.py +++ b/app/routers/categories.py @@ -24,19 +24,25 @@ class CategoryModel(BaseModel): # TODO(issue#29): get current user_id from session @router.get("/") -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 unallowed params.") + detail=f"Request {request.query_params} contains " + f"unallowed params.") # TODO(issue#29): get current user_id from session @router.post("/") -async def set_category(category: CategoryModel, db_session: Session = Depends(get_db)) -> Dict: +async def set_category(category: CategoryModel, + db_session: Session = Depends(get_db)) -> Dict: try: - cat = Category.create(db_session, name=category.name, color=category.color, user_id=category.user_id) + cat = Category.create(db_session, + name=category.name, + color=category.color, + user_id=category.user_id) except IntegrityError: db_session.rollback() raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, @@ -58,12 +64,14 @@ def validate_request_params(query_params: ImmutableMultiDict) -> bool: return union_set == all_fields and "user_id" in intersection_set -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: diff --git a/tests/db_entities.py b/tests/db_entities.py index 8c4afbe6..e6fac60a 100644 --- a/tests/db_entities.py +++ b/tests/db_entities.py @@ -32,7 +32,8 @@ def event(session, user): @pytest.fixture def category(session, user): - category = Category.create(session, name="Guitar Lesson", color="121212", user_id=user.id) + category = Category.create(session, name="Guitar Lesson", + color="121212", user_id=user.id) yield category session.delete(category) session.commit() diff --git a/tests/test_categories.py b/tests/test_categories.py index a2322703..6e0ac37e 100644 --- a/tests/test_categories.py +++ b/tests/test_categories.py @@ -7,47 +7,58 @@ class TestCategories: - CATEGORY_ALREADY_EXISTS_MESSAGE = "category is already exists for {0} user." + CATEGORY_ALREADY_EXISTS_MSG = "category is already exists for" UNALLOWED_PARAMS = "contains unallowed params" @staticmethod def test_creating_new_category(client, user): - response = client.post("/categories/", json={"user_id": user.id, "name": "Foo", "color": "eecc11"}) + response = client.post("/categories/", json={"user_id": user.id, + "name": "Foo", "color": "eecc11"}) assert response.status_code == status.HTTP_200_OK - assert {"user_id": user.id, "name": "Foo", "color": "eecc11"}.items() <= response.json()['category'].items() + assert {"user_id": user.id, "name": "Foo", "color": "eecc11"}.items() \ + <= response.json()['category'].items() @staticmethod def test_creating_not_unique_category_failed(client, user, category): - response = client.post("/categories/", json={"user_id": user.id, "name": "Guitar Lesson", "color": "121212"}) + response = client.post("/categories/", json={"user_id": user.id, + "name": "Guitar Lesson", "color": "121212"}) assert response.status_code == status.HTTP_400_BAD_REQUEST - assert response.json()["detail"] == TestCategories.CATEGORY_ALREADY_EXISTS_MESSAGE.format(user.id) + assert TestCategories.CATEGORY_ALREADY_EXISTS_MSG 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 @staticmethod def test_get_user_categories(client, category): - response = client.get(f"/categories/?user_id={category.user_id}&name={category.name}&color={category.color}") + response = client.get(f"/categories/?user_id={category.user_id}" + f"&name={category.name}&color={category.color}") assert response.status_code == status.HTTP_200_OK assert response.json() == [ - {"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, user, category): - response = client.get(f"/categories/?user_id={category.user_id}&name={category.name}") + response = client.get(f"/categories/?user_id={category.user_id}" + f"&name={category.name}") assert response.status_code == status.HTTP_200_OK assert response.json() == [ - {"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, user, category): - response = client.get(f"/categories/?user_id={category.user_id}&color={category.color}") + response = client.get(f"/categories/?user_id={category.user_id}&" + f"color={category.color}") assert response.status_code == status.HTTP_200_OK assert response.json() == [ - {"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): @@ -57,11 +68,13 @@ def test_get_category_bad_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() + assert {c.name: getattr(category, c.name) for c in + category.__table__.columns} == category.to_dict() @staticmethod def test_get_categories_logic_succeeded(session, user, category): @@ -69,13 +82,15 @@ def test_get_categories_logic_succeeded(session, user, category): @staticmethod @pytest.mark.parametrize('params, expected_result', [ - (ImmutableMultiDict([('user_id', ''), ('name', ''), ('color', '')]), True), + (ImmutableMultiDict([('user_id', ''), ('name', ''), + ('color', '')]), True), (ImmutableMultiDict([('user_id', ''), ('name', '')]), True), (ImmutableMultiDict([('user_id', ''), ('color', '')]), True), (ImmutableMultiDict([('user_id', '')]), True), (ImmutableMultiDict([('name', ''), ('color', '')]), False), (ImmutableMultiDict([]), False), - (ImmutableMultiDict([('user_id', ''), ('name', ''), ('color', ''), ('bad_param', '')]), False), + (ImmutableMultiDict([('user_id', ''), ('name', ''), ('color', ''), + ('bad_param', '')]), False), ]) def test_validate_request_params(params, expected_result): assert validate_request_params(params) == expected_result From ec9c5f005476c569a8967d928d0c601b1e561458 Mon Sep 17 00:00:00 2001 From: i Date: Fri, 22 Jan 2021 12:08:39 +0200 Subject: [PATCH 096/108] PR comments and linter --- app/database/models.py | 6 +++--- app/routers/categories.py | 10 ++++++---- tests/test_categories.py | 10 ++++++---- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/database/models.py b/app/database/models.py index 5d821a44..7c715700 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -93,10 +93,10 @@ class Category(Base): color = Column(String, nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - @staticmethod - def create(db_session: Session, name: str, color: str, user_id: int): + @classmethod + def create(cls, db_session: Session, name: str, color: str, user_id: int): try: - category = Category(name=name, color=color, user_id=user_id) + category = cls(name=name, color=color, user_id=user_id) db_session.add(category) db_session.flush() db_session.commit() diff --git a/app/routers/categories.py b/app/routers/categories.py index a12db0dd..90cfadaa 100644 --- a/app/routers/categories.py +++ b/app/routers/categories.py @@ -46,14 +46,16 @@ async def set_category(category: CategoryModel, except IntegrityError: db_session.rollback() raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, - detail=f"category is already exists for {category.user_id} user.") + detail=f"category is already exists for " + f"{category.user_id} user.") else: return {"category": cat.to_dict()} def validate_request_params(query_params: ImmutableMultiDict) -> bool: """ - request.query_params contains not more than user_id, name, color and not less than user_id: + request.query_params contains not more than user_id, name, color + and not less than user_id: Intersection must contain at least user_id. Union must not contain fields other than user_id, name, color. """ @@ -70,8 +72,8 @@ def get_user_categories(db_session: Session, 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: diff --git a/tests/test_categories.py b/tests/test_categories.py index 6e0ac37e..f3c2efd4 100644 --- a/tests/test_categories.py +++ b/tests/test_categories.py @@ -12,8 +12,9 @@ class TestCategories: @staticmethod def test_creating_new_category(client, user): - response = client.post("/categories/", json={"user_id": user.id, - "name": "Foo", "color": "eecc11"}) + response = client.post("/categories/", + json={"user_id": user.id, "name": "Foo", + "color": "eecc11"}) assert response.status_code == status.HTTP_200_OK assert {"user_id": user.id, "name": "Foo", "color": "eecc11"}.items() \ <= response.json()['category'].items() @@ -21,7 +22,8 @@ def test_creating_new_category(client, user): @staticmethod def test_creating_not_unique_category_failed(client, user, category): response = client.post("/categories/", json={"user_id": user.id, - "name": "Guitar Lesson", "color": "121212"}) + "name": "Guitar Lesson", + "color": "121212"}) assert response.status_code == status.HTTP_400_BAD_REQUEST assert TestCategories.CATEGORY_ALREADY_EXISTS_MSG in \ response.json()["detail"] @@ -62,7 +64,7 @@ def test_get_category_by_color(client, user, category): @staticmethod def test_get_category_bad_request(client): - response = client.get(f"/categories/") + response = client.get("/categories/") assert response.status_code == status.HTTP_400_BAD_REQUEST assert TestCategories.UNALLOWED_PARAMS in response.json()["detail"] From 663fb1f7785cafe274e59f1a25ab8f54efd5ca87 Mon Sep 17 00:00:00 2001 From: i Date: Fri, 22 Jan 2021 14:50:39 +0200 Subject: [PATCH 097/108] Test coverage --- app/database/database.py | 3 ++- tests/test_categories.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/database/database.py b/app/database/database.py index 631a3593..63ef68aa 100644 --- a/app/database/database.py +++ b/app/database/database.py @@ -2,6 +2,7 @@ from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import Session from sqlalchemy.orm import sessionmaker from app import config @@ -17,7 +18,7 @@ Base = declarative_base() -def get_db(): +def get_db() -> Session: db = SessionLocal() try: yield db diff --git a/tests/test_categories.py b/tests/test_categories.py index f3c2efd4..e2358a43 100644 --- a/tests/test_categories.py +++ b/tests/test_categories.py @@ -1,4 +1,6 @@ import pytest +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.testing import mock from starlette import status from starlette.datastructures import ImmutableMultiDict @@ -96,3 +98,11 @@ def test_get_categories_logic_succeeded(session, user, category): ]) def test_validate_request_params(params, expected_result): assert validate_request_params(params) == expected_result + + @staticmethod + def test_get_categories_failed(session): + def raise_error(param): + raise SQLAlchemyError() + + session.query = mock.Mock(side_effect=raise_error) + assert get_user_categories(session, 1) == [] From 9066d834e136c8e94746ebf4bac6fc2c1f94056b Mon Sep 17 00:00:00 2001 From: i Date: Wed, 20 Jan 2021 21:02:40 +0200 Subject: [PATCH 098/108] Add categories table --- app/database/models.py | 37 ++++++++++++++++++++++++++++++++++--- app/main.py | 4 ++-- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/app/database/models.py b/app/database/models.py index 7c715700..36476d1a 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1,8 +1,8 @@ from datetime import datetime -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, UniqueConstraint -from sqlalchemy.orm import relationship +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, \ + String, UniqueConstraint +from sqlalchemy.orm import relationship, Session from app.database.database import Base from app.database.database import Base @@ -62,6 +62,37 @@ def __repr__(self): return f'' +class Category(Base): + __tablename__ = "categories" + + __table_args__ = ( + UniqueConstraint('user_id', 'name', 'color'), + ) + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False) + color = Column(String, nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + + @staticmethod + def create(db_session: Session, name: str, color: str, user_id: int): + print(Base.metadata.tables.keys()) + try: + category = Category(name=name, color=color, user_id=user_id) + db_session.add(category) + db_session.flush() + db_session.commit() + db_session.refresh(category) + return category + except Exception as e: + raise e + + def to_dict(self): + return {c.name: getattr(self, c.name) for c in self.__table__.columns} + + def __repr__(self): + return f'' + + class Invitation(Base): __tablename__ = "invitations" diff --git a/app/main.py b/app/main.py index b9200a2a..1db4c9d7 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ from app.database.database import engine from app.dependencies import ( MEDIA_PATH, STATIC_PATH, templates) -from app.routers import agenda, categories, email, event, profile +from app.routers import agenda, email, event, invitation, profile models.Base.metadata.create_all(bind=engine) @@ -15,9 +15,9 @@ routers_to_include = [ agenda.router, - categories.router, email.router, event.router, + invitation.router, profile.router, ] for router in routers_to_include: From 3c5c55a6f9a44ac40f6b72a64992bf8a9ba8a6c2 Mon Sep 17 00:00:00 2001 From: i Date: Wed, 20 Jan 2021 23:22:49 +0200 Subject: [PATCH 099/108] Add router for categories --- app/database/models.py | 1 - app/main.py | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/database/models.py b/app/database/models.py index 36476d1a..db525e08 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -75,7 +75,6 @@ class Category(Base): @staticmethod def create(db_session: Session, name: str, color: str, user_id: int): - print(Base.metadata.tables.keys()) try: category = Category(name=name, color=color, user_id=user_id) db_session.add(category) diff --git a/app/main.py b/app/main.py index 1db4c9d7..5a9cd55d 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ from app.database.database import engine from app.dependencies import ( MEDIA_PATH, STATIC_PATH, templates) -from app.routers import agenda, email, event, invitation, profile +from app.routers import agenda, categories, email, event, invitation, profile models.Base.metadata.create_all(bind=engine) @@ -15,6 +15,7 @@ routers_to_include = [ agenda.router, + categories.router, email.router, event.router, invitation.router, From 65034dab232a0ed765ed78400125f15420b25f3c Mon Sep 17 00:00:00 2001 From: i Date: Thu, 21 Jan 2021 23:49:25 +0200 Subject: [PATCH 100/108] Add tests + schema --- tests/conftest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 61d8bb41..7a332113 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ from sqlalchemy.orm import sessionmaker from app.database.database import Base +from app.database.database import engine pytest_plugins = [ 'tests.user_fixture', @@ -11,6 +12,7 @@ 'tests.association_fixture', 'tests.client_fixture', 'smtpdfix', + 'tests.db_entities', ] SQLALCHEMY_TEST_DATABASE_URL = "sqlite:///./test.db" @@ -39,4 +41,4 @@ def session(): yield session session.rollback() session.close() - Base.metadata.drop_all(bind=test_engine) + Base.metadata.drop_all(bind=engine) From 1a98f5006fd1fbe522aef7b8b99012782d9ad8e3 Mon Sep 17 00:00:00 2001 From: i Date: Fri, 22 Jan 2021 12:08:39 +0200 Subject: [PATCH 101/108] PR comments and linter --- app/database/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/database/models.py b/app/database/models.py index db525e08..dbf920ba 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -73,10 +73,10 @@ class Category(Base): color = Column(String, nullable=False) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - @staticmethod - def create(db_session: Session, name: str, color: str, user_id: int): + @classmethod + def create(cls, db_session: Session, name: str, color: str, user_id: int): try: - category = Category(name=name, color=color, user_id=user_id) + category = cls(name=name, color=color, user_id=user_id) db_session.add(category) db_session.flush() db_session.commit() From 0c2492f7f882a38948e07a091517b6ee7c7af7be Mon Sep 17 00:00:00 2001 From: i Date: Sun, 24 Jan 2021 16:23:51 +0200 Subject: [PATCH 102/108] Remove --- tests/conftest.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 7a332113..5401f310 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,12 +28,6 @@ def get_test_db(): return TestingSessionLocal() -pytest_plugins = [ - 'tests.db_entities', - 'smtpdfix' -] - - @pytest.fixture def session(): Base.metadata.create_all(bind=test_engine) From cf80aab11a4303cc4b6dd7e71dd000e6a1c4aa45 Mon Sep 17 00:00:00 2001 From: i Date: Sun, 24 Jan 2021 19:21:14 +0200 Subject: [PATCH 103/108] Missed line --- app/database/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/database/models.py b/app/database/models.py index dbf920ba..03b37a04 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -52,6 +52,8 @@ class Event(Base): location = Column(String) owner = relationship("User") + participants = relationship("UserEvent", back_populates="events") + owner_id = Column(Integer, ForeignKey("users.id")) category_id = Column(Integer, ForeignKey("categories.id")) color = Column(String, nullable=True) From ab110a1852e3f031f7aa7b0c7563ae24ea71a918 Mon Sep 17 00:00:00 2001 From: i Date: Sun, 24 Jan 2021 20:33:22 +0200 Subject: [PATCH 104/108] Rename file --- tests/{db_entities.py => category_fixture.py} | 0 tests/conftest.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename tests/{db_entities.py => category_fixture.py} (100%) diff --git a/tests/db_entities.py b/tests/category_fixture.py similarity index 100% rename from tests/db_entities.py rename to tests/category_fixture.py diff --git a/tests/conftest.py b/tests/conftest.py index 5401f310..f9b3cd0f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,8 +11,8 @@ 'tests.invitation_fixture', 'tests.association_fixture', 'tests.client_fixture', + 'tests.category_fixture', 'smtpdfix', - 'tests.db_entities', ] SQLALCHEMY_TEST_DATABASE_URL = "sqlite:///./test.db" From b61cbaaa25d259cc757ac5e356b161edec90395c Mon Sep 17 00:00:00 2001 From: i Date: Sun, 24 Jan 2021 21:01:45 +0200 Subject: [PATCH 105/108] Tests --- tests/test_categories.py | 8 ++++---- tests/test_share_event.py | 15 +++++++-------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/tests/test_categories.py b/tests/test_categories.py index e2358a43..5f9a12e0 100644 --- a/tests/test_categories.py +++ b/tests/test_categories.py @@ -12,6 +12,10 @@ class TestCategories: CATEGORY_ALREADY_EXISTS_MSG = "category is already exists for" UNALLOWED_PARAMS = "contains unallowed params" + @staticmethod + def test_get_categories_logic_succeeded(session, user, category): + assert get_user_categories(session, category.user_id) == [category] + @staticmethod def test_creating_new_category(client, user): response = client.post("/categories/", @@ -80,10 +84,6 @@ def test_to_dict(category): assert {c.name: getattr(category, c.name) for c in category.__table__.columns} == category.to_dict() - @staticmethod - def test_get_categories_logic_succeeded(session, user, category): - assert get_user_categories(session, category.user_id) == [category] - @staticmethod @pytest.mark.parametrize('params, expected_result', [ (ImmutableMultiDict([('user_id', ''), ('name', ''), diff --git a/tests/test_share_event.py b/tests/test_share_event.py index 03282af9..45a70581 100644 --- a/tests/test_share_event.py +++ b/tests/test_share_event.py @@ -5,6 +5,13 @@ class TestShareEvent: + def test_share_failure(self, event, session): + participants = [event.owner.email] + share(event, participants, session) + invitations = get_all_invitations( + session=session, recipient_id=event.owner.id + ) + assert invitations == [] def test_share_success(self, user, event, session): participants = [user.email] @@ -14,14 +21,6 @@ def test_share_success(self, user, event, session): ) assert invitations != [] - def test_share_failure(self, event, session): - participants = [event.owner.email] - share(event, participants, session) - invitations = get_all_invitations( - 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 From 5c90a83f66d137ca13b235e7ac270442c39ba9bd Mon Sep 17 00:00:00 2001 From: i Date: Mon, 25 Jan 2021 17:06:39 +0200 Subject: [PATCH 106/108] Up to date --- app/database/models.py | 39 +-------------------------------------- app/main.py | 4 +++- 2 files changed, 4 insertions(+), 39 deletions(-) diff --git a/app/database/models.py b/app/database/models.py index 61260363..80939ae9 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -5,8 +5,6 @@ from sqlalchemy.orm import relationship, Session from app.database.database import Base -from app.database.database import Base -from sqlalchemy.orm import Session class UserEvent(Base): @@ -50,14 +48,9 @@ class Event(Base): end = Column(DateTime, nullable=False) content = Column(String) location = Column(String) - - owner = relationship("User") - participants = relationship("UserEvent", back_populates="events") - - owner_id = Column(Integer, ForeignKey("users.id")) - category_id = Column(Integer, ForeignKey("categories.id")) color = Column(String, nullable=True) + owner = relationship("User") participants = relationship("UserEvent", back_populates="events") owner_id = Column(Integer, ForeignKey("users.id")) @@ -115,33 +108,3 @@ def __repr__(self): f'({self.event.owner}' f'to {self.recipient})>' ) - - -class Category(Base): - __tablename__ = "categories" - - __table_args__ = ( - UniqueConstraint('user_id', 'name', 'color'), - ) - id = Column(Integer, primary_key=True, index=True) - name = Column(String, nullable=False) - color = Column(String, nullable=False) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - - @classmethod - def create(cls, db_session: Session, name: str, color: str, user_id: int): - try: - category = cls(name=name, color=color, user_id=user_id) - db_session.add(category) - db_session.flush() - db_session.commit() - db_session.refresh(category) - return category - except Exception as e: - raise e - - def to_dict(self): - return {c.name: getattr(self, c.name) for c in self.__table__.columns} - - def __repr__(self): - return f'' diff --git a/app/main.py b/app/main.py index 5a9cd55d..574b5433 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,8 @@ from app.database.database import engine from app.dependencies import ( MEDIA_PATH, STATIC_PATH, templates) -from app.routers import agenda, categories, email, event, invitation, profile +from app.routers import agenda, categories, dayview, email, event, \ + invitation, profile models.Base.metadata.create_all(bind=engine) @@ -16,6 +17,7 @@ routers_to_include = [ agenda.router, categories.router, + dayview.router, email.router, event.router, invitation.router, From 8e4b569d90e475dc942779fd3ddb86dcec87c07e Mon Sep 17 00:00:00 2001 From: i Date: Mon, 25 Jan 2021 17:40:44 +0200 Subject: [PATCH 107/108] Fix tests --- tests/conftest.py | 2 +- tests/test_dayview.py | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index f9b3cd0f..ccc01fa4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,4 +35,4 @@ def session(): yield session session.rollback() session.close() - Base.metadata.drop_all(bind=engine) + Base.metadata.drop_all(bind=test_engine) diff --git a/tests/test_dayview.py b/tests/test_dayview.py index 71371406..8fc56116 100644 --- a/tests/test_dayview.py +++ b/tests/test_dayview.py @@ -9,7 +9,7 @@ # TODO add user session login @pytest.fixture -def user(): +def user_dayevent(): return User(username='test1', email='user@email.com', password='1a2b3c4e5f', full_name='test me') @@ -84,8 +84,8 @@ def test_div_attributes_with_costume_color(event2): assert div_attr.color == 'blue' -def test_dayview_html(event1, event2, event3, session, user, client): - session.add_all([user, event1, event2, event3]) +def test_dayview_html(event1, event2, event3, session, user_dayevent, client): + session.add_all([user_dayevent, event1, event2, event3]) session.commit() response = client.get("/day/2021-2-1") soup = BeautifulSoup(response.content, 'html.parser') @@ -99,8 +99,9 @@ def test_dayview_html(event1, event2, event3, session, user, client): ("2021-2-2", '1 / 101'), ("2021-2-3", '1 / 57')]) def test_dayview_html_with_multiday_event(multiday_event, session, - user, client, day, grid_position): - session.add_all([user, multiday_event]) + user_dayevent, client, day, + grid_position): + session.add_all([user_dayevent, multiday_event]) session.commit() response = client.get(f"/day/{day}") soup = BeautifulSoup(response.content, 'html.parser') From 8ffa7044ed80b9c883b518b6f5a09367510ce5e7 Mon Sep 17 00:00:00 2001 From: i Date: Mon, 25 Jan 2021 17:42:55 +0200 Subject: [PATCH 108/108] Flake8 --- tests/conftest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index ccc01fa4..28e0dffb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,6 @@ from sqlalchemy.orm import sessionmaker from app.database.database import Base -from app.database.database import engine pytest_plugins = [ 'tests.user_fixture',