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

{{ event.title }}

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

{{ event.title }}

- {% if attr.total_time_visible %} -

{{attr.total_time}}

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

{{ + event.title }}

+ {% if attr.total_time_visible %} +

+ {{attr.total_time}}

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

Time calculator:

+
+ + +
+ + +
+ +

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

It's time to make some decisions

-

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

+

It's time to make some decisions

+

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

-
- -

- -

-

-
+
+ +

+ +

+

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

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

+ + COVID 19 Vaccinated

+

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

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

Say hello to our developers:

+

Say hello to our developers:

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

Upcoming events

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

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


- Last updated {{time}} ago + {{ event.title }} details
-
\ No newline at end of file +
diff --git a/app/templates/partials/user_profile/sidebar_left.html b/app/templates/partials/user_profile/sidebar_left.html index 92c48acb..f2493b43 100644 --- a/app/templates/partials/user_profile/sidebar_left.html +++ b/app/templates/partials/user_profile/sidebar_left.html @@ -3,10 +3,14 @@ {% include 'partials/user_profile/sidebar_left/profile_card.html' %} + + {% include "corona_stats.html" %} + + {% include 'partials/user_profile/sidebar_left/features_card.html' %} {% include 'partials/user_profile/sidebar_left/daily_horoscope.html' %} -
\ No newline at end of file +
diff --git a/app/templates/partials/user_profile/sidebar_left/features_card.html b/app/templates/partials/user_profile/sidebar_left/features_card.html index 946d1040..aaf3f4a2 100644 --- a/app/templates/partials/user_profile/sidebar_left/features_card.html +++ b/app/templates/partials/user_profile/sidebar_left/features_card.html @@ -1,40 +1,43 @@
-
- -

- Explore more features -

- - -
+
+ +

+ Explore more features +

+ + +
diff --git a/app/templates/partials/user_profile/sidebar_left/profile_card/user_details.html b/app/templates/partials/user_profile/sidebar_left/profile_card/user_details.html index 5f85416f..3c67f576 100644 --- a/app/templates/partials/user_profile/sidebar_left/profile_card/user_details.html +++ b/app/templates/partials/user_profile/sidebar_left/profile_card/user_details.html @@ -3,7 +3,7 @@
{{ user.full_name }}

- + Settings

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

{{ user.description }}

-
\ No newline at end of file +
diff --git a/app/templates/profile.html b/app/templates/profile.html index 9e3afc64..1ca1fc06 100644 --- a/app/templates/profile.html +++ b/app/templates/profile.html @@ -1,19 +1,18 @@ -{% extends "base.html" %} +{% extends "partials/index/index_base.html" %} {% include "partials/calendar/event/text_editor_partial_head.html" %} {% block content %} -
-
- - {% include 'partials/user_profile/sidebar_left.html' %} +
+
+ + {% include 'partials/user_profile/sidebar_left.html' %} - - {% include 'partials/user_profile/middle_content.html' %} - - - {% include 'partials/user_profile/sidebar_right.html' %} + + {% include 'partials/user_profile/middle_content.html' %} + + {% include 'partials/user_profile/sidebar_right.html' %} +
-
-{% include "partials/calendar/event/text_editor_partial_body.html" %} + {% include "partials/calendar/event/text_editor_partial_body.html" %} {% endblock content %} diff --git a/app/templates/register.html b/app/templates/register.html index a660b33e..1eb9c602 100644 --- a/app/templates/register.html +++ b/app/templates/register.html @@ -1,12 +1,12 @@ -{% extends "base.html" %} +{% extends "partials/index/index_base.html" %} {% block content %}
-

Please Register

-
+

Please Register

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

Please Register

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

Please Register

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

Hi, {{ recipient }}!

+

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

+

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

+

This confirmation link will expired in 15 minutes

+

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

+

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

diff --git a/app/templates/restore_events.html b/app/templates/restore_events.html new file mode 100644 index 00000000..66b41a98 --- /dev/null +++ b/app/templates/restore_events.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% block head %} + {{ super() }} +{% endblock %} + +{% block content %} +

Restore deleted events

+
+ + + + + + + + + + + + {% for del_event in deleted_events %} + + + + + + + + {% endfor %} + +
#Event IDTitleStart EventEnd Event
+ + + + +
+ + +
+{% endblock %} \ No newline at end of file diff --git a/app/templates/salary/month.j2 b/app/templates/salary/month.j2 index bc240968..1d407192 100644 --- a/app/templates/salary/month.j2 +++ b/app/templates/salary/month.j2 @@ -1,4 +1,4 @@ -{% extends 'base.html' %} +{% extends "partials/index/index_base.html" %} {% block content %}
@@ -35,4 +35,4 @@

Need to alter your settings? Edit your settings here

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

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

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

Want a new category? Create one here

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

{{ category }}

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

- +

Base Salary

Hourly Wage: {{ wage.wage }}

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

Bonus: {{ salary.bonus }}

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

Deductions

Deduction: {{ salary.deduction }}

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

Hello, {{ username }}

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

Hello, {{ username }}

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

Hello, {{ username }}

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

Change your preferences

+
+ +
+
+ + +
+ +
+

Language and region

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

Audio settings

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

View options

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